feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

+96 -24
View File
@@ -50,10 +50,20 @@ app/
│ ├── honey-batches/[id]/
│ ├── storage/
│ ├── search/
│ └── settings/users/ nur Administrator
│ ├── offline/ Offline-Seiten (Start, apiary?id=, colony?id=)
│ ├── nav-config.ts einzige Quelle aller Navigationspunkte
│ └── settings/
│ ├── users-roles/ nur Administrator
│ └── system/ „Verwaltung > System“ (Jobs, Ereignisse, Laufrollen)
```
Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet Auswahl-Sheet (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld.
Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-Button in der mobilen Bottom-Nav, öffnet ein Auswahl-Sheet mit Icons und 56-px-Zeilen (auch am Desktop; `quick-add-sheet.tsx`, Einträge aus `quick-actions.ts`) (Durchsicht, Fütterung, Behandlung, Varroamessung, Aufgabe, Ernte, Volk, Königin) → direkt zum jeweiligen Schnellerfassungs-Formular, mobil möglichst 1-2 Taps bis zum ersten Eingabefeld.
**Navigation aus einer Quelle:** `app/(app)/nav-config.ts` ist die einzige Quelle für Seitenleiste, Bottom-Nav, „Mehr“-Sheet und „+ Neu“; der Berechtigungsfilter gilt überall gleich. Neue Seiten (auch „Verwaltung > System“) werden nur dort eingetragen. Auf allen `new`-Seiten führt ein Zurück-Link zur Liste, bei einem Entwurf mit Rückfrage. „Mehr“ und „+ Neu“ teilen sich die Sheet-Hülle `components/nav/nav-sheet.tsx`.
**Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/<id>`, `/colonies/<id>`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`).
**Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.
Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein direkter DB-Zugriff, kein Next.js-eigenes Datenbank-Layer) – erzwingt API-first auch praktisch.
@@ -93,15 +103,15 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
2. **Treatment auf Einzelvolk statt "Völkergruppe"**: Abschnitt 4.11 erlaubt "Volk oder Völkergruppe". MVP modelliert `Treatment` pro Volk (`colonyId` Pflichtfeld); eine Gruppenbehandlung wird im MVP als mehrere Treatment-Datensätze mit gleicher `batchNumber`/Notiz erfasst statt eigener Gruppen-Entität. Spätere Migration kann eine `TreatmentGroup`-Entität nachziehen, falls das in der Praxis zu unhandlich ist.
3. **Lagerverwaltung MVP-Umfang**: "grundlegende Lagerverwaltung" (Abschnitt 26 Punkt 12) wird als generisches `StockItem` + `StockMovement` (Kategorie Honig/Verbrauchsmaterial/Imkereimaterial) umgesetzt. Das detaillierte Inventar mit Wartungsdaten (Abschnitt 4.22) bleibt v1.x/"SOLL", wie im Lastenheft vorgesehen.
4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
5. **Backup-fähige Struktur**: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), anwendungsseitiges Backup (`BackupModule`, Abschnitt 8) und der CronJob `apicore-db-backup` mit `pg_dump` (Abschnitt 7), `/health`-Endpoint für Betriebsüberwachung.
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster, das die Worker-Schleife `calendar-outbox` abarbeitet (alle 60 s und auf `pg_notify('calendar_sync', '')`, siehe Abschnitt 10): Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Abmelden eines Eintrags aus dem Kalender geschieht durch erneutes Einreihen: Wird eine Quelle (z. B. eine Aufgabe, die erledigt oder storniert wird oder ihr Fälligkeitsdatum verliert, oder ein gelöschter Datensatz) erneut in die Kalender-Outbox eingereiht, findet der Worker in `buildEventInput` nichts mehr anzuzeigen. Hat der Eintrag eine `graphEventId`, löscht der Worker das Graph-Event (404/410 gelten als bereits gelöscht, jeder andere Fehler zählt als fehlgeschlagener Versuch) und setzt den Eintrag auf `SKIPPED` mit `graphEventId = null`. Die API ruft Graph dafür nie auf; die Einreihung selbst erfolgt in der Handler-Transaktion der Domain-Events (`calendar-sync.*`-Handler über `CalendarSyncService.enqueueIn(tx, …)`). Nur die Königin-Zuordnung reiht noch nach dem Commit über `CalendarSyncService.enqueue` ein (Annahme 11). Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden.
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`).
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt.
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`), den Webhook `colony.created` und den Kalendereintrag der Königin-Zuordnung (`CalendarSyncService.enqueue`). Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`, `queen.replaced`), Honigchargen (`batch.created`) und Lager (`product.stock_changed`). Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`.
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Geprüft wird unter einer Zeilensperre auf der Regel (`SELECT … FOR UPDATE`), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Ihr Standardwert 10 und der Rückfall bei unbrauchbarer Einstellung kommen allein aus `varroaThresholdOf` (`automation/varroa-threshold.ts`). Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Automatik-Aufgaben laufen wie jede andere Aufgabe über `TasksService.createInTx(tx, …, null)`: Audit-Eintrag ohne Benutzer und `TaskCreated`. Aus dem Ereignis folgen Kalendereintrag (`calendar-sync.task`) und Webhook `task.created` (`webhooks.forward`); die Regel ruft `WebhooksService` nicht selbst auf. Aufgabe, Warnung (`Notification`, Priorität `WARNUNG`) und der Audit-Eintrag der Regel (`AutomationRule CREATE`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) 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`, derzeit nur `shop-sync:read`). 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`): Ein Schlüssel mit beiden Rechten wird beim Anlegen mit 400 abgelehnt; bereits ausgestellte kombinierte Schlüssel bleiben gültig, die Oberfläche markiert sie und empfiehlt zwei getrennte Schlüssel (neue anlegen, in Wabenhain eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen.
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).
@@ -114,21 +124,15 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
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, Job `rekey` laufen lassen, dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen.
**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).
## Concerns
1. **Rollout order (blocking for production).** The live `apicore-backend-secret` must contain `MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY` and `BACKUP_ENCRYPTION_KEY` *before* this image runs with `NODE_ENV=production`. Without them, api, worker and job all refuse to start (by design).
- The three keys must be distinct and must not equal the JWT secrets.
- The pods get them via `envFrom: secretRef`, so no manifest change is needed.
- Per the owner's rule, the user creates the secrets.
2. **JWT rotation after Task 21 will strand old backup files.** Backups sealed before `BACKUP_ENCRYPTION_KEY` was set (JWT-derived keyId) become unrestorable once `JWT_ACCESS_SECRET` rotates.
- Remedy: take a fresh export after setting the key, before rotating. This is in the Annahme 22 text.
- Task 21 or the runbook should state this ordering explicitly.
3. **Dev-only edge.** Values sealed with the dev fallback (no env key set) become unreadable if a developer later sets an explicit key: the fallback cannot be named as `*_PREVIOUS`. This does not affect production, which never uses the fallback.
4. **Not checked in production:** `*_PREVIOUS` values are not checked for equality with the JWT or other keys; only their length is enforced. This matches the brief, which limits that rule to the three main variables.
5. **Not run:** whole-project `npx eslint "{src,test}/**/*.ts"` (I linted only my files). Typecheck was project-wide and clean.
**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.
## 7. Kubernetes
@@ -205,10 +209,10 @@ Scheitert die Migration, scheitert der initContainer und der neue Pod startet ni
**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), 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.
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)
@@ -222,7 +226,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`, `shop-sync:write`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Lesen und Buchen sind getrennte Schlüssel: `ApiKeysService.create` lehnt einen Schlüssel mit beiden Shop-Rechten mit 400 ab; bereits ausgestellte kombinierte Schlüssel bleiben gültig und werden in der Oberfläche mit Hinweis markiert |
| 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 | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf |
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
@@ -253,5 +257,73 @@ Die Wirkung des Handlers und `DONE` werden gemeinsam committet. Die Exklusivitä
**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).
+4 -20
View File
@@ -15,7 +15,6 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^12.0.1",
"@nestjs/swagger": "^11.4.7",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.9.1",
@@ -24,6 +23,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"cron": "^4.4.0",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
@@ -2480,22 +2480,6 @@
"@nestjs/core": "^11.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.1.tgz",
"integrity": "sha512-v0z/li9L8oxpvuIDAGm5uOLaZ8KzsfZRzgWgppvs5vClneSinZP5IFl3C1F1DCeTCkNLiGN9g/oLUvzg8x++RQ==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0 || ^12.0.0",
"@nestjs/core": "^11.0.0 || ^12.0.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz",
@@ -3603,9 +3587,9 @@
"license": "MIT"
},
"node_modules/@types/luxon": {
"version": "3.7.5",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
"integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==",
"version": "3.7.6",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz",
"integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==",
"license": "MIT"
},
"node_modules/@types/methods": {
+2 -2
View File
@@ -31,7 +31,6 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^12.0.1",
"@nestjs/swagger": "^11.4.7",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.9.1",
@@ -40,6 +39,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"cron": "^4.4.0",
"helmet": "^8.3.0",
"openid-client": "^6.8.4",
"otplib": "^13.5.0",
@@ -96,7 +96,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)"
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron)/)"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
+1 -1
View File
@@ -544,7 +544,7 @@ enum TaskTemplateRecurrenceType {
}
// Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task,
// sobald nextDueDate erreicht ist (siehe TasksService.generateDueTasks).
// sobald nextDueDate erreicht ist (siehe TasksService.generateFromTemplates).
model TaskTemplate {
id String @id @default(cuid())
title String
+2 -4
View File
@@ -5,7 +5,6 @@ import { ThrottlerModule } from '@nestjs/throttler';
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
import { PrismaThrottlerStorage } from './common/throttler/prisma-throttler.storage';
import { ThrottlerStorageModule } from './common/throttler/throttler-storage.module';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
@@ -53,16 +52,15 @@ import { SiteSettingsModule } from './site-settings/site-settings.module';
import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { SystemModule } from './system/system.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
ThrottlerModule.forRootAsync({
imports: [ThrottlerStorageModule],
inject: [PrismaThrottlerStorage],
@@ -119,6 +117,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
BackupModule,
ShopSyncModule,
ApiKeysModule,
SystemModule,
],
controllers: [],
providers: [
@@ -128,7 +127,6 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
IdempotencyCleanupService,
],
})
export class AppModule {}
@@ -361,6 +361,15 @@ export const PERMISSION_CATALOG = [
[['manage', 'Anlegen, ansehen und widerrufen']],
3400,
),
...group(
'System',
'system',
[
['read', 'Hintergrundjobs und Warteschlangen ansehen'],
['manage', 'Fehlgeschlagene Ereignisse erneut anstoßen'],
],
3500,
),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -0,0 +1,253 @@
import { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import type { StockService } from '../stock/stock.service';
import { AutomationEventHandlers } from './automation.handlers';
import type { AutomationService } from './automation.service';
type RecordModel =
'inspection' | 'varroaMeasurement' | 'treatment' | 'feeding' | 'colony';
const RECORD_MODELS: readonly RecordModel[] = [
'inspection',
'varroaMeasurement',
'treatment',
'feeding',
'colony',
];
function envelope<T extends DomainEventType>(
type: T,
aggregateId: string,
payload: DomainEventEnvelope<T>['payload'],
): AnyDomainEventEnvelope<T> {
return {
id: 'e1',
type,
aggregateType: 'Inspection',
aggregateId,
payload,
actorUserId: 'u1',
correlationId: 'corr-1',
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
attempts: 0,
} as unknown as AnyDomainEventEnvelope<T>;
}
describe('AutomationEventHandlers', () => {
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
let automation: Record<
| 'evaluateInspectionIn'
| 'evaluateVarroaMeasurementIn'
| 'evaluateTreatmentIn'
| 'evaluateFeedingIn'
| 'evaluateColonyHealthIn'
| 'evaluateRawMaterialLowIn',
jest.Mock
>;
let stock: { remainingByRawMaterialKind: jest.Mock };
let handlers: AutomationEventHandlers;
const asTx = () => tx as unknown as Prisma.TransactionClient;
beforeEach(() => {
tx = Object.fromEntries(
RECORD_MODELS.map((model) => [
model,
{ findUnique: jest.fn().mockResolvedValue(null) },
]),
) as Record<RecordModel, { findUnique: jest.Mock }>;
automation = {
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
evaluateColonyHealthIn: jest.fn().mockResolvedValue(undefined),
evaluateRawMaterialLowIn: jest.fn().mockResolvedValue(undefined),
};
stock = { remainingByRawMaterialKind: jest.fn() };
handlers = new AutomationEventHandlers(
automation as unknown as AutomationService,
stock as unknown as StockService,
);
});
it('registers the stable handler ids and event types', () => {
const registered = (method: keyof AutomationEventHandlers) =>
domainEventHandlerMetadata(
Reflect.get(AutomationEventHandlers.prototype, method),
);
expect(registered('inspectionRules')).toEqual([
{
types: ['InspectionRecorded'],
handlerId: 'automation.inspection-rules',
},
]);
expect(registered('varroaThreshold')).toEqual([
{
types: ['VarroaMeasured', 'VarroaCorrected'],
handlerId: 'automation.varroa-threshold',
},
]);
expect(registered('treatmentFollowUp')).toEqual([
{
types: ['TreatmentRecorded'],
handlerId: 'automation.treatment-follow-up',
},
]);
expect(registered('feedingFollowUp')).toEqual([
{ types: ['FeedingRecorded'], handlerId: 'automation.feeding-follow-up' },
]);
expect(registered('colonyHealth')).toEqual([
{ types: ['ColonyStatusChanged'], handlerId: 'automation.colony-health' },
]);
expect(registered('rawMaterialLow')).toEqual([
{ types: ['StockChanged'], handlerId: 'automation.raw-material-low' },
]);
});
describe('record handlers', () => {
const cases = [
[
'inspectionRules',
'InspectionRecorded',
'inspection',
'evaluateInspectionIn',
],
[
'varroaThreshold',
'VarroaMeasured',
'varroaMeasurement',
'evaluateVarroaMeasurementIn',
],
[
'varroaThreshold',
'VarroaCorrected',
'varroaMeasurement',
'evaluateVarroaMeasurementIn',
],
[
'treatmentFollowUp',
'TreatmentRecorded',
'treatment',
'evaluateTreatmentIn',
],
['feedingFollowUp', 'FeedingRecorded', 'feeding', 'evaluateFeedingIn'],
] as const;
const run = (
method: (typeof cases)[number][0],
type: (typeof cases)[number][1],
) =>
handlers[method](
envelope(type, 'record-1', { colonyId: 'colony-1' }) as never,
asTx(),
);
it.each(cases)(
'handlers load the record via tx and evaluate it: %s on %s',
async (method, type, model, core) => {
const record = { id: 'record-1', colonyId: 'colony-1' };
tx[model].findUnique.mockResolvedValueOnce(record);
await run(method, type);
expect(tx[model].findUnique).toHaveBeenCalledWith({
where: { id: 'record-1' },
});
expect(automation[core]).toHaveBeenCalledWith(tx, record);
},
);
it.each(cases)(
'handlers load the record via tx and skip a missing one: %s on %s',
async (method, type, model, core) => {
await run(method, type);
expect(tx[model].findUnique).toHaveBeenCalledTimes(1);
expect(automation[core]).not.toHaveBeenCalled();
},
);
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' });
automation.evaluateInspectionIn.mockRejectedValue(
new Error('notifications down'),
);
await expect(
run('inspectionRules', 'InspectionRecorded'),
).rejects.toThrow('notifications down');
});
});
describe('colony-health', () => {
const from = { status: 'ACTIVE', healthStatus: 'AVERAGE' } as const;
const to = { status: 'ACTIVE', healthStatus: 'CRITICAL' } as const;
const event = () =>
envelope('ColonyStatusChanged', 'colony-1', { from, to });
it('hands the transition of the event to evaluateColonyHealthIn', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
await handlers.colonyHealth(event(), asTx());
expect(tx.colony.findUnique).toHaveBeenCalledWith({
where: { id: 'colony-1' },
select: { id: true },
});
// The rule decides on the snapshots of the event, not on the colony
// as it is now: a later change must not hide the transition.
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
tx,
'colony-1',
from,
to,
);
});
it('skips a colony that no longer exists', async () => {
await handlers.colonyHealth(event(), asTx());
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
});
});
describe('raw-material-low', () => {
const stockChanged = (rawMaterialKinds: ('BEESWAX' | 'PROPOLIS')[]) =>
envelope('StockChanged', 'movement-1', {
bottlingBatchIds: [],
productIds: [],
rawMaterialKinds,
});
it('evaluates every touched kind once with what is left of it', async () => {
stock.remainingByRawMaterialKind.mockImplementation((kind: string) =>
Promise.resolve(kind === 'BEESWAX' ? 400 : 75),
);
await handlers.rawMaterialLow(
stockChanged(['BEESWAX', 'PROPOLIS', 'BEESWAX']),
asTx(),
);
expect(stock.remainingByRawMaterialKind).toHaveBeenCalledTimes(2);
expect(automation.evaluateRawMaterialLowIn.mock.calls).toEqual([
[tx, 'BEESWAX', 400],
[tx, 'PROPOLIS', 75],
]);
});
it('does nothing for a change without raw material', async () => {
await handlers.rawMaterialLow(stockChanged([]), asTx());
expect(stock.remainingByRawMaterialKind).not.toHaveBeenCalled();
expect(automation.evaluateRawMaterialLowIn).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { Prisma } from '../../generated/prisma';
import { StockService } from '../stock/stock.service';
import { AutomationService } from './automation.service';
type Tx = Prisma.TransactionClient;
/**
* Evaluates the automation rules on domain events. Pure DB writer: it reads
* the record the event is about through the delivery transaction and lets
* AutomationService write task, warning and audit entries in it. A record
* deleted before the event was dispatched is skipped. Errors propagate, so
* the delivery is rolled back as a whole and retried.
*/
@Injectable()
export class AutomationEventHandlers {
constructor(
private readonly automation: AutomationService,
private readonly stock: StockService,
) {}
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
async inspectionRules(
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
tx: Tx,
): Promise<void> {
const inspection = await tx.inspection.findUnique({
where: { id: event.aggregateId },
});
if (inspection === null) {
return;
}
await this.automation.evaluateInspectionIn(tx, inspection);
}
// A correction can lift a count over the threshold, so it is evaluated
// like a new measurement; the open-task check keeps it to one task.
@OnDomainEvent(
['VarroaMeasured', 'VarroaCorrected'],
'automation.varroa-threshold',
)
async varroaThreshold(
event: AnyDomainEventEnvelope<'VarroaMeasured' | 'VarroaCorrected'>,
tx: Tx,
): Promise<void> {
const measurement = await tx.varroaMeasurement.findUnique({
where: { id: event.aggregateId },
});
if (measurement === null) {
return;
}
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
}
@OnDomainEvent('TreatmentRecorded', 'automation.treatment-follow-up')
async treatmentFollowUp(
event: AnyDomainEventEnvelope<'TreatmentRecorded'>,
tx: Tx,
): Promise<void> {
const treatment = await tx.treatment.findUnique({
where: { id: event.aggregateId },
});
if (treatment === null) {
return;
}
await this.automation.evaluateTreatmentIn(tx, treatment);
}
@OnDomainEvent('FeedingRecorded', 'automation.feeding-follow-up')
async feedingFollowUp(
event: AnyDomainEventEnvelope<'FeedingRecorded'>,
tx: Tx,
): Promise<void> {
const feeding = await tx.feeding.findUnique({
where: { id: event.aggregateId },
});
if (feeding === null) {
return;
}
await this.automation.evaluateFeedingIn(tx, feeding);
}
// Decides on the snapshots the event carries, not on the colony as it is
// now: a later edit must not hide the transition into CRITICAL.
@OnDomainEvent('ColonyStatusChanged', 'automation.colony-health')
async colonyHealth(
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
tx: Tx,
): Promise<void> {
const colony = await tx.colony.findUnique({
where: { id: event.aggregateId },
select: { id: true },
});
if (colony === null) {
return;
}
await this.automation.evaluateColonyHealthIn(
tx,
colony.id,
event.payload.from,
event.payload.to,
);
}
// Per kind the change touched, what is left of it across all its lots.
@OnDomainEvent('StockChanged', 'automation.raw-material-low')
async rawMaterialLow(
event: AnyDomainEventEnvelope<'StockChanged'>,
tx: Tx,
): Promise<void> {
for (const kind of new Set(event.payload.rawMaterialKinds)) {
const remaining = await this.stock.remainingByRawMaterialKind(kind);
await this.automation.evaluateRawMaterialLowIn(tx, kind, remaining);
}
}
}
@@ -1,12 +1,20 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { StockModule } from '../stock/stock.module';
import { TasksModule } from '../tasks/tasks.module';
import { AutomationController } from './automation.controller';
import { AutomationEventHandlers } from './automation.handlers';
import { AutomationService } from './automation.service';
/**
* Automation rules and the handlers that evaluate them on domain events.
* Rule tasks go through TasksService (audit entry, TaskCreated - and with
* it calendar entry and task.created webhook), so no WebhooksService here.
*/
@Module({
imports: [NotificationsModule],
imports: [TasksModule, NotificationsModule, StockModule],
controllers: [AutomationController],
providers: [AutomationService],
providers: [AutomationService, AutomationEventHandlers],
exports: [AutomationService],
})
export class AutomationModule {}
File diff suppressed because it is too large. Load diff
@@ -1,13 +1,14 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { assertTransactionClient, DbClient } from '../prisma/db-client';
import { runAfterCommit } from '../common/after-commit';
import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { TasksService } from '../tasks/tasks.service';
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
import {
AutomationRule,
AutomationTriggerType,
Colony,
Inspection,
Feeding,
Prisma,
@@ -20,6 +21,9 @@ import {
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
import { varroaThresholdOf } from './varroa-threshold';
type Tx = Prisma.TransactionClient;
/** The rules that fill in a batch/lot number instead of creating tasks. */
export type NumberFormatTrigger =
@@ -171,29 +175,33 @@ export class AutomationService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly notifications: NotificationsService,
private readonly webhooks: WebhooksService,
private readonly tasks: TasksService,
) {}
private async ensureDefaults(): Promise<void> {
for (const triggerType of Object.keys(
DEFAULT_RULES,
) as AutomationTriggerType[]) {
const defaults = DEFAULT_RULES[triggerType];
await this.prisma.automationRule.upsert({
where: { triggerType },
update: {},
create: {
/**
* Creates the rules that do not exist yet, on the given client - inside a
* handler or legacy transaction too. One INSERT … ON CONFLICT DO NOTHING:
* existing rules keep their settings, and their rows are not locked
* (an upsert would hold all of them until the evaluation commits).
*/
private async ensureDefaults(db: DbClient): Promise<void> {
const triggerTypes = Object.keys(DEFAULT_RULES) as AutomationTriggerType[];
await db.automationRule.createMany({
data: triggerTypes.map((triggerType) => {
const defaults = DEFAULT_RULES[triggerType];
return {
triggerType,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
});
}
};
}),
skipDuplicates: true,
});
}
async findAll(): Promise<AutomationRuleWithSequences[]> {
await this.ensureDefaults();
await this.ensureDefaults(this.prisma);
return this.prisma.automationRule.findMany({
orderBy: { triggerType: 'asc' },
include: {
@@ -244,141 +252,140 @@ export class AutomationService {
return rule;
}
/**
* Every evaluate*() runs after the triggering record is committed and is
* called by the domain services without a guard - so it never throws (see
* runAfterCommit). A failed evaluation is logged, the request succeeds.
*/
private evaluateAfterCommit(
entityType: string,
entityId: string,
evaluate: () => Promise<void>,
): Promise<void> {
return runAfterCommit(
this.logger,
{ action: 'Automation rule evaluation', entityType, entityId },
evaluate,
);
}
// -------------------------------------------------------------------------
// Rule evaluation
//
// One core per trigger, on the caller's transaction: the domain event
// handlers (AutomationEventHandlers) call it with their delivery
// transaction, the legacy wrappers below with a transaction of their own.
// -------------------------------------------------------------------------
async evaluateInspection(inspection: Inspection): Promise<void> {
await this.evaluateAfterCommit('Inspection', inspection.id, async () => {
await this.ensureDefaults();
const rules = await this.prisma.automationRule.findMany({
where: {
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
active: true,
},
});
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
if (
queenlessRule &&
inspection.queenSeen === false &&
inspection.eggsPresent === false
) {
await this.triggerAction(
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
await this.triggerAction(
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
await this.ensureDefaults(tx);
const rules = await tx.automationRule.findMany({
where: {
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
active: true,
},
});
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
if (
queenlessRule &&
inspection.queenSeen === false &&
inspection.eggsPresent === false
) {
await this.triggerActionIn(
tx,
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
await this.triggerActionIn(
tx,
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
}
async evaluateVarroaMeasurement(
async evaluateVarroaMeasurementIn(
tx: Tx,
measurement: VarroaMeasurement,
): Promise<void> {
await this.evaluateAfterCommit(
'VarroaMeasurement',
measurement.id,
async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
});
if (!rule || !rule.active) {
return;
}
// The threshold is a natural mite fall rate (mites per day). Only
// that method yields a value in this unit - a mite count from a
// sugar roll or wash is per bee sample and would be compared against
// the wrong scale, as would a raw board count without the number of
// days it covered.
if (measurement.method !== 'NATURAL_MITE_FALL') {
return;
}
const config = rule.config as { thresholdMitesPerDay?: number } | null;
const threshold = config?.thresholdMitesPerDay ?? 10;
const value = measurement.calculatedValue;
if (value !== null && value >= threshold) {
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
}
},
assertTransactionClient(
tx,
'AutomationService.evaluateVarroaMeasurementIn',
);
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
});
// null for an inactive rule; the one source of the default value.
const threshold = varroaThresholdOf(rule);
if (!rule || threshold === null) {
return;
}
// The threshold is a natural mite fall rate (mites per day). Only that
// method yields a value in this unit - a mite count from a sugar roll or
// wash is per bee sample and would be compared against the wrong scale,
// as would a raw board count without the number of days it covered.
if (measurement.method !== 'NATURAL_MITE_FALL') {
return;
}
const value = measurement.calculatedValue;
if (value !== null && value >= threshold) {
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
}
}
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerAction(rule, 'COLONY', check.colonyId);
}
async evaluateSwarmControlCheckIn(
tx: Tx,
check: SwarmControlCheck,
): Promise<void> {
assertTransactionClient(
tx,
'AutomationService.evaluateSwarmControlCheckIn',
);
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
}
}
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
await this.evaluateAfterCommit('Colony', after.id, async () => {
await this.ensureDefaults();
if (
after.healthStatus !== 'CRITICAL' ||
before.healthStatus === 'CRITICAL'
) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', after.id);
async evaluateColonyHealthIn(
tx: Tx,
colonyId: string,
from: ColonyStateSnapshot,
to: ColonyStateSnapshot,
): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
// ColonyStatusChanged comes with every status or health edit: decide on
// the snapshots first, so most of them cost no statement at all.
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
return;
}
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'COLONY', colonyId);
}
async evaluateStockItem(item: StockItem): Promise<void> {
await this.evaluateAfterCommit('StockItem', item.id, async () => {
await this.ensureDefaults();
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'STORAGE', item.id);
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
// Every movement of the item comes here: only one below its minimum
// needs the rule.
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return;
}
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'STORAGE', item.id);
}
/**
@@ -388,73 +395,112 @@ export class AutomationService {
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
* keeps one open task per kind.
*/
async evaluateRawMaterialLow(
async evaluateRawMaterialLowIn(
tx: Tx,
kind: RawMaterialKind,
remainingGrams: number,
): Promise<void> {
await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
return;
}
const config = rule.config as {
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return;
}
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
});
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
return;
}
const config = rule.config as {
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return;
}
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
});
}
async evaluateTreatment(treatment: Treatment): Promise<void> {
await this.evaluateAfterCommit('Treatment', treatment.id, () =>
this.evaluateFollowUp(
'TREATMENT_FOLLOW_UP',
treatment.colonyId,
treatment.endDate ?? treatment.date,
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
),
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
const measureDate = treatment.endDate ?? treatment.date;
await this.evaluateFollowUpIn(
tx,
'TREATMENT_FOLLOW_UP',
treatment.colonyId,
measureDate,
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`,
);
}
async evaluateFeeding(feeding: Feeding): Promise<void> {
await this.evaluateAfterCommit('Feeding', feeding.id, () =>
this.evaluateFollowUp(
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
),
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
await this.evaluateFollowUpIn(
tx,
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
);
}
/** A check counted from the measure's own date, not from today. */
private async evaluateFollowUp(
/**
* A check counted from the measure's own date, not from today. Its public
* callers check the transaction client, so an error names them.
*/
private async evaluateFollowUpIn(
tx: Tx,
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
colonyId: string,
measureDate: Date,
description: string,
): Promise<void> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', colonyId, undefined, {
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
baseDate: measureDate,
description,
});
}
// -------------------------------------------------------------------------
// Legacy paths
//
// Only SWARM_MOOD_HIGH and STOCK_ITEM_LOW remain: the swarm control and
// stock modules record no event for them yet. Called after the write is
// committed, without a guard - so they never throw (see runAfterCommit): a
// failed evaluation is logged, the request succeeds. Each runs the same
// core as the handlers, in one transaction of its own.
// -------------------------------------------------------------------------
private evaluateAfterCommit(
entityType: string,
entityId: string,
evaluate: (tx: Tx) => Promise<void>,
): Promise<void> {
return runAfterCommit(
this.logger,
{ action: 'Automation rule evaluation', entityType, entityId },
() => this.prisma.$transaction((tx) => evaluate(tx)),
);
}
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
await this.evaluateAfterCommit('SwarmControlCheck', check.id, (tx) =>
this.evaluateSwarmControlCheckIn(tx, check),
);
}
async evaluateStockItem(item: StockItem): Promise<void> {
await this.evaluateAfterCommit('StockItem', item.id, (tx) =>
this.evaluateStockItemIn(tx, item),
);
}
// Returns null (never throws) when the rule is inactive or has no format
// configured, so the caller can fall back to requiring manual entry.
//
@@ -466,7 +512,7 @@ export class AutomationService {
triggerType: NumberFormatTrigger,
options: { scope?: string; vars?: Record<string, string> } = {},
): Promise<string | null> {
await this.ensureDefaults();
await this.ensureDefaults(this.prisma);
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType },
});
@@ -547,75 +593,76 @@ export class AutomationService {
}
/**
* Leaves a suggestion task (never a record of work done) plus a warning.
* Leaves a suggestion task (never a record of work done) plus a warning,
* all on the caller's transaction: task, its audit entry and TaskCreated
* (TasksService.createInTx - the task.created webhook and the calendar
* entry follow from that event), the warning and the rule's audit entry.
* A failing write rolls back all of it; a handler delivery is retried.
*
* At most one open task per rule and object: while the reorder task for a
* stock item or the check task for a colony is still open, further triggers
* (the next removal below minimum, a second high varroa count) add nothing.
* Repeating the same alarm would only bury the one task that matters.
*
* Check and create run in one transaction under a lock on the rule's row:
* two evaluations at the same moment (parallel requests, several pods)
* would otherwise both see "no open task" and both create one. The lock
* is per rule and held for two short statements; webhook, warning and
* audit entry follow after the commit.
* The check runs under a lock on the rule's row, held until the caller
* commits: two evaluations at the same moment (parallel requests, a
* handler and a legacy path, several pods) would otherwise both see
* "no open task" and both create one.
*/
private async triggerAction(
private async triggerActionIn(
tx: Tx,
rule: AutomationRule,
relatedType: TaskRelatedType,
relatedId: string,
sourceInspectionId?: string,
options: { baseDate?: Date; description?: string } = {},
): Promise<void> {
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
const alreadyOpen = await tx.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return null;
}
return tx.task.create({
data: {
title,
priority: 'HIGH',
relatedType,
relatedId,
sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
},
});
});
if (!task) {
return;
}
await this.webhooks.dispatch('task.created', task);
await this.notifications.record({
priority: 'WARNUNG',
message: title,
relatedType,
relatedId,
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: {
taskId: task.id,
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
const alreadyOpen = await tx.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
triggerType: rule.triggerType,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return;
}
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.tasks.createInTx(
tx,
{
title,
priority: 'HIGH',
relatedType,
relatedId,
sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
},
null,
);
await this.notifications.record(
{ priority: 'WARNUNG', message: title, relatedType, relatedId },
tx,
);
await this.auditLog.record(
{
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: {
taskId: task.id,
relatedType,
relatedId,
triggerType: rule.triggerType,
},
},
tx,
);
}
}
@@ -0,0 +1,82 @@
import { runInNewContext } from 'node:vm';
import { BACKUP_SCHEDULE_TIME_ZONE, isBackupDue } from './backup-due';
describe('isBackupDue', () => {
it('reads the schedule in Europe/Berlin by default', () => {
expect(BACKUP_SCHEDULE_TIME_ZONE).toBe('Europe/Berlin');
});
it('is due when there is no backup yet', () => {
expect(
isBackupDue('0 3 * * *', null, new Date('2026-10-13T00:30:00Z')),
).toBe(true);
});
it('is not due again on the same day for "0 3 * * *"', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-12T01:00:00Z'),
new Date('2026-10-13T00:30:00Z'),
),
).toBe(false));
it('is due after the next 03:00 Europe/Berlin', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-12T01:00:00Z'),
new Date('2026-10-13T01:05:00Z'),
),
).toBe(true));
it('is due exactly at the next scheduled time', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-12T01:00:00Z'),
new Date('2026-10-13T01:00:00Z'),
),
).toBe(true));
it('counts a backup written after the scheduled time (e.g. "Jetzt sichern") as covering it', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-13T01:10:00Z'),
new Date('2026-10-13T02:00:00Z'),
),
).toBe(false));
it('stays due after a missed run until a backup exists', () =>
expect(
isBackupDue(
'0 3 * * *',
new Date('2026-10-10T01:00:00Z'),
new Date('2026-10-13T05:00:00Z'),
),
).toBe(true));
it('uses the given time zone', () => {
const lastBackupAt = new Date('2026-10-12T03:00:00Z');
const now = new Date('2026-10-13T02:00:00Z');
// Next 03:00 UTC is 03:00Z, next 03:00 Berlin was 01:00Z.
expect(isBackupDue('0 3 * * *', lastBackupAt, now, 'UTC')).toBe(false);
expect(isBackupDue('0 3 * * *', lastBackupAt, now)).toBe(true);
});
// fs.Stats.mtime is created by Node's core; under Jest it is not an
// instance of the test's Date (cron checks `instanceof Date`).
it('accepts a Date from another realm', () => {
const foreign = runInNewContext('new Date("2026-10-12T01:00:00Z")') as Date;
expect(
isBackupDue('0 3 * * *', foreign, new Date('2026-10-13T00:30:00Z')),
).toBe(false);
});
it('rejects an invalid schedule even without a backup', () => {
expect(() => isBackupDue('every night', null, new Date())).toThrow();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { CronTime } from 'cron';
/**
* The time zone a backup schedule is read in - the same as the CronJobs'
* `timeZone`, so "0 3 * * *" means 03:00 in Germany, summer and winter.
*/
export const BACKUP_SCHEDULE_TIME_ZONE = 'Europe/Berlin';
/**
* Whether the hourly app-backup check has to write a backup: true while no
* backup exists, otherwise once the first scheduled time after the newest
* backup has come. A backup written after a scheduled time (a manual
* "Jetzt sichern", or a late run) covers that time; a missed or failed run
* stays due until a backup exists. Throws on an invalid schedule, also
* without a backup, so a broken schedule fails the run instead of being
* skipped silently.
*/
export function isBackupDue(
cronSchedule: string,
lastBackupAt: Date | null,
now: Date,
timeZone: string = BACKUP_SCHEDULE_TIME_ZONE,
): boolean {
const schedule = new CronTime(cronSchedule, timeZone);
if (lastBackupAt === null) {
return true;
}
// A copy: fs.Stats.mtime comes from another realm than cron's Date check.
const next = schedule.getNextDateFrom(
new Date(lastBackupAt.getTime()),
timeZone,
);
return next.toMillis() <= now.getTime();
}
@@ -1,9 +1,12 @@
import { Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import * as path from 'node:path';
import { Prisma } from '../../generated/prisma';
import { BackupSchedulerService } from './backup-scheduler.service';
import { JobRun } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import {
APP_BACKUP_LOCK_TIMEOUT_MS,
BackupSchedulerService,
} from './backup-scheduler.service';
import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service';
@@ -17,12 +20,30 @@ jest.mock('node:fs/promises', () => ({
import * as fs from 'node:fs/promises';
type RawCall = [readonly string[], ...unknown[]];
const SEALED = {
format: 'apicore-backup',
exportedAt: '2026-10-13T01:05:00.000Z',
ciphertext: 'x'.repeat(2000),
};
describe('BackupSchedulerService', () => {
const originalEnv = { ...process.env };
const directory = path.resolve('/data/backups');
let service: BackupSchedulerService;
let backupService: { exportSealed: jest.Mock };
let settings: { get: jest.Mock };
let tx: {
$executeRaw: jest.Mock<Promise<number>, RawCall>;
$queryRaw: jest.Mock<Promise<{ now: Date }[]>, RawCall>;
};
let prisma: { $transaction: jest.Mock };
let jobRuns: { recent: jest.Mock };
let error: jest.SpyInstance;
let warn: jest.SpyInstance;
const sqlText = (call: RawCall): string => call[0].join('?');
beforeEach(() => {
jest.clearAllMocks();
@@ -31,33 +52,43 @@ describe('BackupSchedulerService', () => {
delete process.env.BACKUP_RETENTION_COUNT;
delete process.env.BACKUP_CRON_SCHEDULE;
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
backupService = {
exportSealed: jest.fn().mockResolvedValue({
format: 'apicore-backup',
exportedAt: '2026-10-12T03:00:00.000Z',
ciphertext: 'x'.repeat(2000),
}),
};
const settings = {
backupService = { exportSealed: jest.fn().mockResolvedValue(SEALED) };
settings = {
get: jest.fn().mockResolvedValue({
storagePath: null,
cronSchedule: null,
retentionCount: null,
}),
};
tx = {
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
$queryRaw: jest
.fn<Promise<{ now: Date }[]>, RawCall>()
.mockResolvedValue([{ now: new Date('2026-10-13T01:05:00Z') }]),
};
prisma = {
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
};
jobRuns = { recent: jest.fn().mockResolvedValue([]) };
service = new BackupSchedulerService(
backupService as unknown as BackupService,
settings as unknown as BackupSettingsService,
{} as SchedulerRegistry,
prisma as unknown as PrismaService,
jobRuns as unknown as JobRunService,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.readdir as jest.Mock).mockResolvedValue([]);
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
});
afterEach(() => {
error.mockRestore();
warn.mockRestore();
process.env = { ...originalEnv };
});
@@ -72,6 +103,19 @@ describe('BackupSchedulerService', () => {
});
}
function jobRun(overrides: Partial<JobRun> = {}): JobRun {
return {
id: 'run-1',
job: 'app-backup',
status: 'SUCCEEDED',
startedAt: new Date('2026-10-13T01:00:02.000Z'),
finishedAt: new Date('2026-10-13T01:00:09.000Z'),
result: { written: true },
error: null,
...overrides,
};
}
describe('getStatus', () => {
it('reports the import limit and the size of the newest backup', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
@@ -129,6 +173,49 @@ describe('BackupSchedulerService', () => {
}),
);
});
it('reports the newest app-backup job run', async () => {
jobRuns.recent.mockResolvedValue([
jobRun({
status: 'FAILED',
finishedAt: new Date('2026-10-13T01:00:05.000Z'),
result: null,
error: 'EACCES: permission denied',
}),
]);
const status = await service.getStatus();
expect(jobRuns.recent).toHaveBeenCalledWith(1, 'app-backup');
expect(status.lastJobRun).toEqual({
status: 'FAILED',
startedAt: '2026-10-13T01:00:02.000Z',
finishedAt: '2026-10-13T01:00:05.000Z',
error: 'EACCES: permission denied',
});
});
it('reports a running job run without an end and the run also without a path', async () => {
delete process.env.BACKUP_STORAGE_PATH;
jobRuns.recent.mockResolvedValue([
jobRun({ status: 'RUNNING', finishedAt: null, result: null }),
]);
const status = await service.getStatus();
expect(status.lastJobRun).toEqual({
status: 'RUNNING',
startedAt: '2026-10-13T01:00:02.000Z',
finishedAt: null,
error: null,
});
});
it('reports no job run before the first one', async () => {
const status = await service.getStatus();
expect(status.lastJobRun).toBeNull();
});
});
describe('runNow', () => {
@@ -151,48 +238,188 @@ describe('BackupSchedulerService', () => {
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(error).not.toHaveBeenCalled();
});
// A human asked for it: no due check, no wait for the schedule lock.
it('always writes, without the schedule lock', async () => {
onDisk([
{
name: 'apicore-backup-new.json',
size: 900,
mtime: new Date().toISOString(),
},
]);
await service.runNow();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
// The export reads every table through Prisma; a Prisma message quotes
// query values, so a failed scheduled run logs name, code and model only.
describe('scheduled run', () => {
it('logs a failed scheduled backup without the Prisma message', async () => {
const registry = {
doesExist: jest.fn().mockReturnValue(false),
addCronJob: jest.fn(),
deleteCronJob: jest.fn(),
};
const scheduled = new BackupSchedulerService(
backupService as unknown as BackupService,
describe('runIfDue', () => {
const lastNight = '2026-10-12T01:00:00.000Z';
it('writes nothing without a path', async () => {
delete process.env.BACKUP_STORAGE_PATH;
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
expect(result).toEqual({ written: false, reason: 'no-path' });
expect(backupService.exportSealed).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('writes nothing while the newest backup covers the last scheduled time', async () => {
onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]);
const result = await service.runIfDue(new Date('2026-10-13T00:30:00Z'));
expect(result).toEqual({ written: false, reason: 'not-due' });
expect(backupService.exportSealed).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
expect(fs.unlink).not.toHaveBeenCalled();
});
it('writes and prunes once the next scheduled time has passed', async () => {
process.env.BACKUP_RETENTION_COUNT = '1';
onDisk([
{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight },
{
get: jest.fn().mockResolvedValue({
storagePath: null,
cronSchedule: null,
retentionCount: null,
}),
} as unknown as BackupSettingsService,
registry as unknown as SchedulerRegistry,
);
backupService.exportSealed.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'Task' },
}),
);
await scheduled.reload();
const [[, job]] = registry.addCronJob.mock.calls as [[string, CronJob]];
await job.stop();
name: 'apicore-backup-b.json',
size: 800,
mtime: '2026-10-11T01:00:00.000Z',
},
]);
await job.fireOnTick();
await new Promise((resolve) => setImmediate(resolve));
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
const failure = error.mock.calls.find(([message]) =>
String(message).startsWith('Scheduled backup failed'),
) as [string, unknown];
expect(failure[0]).toContain('P2010');
expect(failure[0]).not.toContain('Mustermann');
expect(failure[1]).toBeUndefined();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(result).toEqual({
written: true,
fileName: 'apicore-backup-2026-10-13T01-05-00-000Z.json',
sizeBytes: Buffer.byteLength(JSON.stringify(SEALED)),
pruned: 1,
});
expect(fs.unlink).toHaveBeenCalledWith(
path.join(directory, 'apicore-backup-b.json'),
);
});
it('writes the first backup into an empty directory', async () => {
const result = await service.runIfDue(new Date('2026-10-13T00:30:00Z'));
expect(result).toEqual(expect.objectContaining({ written: true }));
expect(fs.writeFile).toHaveBeenCalledTimes(1);
});
it('reads the schedule in Europe/Berlin', async () => {
process.env.BACKUP_CRON_SCHEDULE = '0 3 * * *';
onDisk([
{
name: 'apicore-backup-a.json',
size: 900,
mtime: '2026-10-12T03:00:00.000Z',
},
]);
// 03:00 Berlin = 01:00Z has passed, 03:00 UTC has not.
const result = await service.runIfDue(new Date('2026-10-13T02:00:00Z'));
expect(result.written).toBe(true);
});
it('does not count a file it could not delete as pruned', async () => {
process.env.BACKUP_RETENTION_COUNT = '1';
onDisk([
{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight },
{
name: 'apicore-backup-b.json',
size: 800,
mtime: '2026-10-11T01:00:00.000Z',
},
{
name: 'apicore-backup-c.json',
size: 700,
mtime: '2026-10-10T01:00:00.000Z',
},
]);
(fs.unlink as jest.Mock)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('EBUSY'));
const result = await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
expect(result).toEqual(expect.objectContaining({ pruned: 1 }));
expect(warn).toHaveBeenCalledTimes(1);
});
// Two job pods (a CronJob run and a manual `kubectl create job`) must
// not both find the backup due: the check and the write happen under
// one database lock, so the second one sees the first one's file.
it('checks and writes only while holding the schedule lock', async () => {
await service.runIfDue(new Date('2026-10-13T01:05:00Z'));
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: APP_BACKUP_LOCK_TIMEOUT_MS,
maxWait: expect.any(Number) as number,
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
const [lockCall] = tx.$executeRaw.mock.calls;
expect(sqlText(lockCall)).toContain('pg_advisory_xact_lock');
expect(lockCall.slice(1)).toEqual(['app-backup:schedule']);
const lockedAt = tx.$executeRaw.mock.invocationCallOrder[0];
expect(
(fs.readdir as jest.Mock).mock.invocationCallOrder[0],
).toBeGreaterThan(lockedAt);
expect(
(fs.writeFile as jest.Mock).mock.invocationCallOrder[0],
).toBeGreaterThan(lockedAt);
});
it('takes the time from the database clock when none is given', async () => {
onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]);
tx.$queryRaw.mockResolvedValue([
{ now: new Date('2026-10-13T00:30:00Z') },
]);
// The pod's own clock (2026-10-14) would say due; the database's says no.
jest.useFakeTimers({ now: new Date('2026-10-14T12:00:00Z') });
try {
await expect(service.runIfDue()).resolves.toEqual({
written: false,
reason: 'not-due',
});
} finally {
jest.useRealTimers();
}
expect(sqlText(tx.$queryRaw.mock.calls[0])).toContain('now()');
});
it('gives up the lock as soon as the backup is not due', async () => {
onDisk([{ name: 'apicore-backup-a.json', size: 900, mtime: lastNight }]);
await service.runIfDue(new Date('2026-10-13T00:30:00Z'));
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(fs.mkdir).not.toHaveBeenCalled();
});
it('fails the run when the export fails', async () => {
backupService.exportSealed.mockRejectedValue(new Error('export broke'));
await expect(
service.runIfDue(new Date('2026-10-13T01:05:00Z')),
).rejects.toThrow('export broke');
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('fails the run on an invalid schedule instead of skipping silently', async () => {
process.env.BACKUP_CRON_SCHEDULE = 'every night';
await expect(
service.runIfDue(new Date('2026-10-13T01:05:00Z')),
).rejects.toThrow();
expect(fs.writeFile).not.toHaveBeenCalled();
});
});
});
@@ -1,32 +1,51 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { JobRunStatus, Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service';
import { loggableError } from '../common/loggable-error';
import {
assertBackupCron,
resolveGuiBackupPath,
} from './backup-schedule-rules';
import { isBackupDue } from './backup-due';
import { resolveGuiBackupPath } from './backup-schedule-rules';
import {
backupMaxImportBytes,
exceedsBackupImportLimit,
formatMegabytes,
} from './backup-import-limit';
const SCHEDULED_JOB_NAME = 'automatic-backup';
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
const DEFAULT_RETENTION_COUNT = 14;
const BACKUP_FILE_PREFIX = 'apicore-backup-';
const BACKUP_FILE_SUFFIX = '.json';
// Guards against two replicas (deployment runs with replicas: 2, no shared
// DB table for scheduling state) both firing the same cron tick: if the most
// recent backup file on this shared/mounted path is younger than this, a
// concurrent replica already handled the tick, so this one skips.
const DUPLICATE_RUN_GUARD_MS = 2 * 60 * 1000;
/**
* Advisory lock (pg_advisory_xact_lock on its hash) around the due check
* and the write of a scheduled backup.
*/
const APP_BACKUP_LOCK = 'app-backup:schedule';
/**
* How long a scheduled backup may hold the lock: the activeDeadlineSeconds
* of the app-backup CronJob (3600 s), after which the pod is gone anyway.
*/
export const APP_BACKUP_LOCK_TIMEOUT_MS = 60 * 60 * 1000;
/** How long to wait for a free pooled connection to take the lock on. */
const APP_BACKUP_LOCK_MAX_WAIT_MS = 10_000;
/** What one run of the app-backup job did (JobRun.result). */
export type AppBackupResult =
| { written: true; fileName: string; sizeBytes: number; pruned: number }
| { written: false; reason: 'no-path' | 'not-due' };
/** The newest app-backup job run, as GET /backup/schedule shows it. */
export interface BackupJobRunStatus {
status: JobRunStatus;
startedAt: string;
finishedAt: string | null;
error: string | null;
}
export interface BackupScheduleStatus {
enabled: boolean;
@@ -48,6 +67,9 @@ export interface BackupScheduleStatus {
cronSchedule: boolean;
retentionCount: boolean;
};
// The newest run of the hourly app-backup job (CronJob), null before the
// first one. lastRunAt above is the newest backup file instead.
lastJobRun: BackupJobRunStatus | null;
}
interface EffectiveBackupSettings {
@@ -57,20 +79,23 @@ interface EffectiveBackupSettings {
envOverrides: BackupScheduleStatus['envOverrides'];
}
/**
* Automatic backups. The schedule is not a timer in this process: the
* app-backup CronJob runs every hour and calls runIfDue(), which reads path,
* schedule and retention afresh (env before GUI), so a GUI change applies
* from the next hourly check on. runNow() is the "Jetzt sichern" button.
*/
@Injectable()
export class BackupSchedulerService implements OnModuleInit {
export class BackupSchedulerService {
private readonly logger = new Logger(BackupSchedulerService.name);
constructor(
private readonly backupService: BackupService,
private readonly backupSettingsService: BackupSettingsService,
private readonly schedulerRegistry: SchedulerRegistry,
private readonly prisma: PrismaService,
private readonly jobRuns: JobRunService,
) {}
async onModuleInit(): Promise<void> {
await this.reload();
}
private async effectiveSettings(): Promise<EffectiveBackupSettings> {
const envPath = process.env.BACKUP_STORAGE_PATH;
const hasEnvPath = Boolean(envPath && envPath.trim().length > 0);
@@ -106,55 +131,10 @@ export class BackupSchedulerService implements OnModuleInit {
};
}
// Re-registers (or tears down) the cron job to match the current effective
// settings. Called at boot and again after a GUI settings update, so an
// admin's schedule/path/retention change takes effect immediately without
// a pod restart.
async reload(): Promise<void> {
const settings = await this.effectiveSettings();
if (!settings.envOverrides.cronSchedule) {
try {
assertBackupCron(settings.cronSchedule);
} catch {
// Keep whatever is running instead of silently ending backups.
this.logger.error(
`Ungültiger Backup-Zeitplan "${settings.cronSchedule}" - bisheriger Zeitplan bleibt aktiv`,
);
return;
}
}
if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) {
this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME);
}
if (!settings.path) {
this.logger.log(
'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert',
);
return;
}
const directory = settings.path;
const job = CronJob.from({
cronTime: settings.cronSchedule,
onTick: () => {
// Prisma errors by name/code/model only (see loggableError).
this.runScheduled(directory).catch((error: unknown) => {
const { message, stack } = loggableError(error);
this.logger.error(`Scheduled backup failed: ${message}`, stack);
});
},
start: true,
});
this.schedulerRegistry.addCronJob(SCHEDULED_JOB_NAME, job);
this.logger.log(
`Automatische Backups aktiv: schedule="${settings.cronSchedule}" path="${directory}" retention=${settings.retentionCount}`,
);
}
async getStatus(): Promise<BackupScheduleStatus> {
const settings = await this.effectiveSettings();
const importLimitBytes = backupMaxImportBytes();
const lastJobRun = await this.lastJobRun();
if (!settings.path) {
return {
enabled: false,
@@ -166,6 +146,7 @@ export class BackupSchedulerService implements OnModuleInit {
lastBackupSizeBytes: null,
lastBackupExceedsImportLimit: false,
envOverrides: settings.envOverrides,
lastJobRun,
};
}
@@ -185,11 +166,25 @@ export class BackupSchedulerService implements OnModuleInit {
lastBackupSizeBytes !== null &&
exceedsBackupImportLimit(lastBackupSizeBytes),
envOverrides: settings.envOverrides,
lastJobRun,
};
}
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
// duplicate-tick guard, since a human explicitly asked for it.
private async lastJobRun(): Promise<BackupJobRunStatus | null> {
const [run] = await this.jobRuns.recent(1, 'app-backup');
if (!run) {
return null;
}
return {
status: run.status,
startedAt: run.startedAt.toISOString(),
finishedAt: run.finishedAt?.toISOString() ?? null,
error: run.error,
};
}
// Manual trigger ("Jetzt sichern") - always writes: no due check and no
// schedule lock, since a human explicitly asked for it.
async runNow(): Promise<{ fileName: string }> {
const settings = await this.effectiveSettings();
if (!settings.path) {
@@ -197,32 +192,61 @@ export class BackupSchedulerService implements OnModuleInit {
'Kein Backup-Pfad konfiguriert - weder per Umgebungsvariable noch über die GUI',
);
}
const filePath = await this.writeBackup(settings.path);
const { fileName } = await this.writeBackup(settings.path);
await this.pruneOldBackups(settings.path, settings.retentionCount);
// Only the name: the server's directory layout stays on the server.
return { fileName: path.basename(filePath) };
return { fileName };
}
private async runScheduled(directory: string): Promise<void> {
/**
* One hourly check of the app-backup job: writes a backup if one is due
* (isBackupDue against the newest backup file, schedule in
* Europe/Berlin) and prunes beyond the retention. Check and write run
* under one database advisory lock, so a second job pod that checks at
* the same time waits and then finds the first pod's file - two pods
* never both write. Failures are thrown and fail the job run.
*/
async runIfDue(now?: Date): Promise<AppBackupResult> {
const settings = await this.effectiveSettings();
const files = await this.listBackupFiles(directory);
const mostRecent = files[0];
if (
mostRecent &&
Date.now() - mostRecent.mtime.getTime() < DUPLICATE_RUN_GUARD_MS
) {
this.logger.log(
'Skipping scheduled backup - another replica already wrote one for this tick',
);
return;
if (!settings.path) {
return { written: false, reason: 'no-path' };
}
const filePath = await this.writeBackup(directory);
this.logger.log(`Scheduled backup written to ${filePath}`);
await this.pruneOldBackups(directory, settings.retentionCount);
const directory = settings.path;
return this.prisma.$transaction(
async (tx): Promise<AppBackupResult> => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${APP_BACKUP_LOCK}, 0))`;
const [newest] = await this.listBackupFiles(directory);
// The database clock, like every other time comparison of the
// platform: all job pods see the same time.
const checkedAt = now ?? (await this.databaseNow(tx));
if (
!isBackupDue(settings.cronSchedule, newest?.mtime ?? null, checkedAt)
) {
return { written: false, reason: 'not-due' };
}
const { fileName, sizeBytes } = await this.writeBackup(directory);
this.logger.log(`Scheduled backup ${fileName} written`);
const pruned = await this.pruneOldBackups(
directory,
settings.retentionCount,
);
return { written: true, fileName, sizeBytes, pruned };
},
{
timeout: APP_BACKUP_LOCK_TIMEOUT_MS,
maxWait: APP_BACKUP_LOCK_MAX_WAIT_MS,
},
);
}
private async writeBackup(directory: string): Promise<string> {
private async databaseNow(tx: Prisma.TransactionClient): Promise<Date> {
const [row] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`;
return row.now;
}
private async writeBackup(
directory: string,
): Promise<{ fileName: string; sizeBytes: number }> {
const sealed = await this.backupService.exportSealed();
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
@@ -241,26 +265,30 @@ export class BackupSchedulerService implements OnModuleInit {
`Backup ${path.basename(filePath)} ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern (BACKUP_MAX_IMPORT_BYTES erhöhen)`,
);
}
return filePath;
return { fileName: path.basename(filePath), sizeBytes };
}
/** Deletes all but the newest `retentionCount` backups; returns how many. */
private async pruneOldBackups(
directory: string,
retentionCount: number,
): Promise<void> {
): Promise<number> {
const files = await this.listBackupFiles(directory);
const toDelete = files.slice(retentionCount);
await Promise.all(
const deleted = await Promise.all(
toDelete.map((file) =>
fs
.unlink(file.filePath)
.catch((error) =>
fs.unlink(file.filePath).then(
() => true,
(error: unknown) => {
this.logger.warn(
`Could not delete old backup ${file.filePath}: ${String(error)}`,
),
),
);
return false;
},
),
),
);
return deleted.filter(Boolean).length;
}
private async listBackupFiles(
@@ -11,7 +11,8 @@ describe('BackupController', () => {
const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES;
let controller: BackupController;
let backupService: { importFile: jest.Mock; exportSealed: jest.Mock };
let scheduler: { reload: jest.Mock };
let scheduler: Record<string, never>;
let settings: { update: jest.Mock };
let reauth: { verify: jest.Mock };
const actor = { id: 'admin-1' } as AuthenticatedUser;
const file = {
@@ -28,12 +29,13 @@ describe('BackupController', () => {
ciphertext: 'x'.repeat(2000),
}),
};
scheduler = { reload: jest.fn().mockResolvedValue(undefined) };
scheduler = {};
settings = { update: jest.fn() };
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
controller = new BackupController(
backupService as unknown as BackupService,
scheduler as unknown as BackupSchedulerService,
{} as BackupSettingsService,
settings as unknown as BackupSettingsService,
reauth as unknown as ReauthService,
);
});
@@ -46,23 +48,29 @@ describe('BackupController', () => {
}
});
it('re-registers the backup schedule after an import, since it may restore other backup settings', async () => {
await controller.import(file, { password: 'secret' }, actor);
expect(backupService.importFile).toHaveBeenCalledTimes(1);
expect(scheduler.reload).toHaveBeenCalledTimes(1);
expect(scheduler.reload.mock.invocationCallOrder[0]).toBeGreaterThan(
backupService.importFile.mock.invocationCallOrder[0],
);
});
it('leaves the schedule alone when the import fails', async () => {
backupService.importFile.mockRejectedValue(new Error('broken file'));
// The app-backup job reads the settings on every run, so nothing in the
// API has to be told about a restore or a settings change. The scheduler
// double has no methods: any call on it fails these tests.
it('imports without touching the schedule', async () => {
await expect(
controller.import(file, { password: 'secret' }, actor),
).rejects.toThrow('broken file');
expect(scheduler.reload).not.toHaveBeenCalled();
).resolves.toEqual({ imported: {}, skipped: [] });
expect(backupService.importFile).toHaveBeenCalledTimes(1);
});
it('saves settings without touching the schedule', async () => {
const saved = { id: 'singleton', cronSchedule: '0 4 * * *' };
settings.update.mockResolvedValue(saved);
await expect(
controller.updateSettings({ cronSchedule: '0 4 * * *' }, actor),
).resolves.toBe(saved);
expect(settings.update).toHaveBeenCalledWith(
{ cronSchedule: '0 4 * * *' },
'admin-1',
);
});
describe('export', () => {
@@ -65,13 +65,12 @@ export class BackupController {
@RequirePermission('backup:configure')
@Patch('settings')
async updateSettings(
updateSettings(
@Body() dto: UpdateBackupSettingsDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<BackupSettings> {
const settings = await this.backupSettingsService.update(dto, actor.id);
await this.backupSchedulerService.reload();
return settings;
// Read afresh by the next hourly app-backup check.
return this.backupSettingsService.update(dto, actor.id);
}
@RequirePermission('backup:export')
@@ -129,9 +128,7 @@ export class BackupController {
} catch {
throw new BadRequestException('Datei ist kein gültiges JSON');
}
const summary = await this.backupService.importFile(parsed, actor.id);
// The backup may have restored the schedule's settings too.
await this.backupSchedulerService.reload();
return summary;
// Restored backup settings apply from the next hourly app-backup check.
return this.backupService.importFile(parsed, actor.id);
}
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { ReauthModule } from '../auth/reauth/reauth.module';
import { SystemModule } from '../system/system.module';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service';
@@ -10,6 +11,8 @@ import { backupMaxImportBytes } from './backup-import-limit';
@Module({
imports: [
ReauthModule,
// JobRunService: GET /backup/schedule shows the newest app-backup run.
SystemModule,
// Upload limit of POST /backup/import. A factory rather than a constant
// in the decorator: it runs once the app starts, after ConfigModule has
// loaded .env, so the limit is the same one exports are checked against.
@@ -19,5 +22,7 @@ import { backupMaxImportBytes } from './backup-import-limit';
],
controllers: [BackupController],
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
// For the app-backup job (JobsModule, dist/cli.js).
exports: [BackupSchedulerService],
})
export class BackupModule {}
@@ -6,7 +6,7 @@ import {
import { BottlingService } from './bottling.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
@@ -22,6 +22,32 @@ function applyAmountChange(
return current + (change.increment ?? 0) - (change.decrement ?? 0);
}
/**
* What one bottling write records in a single recordAll - so both share a
* correlation id: its honey batch event and StockChanged for its jars.
*/
function bottlingEvents(honeyEvent: { type: string; payload: unknown }) {
return [
{
...honeyEvent,
aggregateType: 'BottlingBatch',
aggregateId: 'b1',
actorUserId: 'user-1',
},
{
type: 'StockChanged',
aggregateType: 'BottlingBatch',
aggregateId: 'b1',
payload: {
bottlingBatchIds: ['b1'],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: 'user-1',
},
];
}
describe('BottlingService', () => {
let service: BottlingService;
let prisma: {
@@ -36,28 +62,44 @@ describe('BottlingService', () => {
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { recordAll: jest.Mock };
let tx: {
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
$queryRaw: jest.Mock;
product: { findUnique: jest.Mock };
bottlingBatch: {
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
delete: jest.Mock;
};
honeyBatch: {
findUnique: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
};
};
let webhooks: { dispatch: jest.Mock };
let stock: {
remainingByBottling: jest.Mock;
notify: jest.Mock;
stockChangedEvent: jest.Mock;
hasMovements: jest.Mock;
};
// Honey batch rows as the transaction sees them - the tests assert the
// resulting amounts instead of the exact sequence of UPDATE statements.
let honeyBatches: Map<string, { amount: number | null; status?: string }>;
// The order of lock and reads inside the transaction.
let steps: string[];
beforeEach(() => {
honeyBatches = new Map();
steps = [];
tx = {
$queryRaw: jest.fn(() => {
steps.push('lock');
return Promise.resolve([]);
}),
product: { findUnique: jest.fn() },
bottlingBatch: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
@@ -123,22 +165,45 @@ describe('BottlingService', () => {
),
};
auditLog = { record: jest.fn() };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
events = { recordAll: jest.fn() };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
stock = {
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
notify: jest.fn().mockResolvedValue(undefined),
// As StockService builds it - covered by its own spec.
stockChangedEvent: jest.fn(
(
payload: unknown,
actorUserId: string | null,
aggregate: { type: string; id: string },
) => ({
type: 'StockChanged',
aggregateType: aggregate.type,
aggregateId: aggregate.id,
payload,
actorUserId,
}),
),
hasMovements: jest.fn().mockResolvedValue(false),
};
service = new BottlingService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
events as unknown as DomainEventsService,
automation as unknown as AutomationService,
stock as unknown as StockService,
);
});
/** The bottling row as the transaction reads it after locking. */
function lockedRows(...rows: Record<string, unknown>[]) {
for (const row of rows) {
tx.bottlingBatch.findUnique.mockImplementationOnce(() => {
steps.push('read');
return Promise.resolve(row);
});
}
}
it('throws NotFoundException when the honey batch does not exist', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce(null);
await expect(
@@ -185,6 +250,7 @@ describe('BottlingService', () => {
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 25,
});
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
@@ -211,6 +277,42 @@ describe('BottlingService', () => {
entityType: 'BottlingBatch',
action: 'CREATE',
}),
tx,
);
});
it('bottling create records BatchBottled and StockChanged together', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 25,
});
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(events.recordAll).toHaveBeenCalledTimes(1);
expect(events.recordAll).toHaveBeenCalledWith(
tx,
bottlingEvents({
type: 'BatchBottled',
payload: { honeyBatchId: 'h1' },
}),
);
});
@@ -238,9 +340,10 @@ describe('BottlingService', () => {
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
expect(honeyBatches.get('h1')?.amount).toBe(3);
expect(events.recordAll).not.toHaveBeenCalled();
});
it('update writes an audit log entry with before and after data', async () => {
it('update writes an audit log entry with before and after data in its transaction', async () => {
const before = {
id: 'b1',
lotNumber: 'L-1',
@@ -251,18 +354,12 @@ describe('BottlingService', () => {
totalAmount: 5,
};
const updated = { ...before, lotNumber: 'L-2' };
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(updated);
lockedRows(before, updated);
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
// Without movements the remaining stock equals the jar count.
expect(result).toEqual({ ...updated, remainingCount: 10 });
expect(stock.notify).toHaveBeenCalledWith({
bottlingBatchIds: ['b1'],
productIds: [],
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
@@ -272,16 +369,125 @@ describe('BottlingService', () => {
beforeData: { ...before, remainingCount: 10 },
afterData: { ...updated, remainingCount: 10 },
}),
tx,
);
});
it('update takes "before" from the row locked in its transaction, never from a read made earlier', async () => {
// A read outside the transaction would see this stale copy.
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'b1',
lotNumber: 'STALE',
honeyBatchId: 'h-stale',
honeyBatch: { id: 'h-stale', amount: 0 },
jarSize: 250,
jarCount: 1,
totalAmount: 0.25,
});
const locked = {
id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
lockedRows(locked, { ...locked, lotNumber: 'L-2' });
await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
expect(prisma.bottlingBatch.findUnique).not.toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
beforeData: { ...locked, remainingCount: 10 },
}),
tx,
);
// Nothing moved: the stale honey batch is never touched.
expect(tx.honeyBatch.updateMany).not.toHaveBeenCalled();
});
it('update records BottlingCorrected and StockChanged in its transaction', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
lockedRows(before, { ...before, lotNumber: 'L-2' });
await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(events.recordAll).toHaveBeenCalledWith(
tx,
bottlingEvents({
type: 'BottlingCorrected',
payload: { honeyBatchId: 'h1', removed: false },
}),
);
});
it('rejects updating to a product that does not exist', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
prisma.product.findUnique.mockResolvedValueOnce(null);
lockedRows({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
});
tx.product.findUnique.mockResolvedValueOnce(null);
await expect(
service.update('b1', { productId: 'missing' }, 'user-1'),
).rejects.toThrow(new NotFoundException('Product not found'));
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
expect(events.recordAll).not.toHaveBeenCalled();
});
it('update reports an unknown bottling before it checks the product', async () => {
lockedRows(null as unknown as Record<string, unknown>);
tx.product.findUnique.mockResolvedValue(null);
prisma.product.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { productId: 'also-missing' }, 'user-1'),
).rejects.toThrow(new NotFoundException('Bottling batch not found'));
expect(tx.product.findUnique).not.toHaveBeenCalled();
expect(prisma.product.findUnique).not.toHaveBeenCalled();
});
it('update refuses an article that keeps stock in production batches', async () => {
lockedRows({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
});
tx.product.findUnique.mockResolvedValueOnce({
id: 'candle',
kind: 'MANUFACTURED',
});
await expect(
service.update('b1', { productId: 'candle' }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
});
it('update throws NotFoundException for an unknown bottling batch', async () => {
lockedRows(null as unknown as Record<string, unknown>);
await expect(
service.update('missing', { lotNumber: 'L-2' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(events.recordAll).not.toHaveBeenCalled();
});
it('clears the product assignment when productId is null', async () => {
@@ -294,9 +500,7 @@ describe('BottlingService', () => {
totalAmount: 5,
productId: 'p1',
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, productId: null });
lockedRows(before, { ...before, productId: null });
await service.update('b1', { productId: null }, 'user-1');
@@ -317,9 +521,7 @@ describe('BottlingService', () => {
jarCount: 20,
totalAmount: 10,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
lockedRows(before, { ...before, jarCount: 10, totalAmount: 5 });
honeyBatches.set('h1', { amount: 0 });
await service.update('b1', { jarCount: 10 }, 'user-1');
@@ -333,7 +535,7 @@ describe('BottlingService', () => {
});
it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
lockedRows({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 0 },
@@ -345,6 +547,8 @@ describe('BottlingService', () => {
await expect(
service.update('b1', { jarCount: 40 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
expect(events.recordAll).not.toHaveBeenCalled();
});
it('moves the amount between honey batches when the honey batch changes', async () => {
@@ -356,10 +560,11 @@ describe('BottlingService', () => {
jarCount: 10,
totalAmount: 5,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
lockedRows(before, {
...before,
honeyBatchId: 'h2',
honeyBatch: { id: 'h2', amount: 3 },
});
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
@@ -367,21 +572,33 @@ describe('BottlingService', () => {
expect(honeyBatches.get('h1')?.amount).toBe(7);
expect(honeyBatches.get('h2')).toEqual({ amount: 3, status: 'BOTTLED' });
// Receivers hear about the honey batch the bottling now draws on.
expect(events.recordAll).toHaveBeenCalledWith(
tx,
expect.arrayContaining([
expect.objectContaining({
type: 'BottlingCorrected',
payload: { honeyBatchId: 'h2', removed: false },
}),
]),
);
});
it('remove gives the amount back and records a DELETE audit entry', async () => {
it('remove gives the amount back and records a DELETE audit entry in its transaction', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
jarCount: 10,
totalAmount: 5,
};
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
lockedRows(before);
honeyBatches.set('h1', { amount: 2 });
const result = await service.remove('b1', 'user-1');
expect(result).toEqual({ id: 'b1' });
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
where: { id: 'b1' },
});
@@ -391,17 +608,21 @@ describe('BottlingService', () => {
entityType: 'BottlingBatch',
entityId: 'b1',
action: 'DELETE',
beforeData: before,
beforeData: { ...before, remainingCount: 10 },
}),
tx,
);
expect(webhooks.dispatch).toHaveBeenCalledWith(
'batch.updated',
before.honeyBatch,
expect(events.recordAll).toHaveBeenCalledWith(
tx,
bottlingEvents({
type: 'BottlingCorrected',
payload: { honeyBatchId: 'h1', removed: true },
}),
);
});
it('remove refuses a bottling whose jars were already sold or withdrawn', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
lockedRows({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
@@ -412,7 +633,13 @@ describe('BottlingService', () => {
await expect(service.remove('b1', 'user-1')).rejects.toBeInstanceOf(
ConflictException,
);
// Checked under the row lock, through the transaction.
expect(stock.hasMovements).toHaveBeenCalledWith(
{ kind: 'bottling', id: 'b1' },
tx,
);
expect(tx.bottlingBatch.delete).not.toHaveBeenCalled();
expect(events.recordAll).not.toHaveBeenCalled();
});
it('refuses to bottle onto an article that keeps stock in production batches', async () => {
@@ -442,7 +669,7 @@ describe('BottlingService', () => {
});
it('remove throws NotFoundException for an unknown bottling batch', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
lockedRows(null as unknown as Record<string, unknown>);
await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
+155 -89
View File
@@ -5,8 +5,11 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
import { BottlingBatch, Prisma } from '../../generated/prisma';
@@ -99,12 +102,18 @@ async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
}
}
/**
* Bottlings of a honey batch. Every write records its audit entry and its
* events in its own transaction: BatchBottled or BottlingCorrected (the
* honey batch's amount changed - batch.updated webhook) and StockChanged
* (the bottling's jars - stock.changed webhook).
*/
@Injectable()
export class BottlingService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly events: DomainEventsService,
private readonly automation: AutomationService,
private readonly stock: StockService,
) {}
@@ -124,15 +133,17 @@ export class BottlingService {
}));
}
async findById(id: string) {
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
/** With `tx` it reads what the caller's transaction sees. */
async findById(id: string, tx?: Tx) {
const db: DbClient = tx ?? this.prisma;
const bottlingBatch = await db.bottlingBatch.findUnique({
where: { id },
include: bottlingBatchInclude,
});
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
}
const remaining = await this.stock.remainingByBottling([id]);
const remaining = await this.stock.remainingByBottling([id], tx);
return {
...bottlingBatch,
remainingCount: remaining.get(id) ?? bottlingBatch.jarCount,
@@ -140,8 +151,12 @@ export class BottlingService {
}
/** Honey is bottled; other articles keep their stock in production batches. */
private async assertBottledProduct(productId: string): Promise<void> {
const product = await this.prisma.product.findUnique({
private async assertBottledProduct(
productId: string,
tx?: Tx,
): Promise<void> {
const db: DbClient = tx ?? this.prisma;
const product = await db.product.findUnique({
where: { id: productId },
});
if (!product) {
@@ -186,7 +201,7 @@ export class BottlingService {
// time (double tap, two devices) must not both pass the check and
// overwrite each other's deduction.
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
return tx.bottlingBatch.create({
const created = await tx.bottlingBatch.create({
data: {
honeyBatchId: dto.honeyBatchId,
productId: dto.productId,
@@ -199,24 +214,28 @@ export class BottlingService {
userId: actingUserId,
},
});
await this.auditLog.record(
{
entityType: 'BottlingBatch',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
await this.recordEvents(tx, {
type: 'BatchBottled',
aggregateType: 'BottlingBatch',
aggregateId: created.id,
payload: { honeyBatchId: created.honeyBatchId },
actorUserId: actingUserId,
});
return created;
},
);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: bottlingBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: bottlingBatch,
});
const created = await this.findById(bottlingBatch.id);
await this.webhooks.dispatch('batch.updated', created.honeyBatch);
await this.stock.notify({
bottlingBatchIds: [created.id],
productIds: [],
});
return created;
return this.findById(bottlingBatch.id);
}
/**
@@ -225,43 +244,48 @@ export class BottlingService {
* of the affected honey batch(es): the previously bottled amount is given
* back before the corrected amount is taken again, so a correction never
* double-charges the source batch.
*
* Everything that depends on the current bottling - defaults, amount
* check, audit "before" - is decided on the row locked in the
* transaction, so a concurrent correction cannot slip in between. An
* unknown bottling is reported (404) before anything about the request.
*/
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
const before = await this.findById(id);
if (dto.productId) {
await this.assertBottledProduct(dto.productId);
}
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
const jarSize = dto.jarSize ?? before.jarSize;
const jarCount = dto.jarCount ?? before.jarCount;
const totalAmount = roundKg((jarSize * jarCount) / 1000);
const targetHoneyBatch = movedToOtherHoneyBatch
? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } })
: before.honeyBatch;
if (!targetHoneyBatch) {
throw new NotFoundException('Honey batch not found');
}
// Early, friendly check against the amounts read above. The booking
// itself happens atomically inside the transaction below.
const availableOnTarget =
targetHoneyBatch.amount === null
? null
: roundKg(
targetHoneyBatch.amount +
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
// 409 with a field-specific message by AllExceptionsFilter.
await this.prisma.$transaction(async (tx) => {
return this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'BottlingBatch', id);
const before = await this.findById(id, tx);
if (dto.productId) {
await this.assertBottledProduct(dto.productId, tx);
}
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
const jarSize = dto.jarSize ?? before.jarSize;
const jarCount = dto.jarCount ?? before.jarCount;
const totalAmount = roundKg((jarSize * jarCount) / 1000);
const targetHoneyBatch = movedToOtherHoneyBatch
? await tx.honeyBatch.findUnique({ where: { id: honeyBatchId } })
: before.honeyBatch;
if (!targetHoneyBatch) {
throw new NotFoundException('Honey batch not found');
}
// Early, friendly check; the booking below is still conditional on
// the stored amount (takeFromHoneyBatch).
const availableOnTarget =
targetHoneyBatch.amount === null
? null
: roundKg(
targetHoneyBatch.amount +
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
const amountChanged =
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
if (amountChanged) {
@@ -291,21 +315,30 @@ export class BottlingService {
lotNumber: dto.lotNumber,
},
});
});
const bottlingBatch = await this.findById(id);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: bottlingBatch,
const bottlingBatch = await this.findById(id, tx);
await this.auditLog.record(
{
entityType: 'BottlingBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: bottlingBatch,
},
tx,
);
// Receivers hear about the honey batch the bottling draws on now; a
// corrected jar count changes the remaining stock as well.
await this.recordEvents(tx, {
type: 'BottlingCorrected',
aggregateType: 'BottlingBatch',
aggregateId: id,
payload: { honeyBatchId, removed: false },
actorUserId: actingUserId,
});
return bottlingBatch;
});
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
// A corrected jar count changes the remaining stock as well.
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
return bottlingBatch;
}
/**
@@ -318,30 +351,63 @@ export class BottlingService {
* write a fact that never happened.
*/
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
// Once jars were sold or withdrawn, the bottling is history: deleting it
// would erase where they came from. Wrong stock is fixed by an inventory
// correction instead.
if (await this.stock.hasMovements({ kind: 'bottling', id })) {
throw new ConflictException(
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
await this.prisma.$transaction(async (tx) => {
// Shop sales and withdrawals lock the same row, so the movement check
// below cannot be raced.
await lockRowForUpdate(tx, 'BottlingBatch', id);
const before = await this.findById(id, tx);
// Once jars were sold or withdrawn, the bottling is history: deleting
// it would erase where they came from. Wrong stock is fixed by an
// inventory correction instead.
if (await this.stock.hasMovements({ kind: 'bottling', id }, tx)) {
throw new ConflictException(
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
await tx.bottlingBatch.delete({ where: { id } });
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await this.auditLog.record(
{
entityType: 'BottlingBatch',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
},
tx,
);
await this.recordEvents(tx, {
type: 'BottlingCorrected',
aggregateType: 'BottlingBatch',
aggregateId: id,
payload: { honeyBatchId: before.honeyBatchId, removed: true },
actorUserId: actingUserId,
});
});
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
});
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
await this.stock.notify({ bottlingBatchIds: [id], productIds: [] });
return { id };
}
/**
* The honey batch event of a bottling write and StockChanged for the
* bottling's jars, in the caller's transaction - in one recordAll, so
* both carry the correlation id of the one action they describe.
*/
private async recordEvents(
tx: Tx,
event: AnyNewDomainEvent<'BatchBottled' | 'BottlingCorrected'>,
): Promise<void> {
const stockChanged = this.stock.stockChangedEvent(
{
bottlingBatchIds: [event.aggregateId],
productIds: [],
rawMaterialKinds: [],
},
event.actorUserId ?? null,
{ type: 'BottlingBatch', id: event.aggregateId },
);
await this.events.recordAll(
tx,
stockChanged === null ? [event] : [event, stockChanged],
);
}
}
@@ -0,0 +1,115 @@
import { CalendarSyncService } from './calendar-sync.service';
import { CalendarSyncEventHandlers } from './calendar-sync.handlers';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { Prisma } from '../../generated/prisma';
describe('CalendarSyncEventHandlers', () => {
let handlers: CalendarSyncEventHandlers;
let calendarSync: { enqueueIn: jest.Mock };
const tx = {} as Prisma.TransactionClient;
const envelope = <T extends DomainEventType>(
type: T,
aggregateId: string,
payload: Record<string, unknown>,
) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope<T>;
beforeEach(() => {
calendarSync = { enqueueIn: jest.fn().mockResolvedValue(undefined) };
handlers = new CalendarSyncEventHandlers(
calendarSync as unknown as CalendarSyncService,
);
});
it.each([
['inspection', 'INSPECTION', 'InspectionRecorded'],
['inspection', 'INSPECTION', 'InspectionCorrected'],
['treatment', 'TREATMENT', 'TreatmentRecorded'],
['treatment', 'TREATMENT', 'TreatmentCorrected'],
['feeding', 'FEEDING', 'FeedingRecorded'],
['feeding', 'FEEDING', 'FeedingCorrected'],
['harvest', 'HARVEST', 'HarvestRecorded'],
['harvest', 'HARVEST', 'HarvestCorrected'],
] as const)(
'%s handler enqueues %s on %s through tx',
async (method, sourceType, type) => {
await handlers[method](envelope(type, 'src-1', {}) as never, tx);
expect(calendarSync.enqueueIn).toHaveBeenCalledWith(
tx,
sourceType,
'src-1',
);
},
);
it.each(['TaskCreated', 'TaskUpdated', 'TaskCompleted'] as const)(
'task handler enqueues the task on %s',
async (type) => {
await handlers.task(
envelope(type, 'task-1', { dueDate: '2027-05-01' }),
tx,
);
expect(calendarSync.enqueueIn).toHaveBeenCalledWith(tx, 'TASK', 'task-1');
},
);
it('TaskCreated without a due date enqueues nothing', async () => {
await handlers.task(
envelope('TaskCreated', 'task-1', { dueDate: null }),
tx,
);
expect(calendarSync.enqueueIn).not.toHaveBeenCalled();
});
it('TaskUpdated without a due date still enqueues, so the worker removes the event', async () => {
await handlers.task(
envelope('TaskUpdated', 'task-1', { dueDate: null, status: 'OPEN' }),
tx,
);
expect(calendarSync.enqueueIn).toHaveBeenCalledWith(tx, 'TASK', 'task-1');
});
it('lets an enqueue failure propagate so the delivery is retried', async () => {
calendarSync.enqueueIn.mockRejectedValue(new Error('db down'));
await expect(
handlers.inspection(envelope('InspectionRecorded', 'i-1', {}), tx),
).rejects.toThrow('db down');
});
it('registers the stable handler ids and event types', () => {
const registered = (method: keyof CalendarSyncEventHandlers) =>
domainEventHandlerMetadata(
Object.getOwnPropertyDescriptor(
CalendarSyncEventHandlers.prototype,
method,
)?.value as object,
);
expect(registered('inspection')).toEqual([
{
types: ['InspectionRecorded', 'InspectionCorrected'],
handlerId: 'calendar-sync.inspection',
},
]);
expect(registered('treatment')[0].handlerId).toBe(
'calendar-sync.treatment',
);
expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding');
expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest');
expect(registered('task')).toEqual([
{
types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'],
handlerId: 'calendar-sync.task',
},
]);
});
});
@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { Prisma } from '../../generated/prisma';
import { CalendarSyncService } from './calendar-sync.service';
/**
* Puts calendar-relevant records into the M365 outbox. Pure DB writer: the
* Graph call itself happens in CalendarSyncService.processOutbox (worker
* loop). Errors propagate, so a failed delivery is retried.
*/
@Injectable()
export class CalendarSyncEventHandlers {
constructor(private readonly calendarSync: CalendarSyncService) {}
@OnDomainEvent(
['InspectionRecorded', 'InspectionCorrected'],
'calendar-sync.inspection',
)
async inspection(
event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(tx, 'INSPECTION', event.aggregateId);
}
@OnDomainEvent(
['TreatmentRecorded', 'TreatmentCorrected'],
'calendar-sync.treatment',
)
async treatment(
event: AnyDomainEventEnvelope<'TreatmentRecorded' | 'TreatmentCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(tx, 'TREATMENT', event.aggregateId);
}
@OnDomainEvent(
['FeedingRecorded', 'FeedingCorrected'],
'calendar-sync.feeding',
)
async feeding(
event: AnyDomainEventEnvelope<'FeedingRecorded' | 'FeedingCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(tx, 'FEEDING', event.aggregateId);
}
@OnDomainEvent(
['HarvestRecorded', 'HarvestCorrected'],
'calendar-sync.harvest',
)
async harvest(
event: AnyDomainEventEnvelope<'HarvestRecorded' | 'HarvestCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(tx, 'HARVEST', event.aggregateId);
}
// Updated and completed tasks are enqueued even without a due date or
// when done: the worker then removes an event created earlier.
@OnDomainEvent(
['TaskCreated', 'TaskUpdated', 'TaskCompleted'],
'calendar-sync.task',
)
async task(
event: AnyDomainEventEnvelope<
'TaskCreated' | 'TaskUpdated' | 'TaskCompleted'
>,
tx: Prisma.TransactionClient,
): Promise<void> {
if (event.type === 'TaskCreated' && event.payload.dueDate === null) {
return;
}
await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId);
}
}
@@ -1,18 +1,20 @@
import { Global, Module } from '@nestjs/common';
import { CalendarSyncController } from './calendar-sync.controller';
import { CalendarSyncEventHandlers } from './calendar-sync.handlers';
import { CalendarSyncService } from './calendar-sync.service';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { GraphClientService } from './graph-client.service';
// @Global(): CalendarSyncService.enqueue()/cancel() are called from ~6
// otherwise-unrelated domain modules (Tasks, Inspections, Treatments,
// Feedings, Harvests, Queens) - matches WebhooksModule's own @Global()
// rationale rather than having each of those six modules import this one.
// @Global(): CalendarSyncService.enqueue() is called from otherwise-unrelated
// domain modules (Inspections, Treatments, Feedings, Harvests, Queens) until
// they record domain events - matches WebhooksModule's own @Global()
// rationale rather than having each of those modules import this one.
@Global()
@Module({
controllers: [CalendarSyncController],
providers: [
CalendarSyncService,
CalendarSyncEventHandlers,
CalendarSyncSettingsService,
GraphClientService,
],
@@ -151,8 +151,8 @@ describe('CalendarSyncService', () => {
});
});
// enqueue()/cancel() run after the source record is committed: a failure
// must not fail that request (the client would retry and store it twice).
// enqueue() runs after the source record is committed: a failure must not
// fail that request (the client would retry and store it twice).
describe('post-commit calls', () => {
let errorSpy: jest.SpyInstance;
@@ -178,61 +178,50 @@ describe('CalendarSyncService', () => {
expect(message).toContain('INSPECTION inspection-1');
expect(message).toContain('db down');
});
it('cancel never throws and logs the failure with the record', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockRejectedValue(
new Error('db down'),
);
await expect(service.cancel('TASK', 'task-1')).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain('TASK task-1');
expect(message).toContain('db down');
});
});
describe('cancel', () => {
it('does nothing when no outbox row exists', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null);
describe('enqueueIn', () => {
it('writes through the given client and lets errors propagate (handler retry)', async () => {
const txUpsert = jest.fn().mockResolvedValue(undefined);
const tx = {
calendarSyncOutboxEntry: { upsert: txUpsert },
} as unknown as Prisma.TransactionClient;
await service.cancel('TASK', 'task-1');
await service.enqueueIn(tx, 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
});
it('deletes the Graph event and clears the row when a graphEventId exists', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
id: 'entry-1',
graphEventId: 'graph-event-1',
expect(txUpsert).toHaveBeenCalledWith({
where: {
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
},
create: { sourceType: 'TASK', sourceId: 'task-1' },
update: {
status: 'PENDING',
attempts: 0,
lastError: null,
revision: { increment: 1 },
},
});
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
await service.cancel('TASK', 'task-1');
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
'group-1',
'graph-event-1',
txUpsert.mockRejectedValue(new Error('tx failed'));
await expect(service.enqueueIn(tx, 'TASK', 'task-1')).rejects.toThrow(
'tx failed',
);
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
where: { id: 'entry-1' },
data: { status: 'SKIPPED', graphEventId: null },
});
});
it('still clears the row when the Graph delete fails', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
id: 'entry-1',
graphEventId: 'graph-event-1',
it('does nothing when sync is disabled', async () => {
settingsService.get.mockResolvedValue({
...enabledSettings,
enabled: false,
});
graphClient.deleteEvent.mockRejectedValue(new Error('network down'));
const txUpsert = jest.fn();
const tx = {
calendarSyncOutboxEntry: { upsert: txUpsert },
} as unknown as Prisma.TransactionClient;
await service.cancel('TASK', 'task-1');
await service.enqueueIn(tx, 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
where: { id: 'entry-1' },
data: { status: 'SKIPPED', graphEventId: null },
});
expect(txUpsert).not.toHaveBeenCalled();
});
});
@@ -241,6 +230,7 @@ describe('CalendarSyncService', () => {
id: 'task-1',
title: 'Beute prüfen',
dueDate: new Date('2026-03-07'),
status: 'OPEN',
};
/** The outcome write: only lands if the row is unchanged since the claim. */
@@ -441,7 +431,78 @@ describe('CalendarSyncService', () => {
await service.processOutbox();
expect(graphClient.createEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED' });
expect(graphClient.deleteEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
it('deletes the Graph event of a task that is DONE and settles the entry SKIPPED', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1' }),
]);
prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' });
await service.processOutbox();
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
'group-1',
'graph-event-1',
);
expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(graphClient.updateEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
it('treats a CANCELLED task like a DONE one', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1' }),
]);
prisma.task.findUnique.mockResolvedValue({
...dueTask,
status: 'CANCELLED',
});
await service.processOutbox();
expect(graphClient.deleteEvent).toHaveBeenCalledTimes(1);
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
it.each([404, 410])(
'treats %i on delete as already deleted',
async (status) => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1' }),
]);
prisma.task.findUnique.mockResolvedValue({
...dueTask,
status: 'DONE',
});
graphClient.deleteEvent.mockRejectedValue(
new GraphRequestError('deleteEvent', status, 'gone'),
);
await service.processOutbox();
expectSettled({ status: 'SKIPPED', graphEventId: null });
},
);
it('counts any other delete error as a failed attempt', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1', attempts: 1 }),
]);
prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' });
graphClient.deleteEvent.mockRejectedValue(
new GraphRequestError('deleteEvent', 503, 'busy'),
);
await service.processOutbox();
expectSettled({
status: 'PENDING',
attempts: 2,
lastError: expect.any(String) as string,
});
});
it('only lets go of the claim when the row changed before SKIPPED could be written', async () => {
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service';
import { runAfterCommit } from '../common/after-commit';
import { loggableError } from '../common/loggable-error';
@@ -106,8 +107,8 @@ export class CalendarSyncService {
return flagBySourceType[sourceType];
}
// enqueue()/cancel() follow a committed write in the domain services and
// are called without a guard - so they never throw (see runAfterCommit).
// enqueue() follows a committed write in the domain services and is
// called without a guard - so it never throws (see runAfterCommit).
private afterCommit(
action: string,
sourceType: CalendarSyncSourceType,
@@ -121,70 +122,48 @@ export class CalendarSyncService {
);
}
// Called from the ~6 domain services right after a create/update that
// should appear on the M365 calendar. Upserts rather than always
// inserting: re-enqueuing an already-synced row (e.g. a Task's due date
// changed) resets it to PENDING while keeping its graphEventId, so the
// worker updates the existing Graph event instead of creating a duplicate.
// The revision counts up so a worker syncing the row right now notices the
// newer state instead of marking it SYNCED.
// The core of every enqueue: upserts rather than always inserting, so
// re-enqueuing an already-synced row (e.g. a Task's due date changed)
// resets it to PENDING while keeping its graphEventId - the worker then
// updates the existing Graph event instead of creating a duplicate. The
// revision counts up so a worker syncing the row right now notices the
// newer state instead of marking it SYNCED. Written through `db` (the
// handler's delivery transaction) and not guarded: a failure propagates,
// so the domain event delivery is retried.
async enqueueIn(
db: DbClient,
sourceType: CalendarSyncSourceType,
sourceId: string,
): Promise<void> {
const settings = await this.settingsService.get();
if (!settings.enabled) {
return;
}
if (!this.isSourceTypeEnabled(settings, sourceType)) {
return;
}
await db.calendarSyncOutboxEntry.upsert({
where: { sourceType_sourceId: { sourceType, sourceId } },
create: { sourceType, sourceId },
update: {
status: 'PENDING',
attempts: 0,
lastError: null,
revision: { increment: 1 },
},
});
}
// Legacy path for sources that do not record a domain event yet
// (inspections, treatments, feedings and harvests until their producer
// tasks; queen assignments), called right after a committed write: never
// throws.
async enqueue(
sourceType: CalendarSyncSourceType,
sourceId: string,
): Promise<void> {
await this.afterCommit(
'Calendar sync enqueue',
sourceType,
sourceId,
async () => {
const settings = await this.settingsService.get();
if (!settings.enabled) {
return;
}
if (!this.isSourceTypeEnabled(settings, sourceType)) {
return;
}
await this.prisma.calendarSyncOutboxEntry.upsert({
where: { sourceType_sourceId: { sourceType, sourceId } },
create: { sourceType, sourceId },
update: {
status: 'PENDING',
attempts: 0,
lastError: null,
revision: { increment: 1 },
},
});
},
);
}
// Called when a source record no longer belongs on the calendar (e.g. a
// Task is completed). Best-effort: a failed Graph delete is logged, not
// thrown, since the outbox row is cleared either way and a future enqueue
// for the same (sourceType, sourceId) will simply create a fresh event.
async cancel(
sourceType: CalendarSyncSourceType,
sourceId: string,
): Promise<void> {
await this.afterCommit(
'Calendar sync cancel',
sourceType,
sourceId,
async () => {
const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({
where: { sourceType_sourceId: { sourceType, sourceId } },
});
if (!entry) {
return;
}
if (entry.graphEventId) {
await this.deleteGraphEvent(entry.graphEventId);
}
await this.prisma.calendarSyncOutboxEntry.update({
where: { id: entry.id },
data: { status: 'SKIPPED', graphEventId: null },
});
},
await this.afterCommit('Calendar sync enqueue', sourceType, sourceId, () =>
this.enqueueIn(this.prisma, sourceType, sourceId),
);
}
@@ -288,7 +267,11 @@ export class CalendarSyncService {
entry.sourceId,
);
if (!input) {
await this.settle(entry, { status: 'SKIPPED' });
// Nothing to show any more: an event created earlier must go.
if (entry.graphEventId) {
await this.removeGraphEvent(groupId, entry.graphEventId);
}
await this.settle(entry, { status: 'SKIPPED', graphEventId: null });
return;
}
@@ -312,6 +295,25 @@ export class CalendarSyncService {
}
}
// Unlike deleteGraphEvent, a failure counts as a failed attempt (the
// caller's catch): the event must really be gone before the entry is
// SKIPPED. 404/410 mean it already is.
private async removeGraphEvent(
groupId: string,
graphEventId: string,
): Promise<void> {
try {
await this.graphClient.deleteEvent(groupId, graphEventId);
} catch (error: unknown) {
const alreadyGone =
error instanceof GraphRequestError &&
EVENT_GONE_STATUSES.includes(error.status);
if (!alreadyGone) {
throw error;
}
}
}
/** The row exactly as claimed: still ours, PENDING, nothing enqueued since. */
private unchangedSinceClaim(
entry: ClaimedEntry,
@@ -426,7 +428,12 @@ export class CalendarSyncService {
const task = await this.prisma.task.findUnique({
where: { id: sourceId },
});
if (!task || !task.dueDate) {
if (
!task ||
!task.dueDate ||
task.status === 'DONE' ||
task.status === 'CANCELLED'
) {
return null;
}
return { title: task.title, date: task.dueDate };
+61
View File
@@ -0,0 +1,61 @@
import { bootstrapCli } from './cli';
import * as runCliModule from './jobs/run-cli';
const VALID_ENV: NodeJS.ProcessEnv = {
DATABASE_URL: 'postgresql://job:secret@db/app',
JWT_ACCESS_SECRET: 'a'.repeat(32),
};
describe('bootstrapCli', () => {
let runCli: jest.SpyInstance;
beforeEach(() => {
runCli = jest.spyOn(runCliModule, 'runCli').mockResolvedValue(0);
});
afterEach(() => jest.restoreAllMocks());
// A misconfigured CronJob pod must fail before any module connects.
it.each([
[
'a role of another entry point',
{ ...VALID_ENV, APICORE_ROLE: 'worker' },
'APICORE_ROLE=worker cannot start dist/cli.js',
],
[
'"all" in production',
{ ...VALID_ENV, NODE_ENV: 'production', APICORE_ROLE: 'all' },
'APICORE_ROLE=all is for local development and tests only',
],
[
'no role in production',
{ ...VALID_ENV, NODE_ENV: 'production' },
'APICORE_ROLE is required in production',
],
[
'a missing DATABASE_URL',
{ JWT_ACCESS_SECRET: VALID_ENV.JWT_ACCESS_SECRET },
'Missing required environment variable(s): DATABASE_URL',
],
])(
'refuses %s before running a job',
async (_case, env: NodeJS.ProcessEnv, message: string) => {
await expect(bootstrapCli(['cleanup'], env)).rejects.toThrow(message);
expect(runCli).not.toHaveBeenCalled();
},
);
it('runs the named job with its arguments and returns the exit code', async () => {
runCli.mockResolvedValue(1);
await expect(
bootstrapCli(['rekey', '--dry-run'], {
...VALID_ENV,
APICORE_ROLE: 'job',
}),
).resolves.toBe(1);
expect(runCli).toHaveBeenCalledWith(['rekey', '--dry-run']);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { CliExitCode, runCli } from './jobs/run-cli';
import { resolveRole } from './runtime/role';
import { assertStartupEnvironment } from './runtime/startup-checks';
/**
* Entry point of the job role: `node dist/cli.js <job> [args]`, one run per
* CronJob pod. Role and environment are checked like in main.ts and
* worker.ts, before any module connects. Resolves with the exit code:
* 0 succeeded, 1 failed, 2 wrong call. `env` is for tests; the modules
* themselves read process.env.
*/
export async function bootstrapCli(
argv: readonly string[] = process.argv.slice(2),
env: NodeJS.ProcessEnv = process.env,
): Promise<CliExitCode> {
const role = resolveRole('cli', env);
assertStartupEnvironment(role, env);
return runCli(argv);
}
// Started as `node dist/cli.js`; importing this file starts nothing.
if (require.main === module) {
void bootstrapCli().then((code) => {
process.exitCode = code;
});
}
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { ColoniesController } from './colonies.controller';
import { ColoniesService } from './colonies.service';
@Module({
imports: [AutomationModule],
controllers: [ColoniesController],
providers: [ColoniesService],
exports: [ColoniesService],
@@ -3,11 +3,12 @@ import { ColoniesService } from './colonies.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
describe('ColoniesService', () => {
let service: ColoniesService;
let tx: {
$queryRaw: jest.Mock;
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
colonyApiaryHistory: { create: jest.Mock };
colonyLineage: { create: jest.Mock };
@@ -27,6 +28,7 @@ describe('ColoniesService', () => {
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock; recordAll: jest.Mock };
const parentColony = {
id: 'colony-1',
@@ -38,6 +40,7 @@ describe('ColoniesService', () => {
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
colonyApiaryHistory: { create: jest.fn() },
colonyLineage: { create: jest.fn() },
@@ -62,19 +65,30 @@ describe('ColoniesService', () => {
callback(tx),
),
};
// The locked re-read sees what the pre-read saw unless a test says otherwise.
tx.colony.findUnique.mockImplementation(
(args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>,
);
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = {
evaluateColonyHealth: jest.fn().mockResolvedValue(undefined),
events = {
record: jest.fn().mockResolvedValue(undefined),
recordAll: jest.fn().mockResolvedValue(0),
};
service = new ColoniesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
events as unknown as DomainEventsService,
);
});
function transitionTypes(): string[] {
return (
events.recordAll.mock.calls as [unknown, { type: string }[]][]
).flatMap(([, recorded]) => recorded.map((event) => event.type));
}
it('findById throws NotFoundException when missing', async () => {
prisma.colony.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -137,7 +151,11 @@ describe('ColoniesService', () => {
status: 'ACTIVE',
currentQueenId: 'queen-2',
});
tx.colony.update.mockResolvedValue({});
tx.colony.update.mockResolvedValue({
id: 'colony-3',
status: 'MERGED',
healthStatus: null,
});
tx.colonyLineage.create.mockResolvedValue({});
await service.merge(
@@ -242,6 +260,7 @@ describe('ColoniesService', () => {
expect(result.status).toBe('DISSOLVED');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEACTIVATE' }),
tx,
);
});
@@ -320,7 +339,7 @@ describe('ColoniesService', () => {
await expect(
service.reactivate('colony-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.colony.update).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
},
);
@@ -330,7 +349,7 @@ describe('ColoniesService', () => {
status: 'DISSOLVED',
deactivatedAt: new Date(),
});
prisma.colony.update.mockResolvedValue({
tx.colony.update.mockResolvedValue({
...parentColony,
status: 'ACTIVE',
deactivatedAt: null,
@@ -339,13 +358,15 @@ describe('ColoniesService', () => {
const result = await service.reactivate('colony-1', 'user-1');
expect(result.status).toBe('ACTIVE');
expect(prisma.colony.update).toHaveBeenCalledWith({
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { status: 'ACTIVE', deactivatedAt: null },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'UPDATE' }),
tx,
);
expect(transitionTypes()).toEqual(['ColonyStatusChanged']);
});
it('writes an apiary-history entry when an edit corrects the apiary', async () => {
@@ -375,4 +396,215 @@ describe('ColoniesService', () => {
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
healthStatus: null,
});
tx.colony.update.mockResolvedValue({
...parentColony,
healthStatus: 'CRITICAL',
});
await service.update('colony-1', { healthStatus: 'CRITICAL' }, 'user-1');
expect(events.recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({
type: 'ColonyStatusChanged',
aggregateType: 'Colony',
aggregateId: 'colony-1',
actorUserId: 'user-1',
payload: {
from: { status: 'ACTIVE', healthStatus: null },
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
},
}),
]);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'UPDATE' }),
tx,
);
});
it('update records no event when status and health stay the same', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
await service.update('colony-1', { notes: 'x' }, 'user-1');
expect(transitionTypes()).toEqual([]);
});
it('dissolve records ColonyStatusChanged and ColonyEnded { status: "DISSOLVED" }', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
await service.dissolve('colony-1', 'user-1');
expect(events.recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'ColonyStatusChanged' }),
expect.objectContaining({
type: 'ColonyEnded',
aggregateId: 'colony-1',
payload: { status: 'DISSOLVED' },
}),
]);
});
it('merge records ColonyEnded "MERGED" for every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({
id: where.id,
status: 'ACTIVE',
healthStatus: null,
currentQueenId: null,
}),
);
tx.colony.update.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({ id: where.id, status: 'MERGED', healthStatus: null }),
);
await service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3', 'colony-4'] },
'user-1',
);
const ended = (
events.recordAll.mock.calls as [
unknown,
{ type: string; aggregateId: string; payload: { status?: string } }[],
][]
)
.flatMap(([, recorded]) => recorded)
.filter((event) => event.type === 'ColonyEnded');
expect(ended.map((event) => event.aggregateId)).toEqual([
'colony-3',
'colony-4',
]);
expect(ended.map((event) => event.payload.status)).toEqual([
'MERGED',
'MERGED',
]);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }),
tx,
);
});
it('writes the audit entry of create, move and split inside the transaction', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
await service.create(
{ code: 'V-9', apiaryId: 'apiary-1', colonyType: 'PRODUCTION_COLONY' },
'user-1',
);
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
await service.split(
'colony-1',
{ code: 'V-1-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
);
const calls = auditLog.record.mock.calls as [unknown, unknown][];
expect(calls).toHaveLength(3);
expect(calls.every(([, client]) => client === tx)).toBe(true);
});
it('locks the colony row before re-reading it in update, move, dissolve and reactivate', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
await service.update('colony-1', { notes: 'x' }, 'user-1');
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
await service.dissolve('colony-1', 'user-1');
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
await service.reactivate('colony-1', 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
});
it('uses the locked row, not the pre-read, as "before" for event and audit', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
healthStatus: null,
});
// Another request made the colony CRITICAL between pre-read and lock.
tx.colony.findUnique.mockResolvedValue({
...parentColony,
healthStatus: 'CRITICAL',
});
tx.colony.update.mockResolvedValue({
...parentColony,
healthStatus: 'GOOD',
});
await service.update('colony-1', { healthStatus: 'GOOD' }, 'user-1');
expect(events.recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({
payload: {
from: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
to: { status: 'ACTIVE', healthStatus: 'GOOD' },
},
}),
]);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
beforeData: { ...parentColony, healthStatus: 'CRITICAL' },
}),
tx,
);
});
it('rejects dissolving a colony that ended between pre-read and lock', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'SOLD',
});
await expect(service.dissolve('colony-1', 'user-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('locks every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({ id: where.id, status: 'ACTIVE', healthStatus: null }),
);
tx.colony.update.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({ id: where.id, status: 'MERGED', healthStatus: null }),
);
await service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3', 'colony-4'] },
'user-1',
);
// One multi-row statement, not one per colony.
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE');
});
});
+133 -80
View File
@@ -7,7 +7,7 @@ import {
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { Colony, ColonyStatus } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto';
@@ -15,6 +15,8 @@ import { MoveColonyDto } from './dto/move-colony.dto';
import { SplitColonyDto } from './dto/split-colony.dto';
import { MergeColonyDto } from './dto/merge-colony.dto';
import { isLivingColonyStatus } from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
export interface ColonyFilter {
status?: ColonyStatus;
@@ -27,7 +29,7 @@ export class ColoniesService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
@@ -77,14 +79,22 @@ export class ColoniesService {
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
await this.assertApiaryActive(dto.apiaryId);
const colony = await this.prisma.colony.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Colony',
entityId: colony.id,
action: 'CREATE',
userId: actingUserId,
afterData: colony,
const colony = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({ data: { ...dto } });
await this.auditLog.record(
{
entityType: 'Colony',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
return created;
});
// No domain event for a new colony in this stage - the webhook stays on
// the direct path.
await this.webhooks.dispatch('colony.created', colony);
return colony;
}
@@ -94,14 +104,16 @@ export class ColoniesService {
dto: UpdateColonyDto,
actingUserId: string,
): Promise<Colony> {
const before = await this.findById(id);
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
// Fail fast with 404; everything else is decided on the locked row.
await this.findById(id);
const colony = await this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
const updated = await tx.colony.update({
where: { id },
data: { ...dto },
@@ -119,17 +131,23 @@ export class ColoniesService {
},
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
await this.automation.evaluateColonyHealth(before, colony);
return colony;
}
@@ -138,10 +156,11 @@ export class ColoniesService {
dto: MoveColonyDto,
actingUserId: string,
): Promise<Colony> {
const before = await this.findById(id);
this.assertActive(before);
this.assertActive(await this.findById(id));
await this.assertApiaryActive(dto.apiaryId);
const colony = await this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
this.assertActive(before);
const updated = await tx.colony.update({
where: { id },
data: { apiaryId: dto.apiaryId },
@@ -154,16 +173,19 @@ export class ColoniesService {
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
@@ -192,15 +214,18 @@ export class ColoniesService {
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'Colony',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: { ...created, splitFromColonyId: parent.id },
},
tx,
);
return created;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: child.id,
action: 'CREATE',
userId: actingUserId,
afterData: { ...child, splitFromColonyId: parent.id },
});
return child;
}
@@ -222,6 +247,9 @@ export class ColoniesService {
);
}
await this.prisma.$transaction(async (tx) => {
// One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write.
await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds);
for (const mergedColonyId of dto.mergedColonyIds) {
const merged = await tx.colony.findUnique({
where: { id: mergedColonyId },
@@ -238,7 +266,7 @@ export class ColoniesService {
// The survivor keeps its own queen; merged-away queens are simply
// released, not auto-transferred (which colony's queen "wins" a
// merge is a beekeeper decision, not one to guess here).
await tx.colony.update({
const mergedAway = await tx.colony.update({
where: { id: mergedColonyId },
data: {
status: 'MERGED',
@@ -246,6 +274,10 @@ export class ColoniesService {
currentQueenId: null,
},
});
await this.events.recordAll(
tx,
colonyTransitionEvents(merged, mergedAway, actingUserId),
);
if (merged.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
@@ -266,24 +298,28 @@ export class ColoniesService {
},
});
}
});
await this.auditLog.record({
entityType: 'Colony',
entityId: survivor.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: survivor,
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
await this.auditLog.record(
{
entityType: 'Colony',
entityId: survivor.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: survivor,
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
},
tx,
);
});
return this.findById(survivor.id);
}
async dissolve(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id);
// Re-dissolving would overwrite deactivatedAt and the terminal status
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
this.assertActive(before);
this.assertActive(await this.findById(id));
const colony = await this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
// Re-dissolving would overwrite deactivatedAt and the terminal status
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
this.assertActive(before);
const updated = await tx.colony.update({
where: { id },
data: {
@@ -302,39 +338,56 @@ export class ColoniesService {
data: { endDate: new Date() },
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
async reactivate(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id);
// Only an ended colony can be brought back. A weak or queenless one
// still exists - reactivating it would just overwrite its condition.
if (isLivingColonyStatus(before.status)) {
throw new ConflictException('Colony is already active');
}
const colony = await this.prisma.colony.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
await this.findById(id);
return this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
// Only an ended colony can be brought back. A weak or queenless one
// still exists - reactivating it would just overwrite its condition.
if (isLivingColonyStatus(before.status)) {
throw new ConflictException('Colony is already active');
}
const updated = await tx.colony.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
async getHistory(id: string) {
@@ -0,0 +1,72 @@
import { Colony } from '../../generated/prisma';
import { colonyTransitionEvents } from './colony-events';
function colony(overrides: Partial<Colony> = {}): Colony {
return {
id: 'c1',
status: 'ACTIVE',
healthStatus: null,
...overrides,
} as Colony;
}
describe('colonyTransitionEvents', () => {
it('records nothing when neither status nor healthStatus changed', () => {
expect(
colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1'),
).toEqual([]);
});
it('records ColonyStatusChanged with from/to snapshots on a health change', () => {
const events = colonyTransitionEvents(
colony(),
colony({ healthStatus: 'CRITICAL' }),
'u1',
);
expect(events).toEqual([
{
type: 'ColonyStatusChanged',
aggregateType: 'Colony',
aggregateId: 'c1',
actorUserId: 'u1',
payload: {
from: { status: 'ACTIVE', healthStatus: null },
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
},
},
]);
});
it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] as const)(
'adds ColonyEnded { status: %s } on the transition from living to ended',
(status) => {
const events = colonyTransitionEvents(
colony({ status: 'WEAK' }),
colony({ status }),
null,
);
expect(events.map((event) => event.type)).toEqual([
'ColonyStatusChanged',
'ColonyEnded',
]);
expect(events[1].payload).toEqual({ status });
},
);
it('does not add ColonyEnded between two ended statuses or on reactivation', () => {
expect(
colonyTransitionEvents(
colony({ status: 'DISSOLVED' }),
colony({ status: 'DECEASED' }),
'u1',
).map((event) => event.type),
).toEqual(['ColonyStatusChanged']);
expect(
colonyTransitionEvents(
colony({ status: 'DISSOLVED' }),
colony({ status: 'ACTIVE' }),
'u1',
).map((event) => event.type),
).toEqual(['ColonyStatusChanged']);
});
});
@@ -0,0 +1,74 @@
import { NotFoundException } from '@nestjs/common';
import { Colony, Prisma } from '../../generated/prisma';
import { lockRowForUpdate } from '../prisma/row-lock';
import {
AnyNewDomainEvent,
ColonyStateSnapshot,
} from '../domain-events/domain-event-types';
import { isLivingColonyStatus } from './colony-status';
type ColonyState = Pick<Colony, 'status' | 'healthStatus'>;
function snapshotOf(colony: ColonyState): ColonyStateSnapshot {
return { status: colony.status, healthStatus: colony.healthStatus };
}
/**
* The domain events of one colony going from `before` to `after`:
* ColonyStatusChanged as soon as status or healthStatus differs, plus
* ColonyEnded when a living colony became an ended one. Empty when nothing
* the events care about changed. The caller records them in the same
* transaction as the colony update.
*/
export function colonyTransitionEvents(
before: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
after: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
actorUserId: string | null,
): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] {
if (
before.status === after.status &&
before.healthStatus === after.healthStatus
) {
return [];
}
const events: AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] = [
{
type: 'ColonyStatusChanged',
aggregateType: 'Colony',
aggregateId: after.id,
actorUserId,
payload: { from: snapshotOf(before), to: snapshotOf(after) },
},
];
if (
isLivingColonyStatus(before.status) &&
!isLivingColonyStatus(after.status)
) {
events.push({
type: 'ColonyEnded',
aggregateType: 'Colony',
aggregateId: after.id,
actorUserId,
payload: { status: after.status },
});
}
return events;
}
/**
* Locks the colony row and re-reads it inside the transaction. Use the
* result - not an earlier read - as "before" for checks, audit and
* colonyTransitionEvents, so a concurrent change cannot be missed or
* mislabelled.
*/
export async function lockColony(
tx: Prisma.TransactionClient,
id: string,
): Promise<Colony> {
await lockRowForUpdate(tx, 'Colony', id);
const colony = await tx.colony.findUnique({ where: { id } });
if (!colony) {
throw new NotFoundException('Colony not found');
}
return colony;
}
@@ -0,0 +1,86 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
// Side effects run through the domain-event backbone. runAfterCommit and the
// direct wrappers stay only for the legacy paths that have no event yet
// (ARCHITECTURE assumption 11). A new caller must record an event instead.
const SRC = join(__dirname, '..');
const DEFINITION = 'common/after-commit.ts';
function productionSources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return productionSources(path);
}
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
? [path]
: [];
});
}
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
}
function filesCalling(call: string): string[] {
return productionSources(SRC)
.filter((file) =>
withoutComments(readFileSync(file, 'utf8')).includes(call),
)
.map((file) => relative(SRC, file).split(sep).join('/'))
.filter((file) => file !== DEFINITION)
.map((file) => `src/${file}`)
.sort();
}
describe('legacy after-commit paths', () => {
it('runAfterCommit is only used by the remaining legacy paths', () => {
expect(filesCalling('runAfterCommit(')).toEqual([
'src/automation/automation.service.ts',
'src/calendar-sync/calendar-sync.service.ts',
'src/webhooks/webhooks.service.ts',
]);
});
it('only the queen assignment still enqueues a calendar entry directly', () => {
expect(filesCalling('calendarSync.enqueue(')).toEqual([
'src/queens/queens.service.ts',
]);
});
it('only the modules without an event still dispatch webhooks directly', () => {
expect(filesCalling('webhooks.dispatch(')).toEqual([
'src/colonies/colonies.service.ts',
'src/honey-batches/honey-batches.service.ts',
'src/queens/queens.service.ts',
'src/storage/storage.service.ts',
]);
});
it('no longer defines the removed post-commit automation wrappers (only SWARM_MOOD_HIGH and STOCK_ITEM_LOW keep one)', () => {
const removed = [
'evaluateInspection',
'evaluateVarroaMeasurement',
'evaluateTreatment',
'evaluateFeeding',
'evaluateColonyHealth',
'evaluateRawMaterialLow',
].map(
(name) =>
[
name,
new RegExp(
String.raw`\b(?:public\s+|private\s+)?async\s+${name}\s*\(`,
),
] as const,
);
const definitions = productionSources(SRC).flatMap((file) => {
const source = withoutComments(readFileSync(file, 'utf8'));
return removed
.filter(([, pattern]) => pattern.test(source))
.map(([name]) => name);
});
expect(definitions).toEqual([]);
});
});
@@ -0,0 +1,111 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { assertLivingColonies } from './assert-living-colonies';
describe('assertLivingColonies', () => {
let tx: { $queryRaw: jest.Mock; colony: { findMany: jest.Mock } };
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
colony: { findMany: jest.fn().mockResolvedValue([]) },
};
});
async function rejection(colonyIds: string[]): Promise<BadRequestException> {
const error: unknown = await assertLivingColonies(
tx as never,
colonyIds,
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BadRequestException);
return error as BadRequestException;
}
it('locks the colonies for share before it reads them through the transaction', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'c2', status: 'ACTIVE', apiaryId: 'a1' },
{ id: 'c1', status: 'ACTIVE', apiaryId: 'a1' },
]);
await assertLivingColonies(tx as never, ['c2', 'c1']);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR SHARE',
);
expect(query.values).toEqual([['c1', 'c2']]);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.colony.findMany.mock.invocationCallOrder[0],
);
expect(tx.colony.findMany).toHaveBeenCalledWith({
where: { id: { in: ['c2', 'c1'] } },
select: { id: true, status: true, apiaryId: true },
});
});
it('returns the apiary of every colony that still lives', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'c1', status: 'ACTIVE', apiaryId: 'a1' },
{ id: 'c2', status: 'WEAK', apiaryId: 'a2' },
{ id: 'c3', status: 'CONSPICUOUS', apiaryId: 'a1' },
{ id: 'c4', status: 'QUEENLESS', apiaryId: 'a2' },
]);
const colonies = await assertLivingColonies(tx as never, [
'c1',
'c2',
'c3',
'c4',
]);
expect(colonies).toEqual(
new Map([
['c1', { apiaryId: 'a1' }],
['c2', { apiaryId: 'a2' }],
['c3', { apiaryId: 'a1' }],
['c4', { apiaryId: 'a2' }],
]),
);
});
it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'])(
'rejects a colony with the status %s',
async (status) => {
tx.colony.findMany.mockResolvedValue([
{ id: 'c1', status: 'ACTIVE', apiaryId: 'a1' },
{ id: 'c2', status, apiaryId: 'a1' },
]);
const error = await rejection(['c1', 'c2']);
expect(error.getResponse()).toEqual({
message: 'Einige Völker sind nicht (mehr) erfassbar',
invalidColonyIds: ['c2'],
});
},
);
it('names every ended and unknown colony in request order', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'c1', status: 'SOLD', apiaryId: 'a1' },
{ id: 'c2', status: 'ACTIVE', apiaryId: 'a1' },
]);
const error = await rejection(['missing', 'c2', 'c1']);
expect(error.getResponse()).toEqual({
message: 'Einige Völker sind nicht (mehr) erfassbar',
invalidColonyIds: ['missing', 'c1'],
});
});
it('refuses the root client before it locks or reads anything', async () => {
const root = { $connect: jest.fn(), ...tx };
await expect(assertLivingColonies(root as never, ['c1'])).rejects.toThrow(
'needs the transaction client',
);
expect(tx.$queryRaw).not.toHaveBeenCalled();
expect(tx.colony.findMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,41 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { isLivingColonyStatus } from '../../colonies/colony-status';
import { lockRowsForShare } from '../../prisma/row-lock';
export interface LivingColony {
apiaryId: string;
}
/**
* Checks, inside the bulk transaction, that every colony exists and still
* lives, and returns where each one stands. The colonies are locked FOR
* SHARE first: none can be ended or moved before the bulk commits, so the
* apiary returned is the one kept on the records.
*
* Unknown and ended colonies are named together in `invalidColonyIds` - the
* client can drop them and send the rest again; nothing is stored.
*/
export async function assertLivingColonies(
tx: Prisma.TransactionClient,
colonyIds: readonly string[],
): Promise<Map<string, LivingColony>> {
await lockRowsForShare(tx, 'Colony', colonyIds);
const colonies = await tx.colony.findMany({
where: { id: { in: [...colonyIds] } },
select: { id: true, status: true, apiaryId: true },
});
const living = new Map<string, LivingColony>(
colonies
.filter((colony) => isLivingColonyStatus(colony.status))
.map((colony) => [colony.id, { apiaryId: colony.apiaryId }]),
);
const invalidColonyIds = colonyIds.filter((id) => !living.has(id));
if (invalidColonyIds.length > 0) {
throw new BadRequestException({
message: 'Einige Völker sind nicht (mehr) erfassbar',
invalidColonyIds,
});
}
return living;
}
@@ -0,0 +1,21 @@
import { RecordedForColony } from '../../domain-events/domain-event-types';
/**
* Answer of a bulk entry: one record per colony, in request order. `bulkId`
* groups them - it is also in the payload of each record's domain event.
*/
export interface BulkCreateResult<T> {
bulkId: string;
records: T[];
}
/**
* Payload of a *Recorded event: `bulkId` only when the record comes from a
* bulk entry, so a single create's payload stays `{ colonyId }`.
*/
export function recordedForColony(
colonyId: string,
bulkId?: string,
): RecordedForColony {
return bulkId === undefined ? { colonyId } : { colonyId, bulkId };
}
@@ -0,0 +1,13 @@
/**
* Most colonies one bulk entry (POST /treatments/bulk, /feedings/bulk,
* /varroa-measurements/bulk) may cover - more than any apiary holds, and it
* keeps one request from becoming an unbounded transaction.
*/
export const BULK_MAX_COLONIES = 100;
/**
* A bulk entry writes up to BULK_MAX_COLONIES records, each with its audit
* entry and domain event, in one transaction - Prisma's 5 s default is too
* short for the upper end.
*/
export const BULK_TRANSACTION_TIMEOUT_MS = 15_000;
@@ -0,0 +1,84 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsNotEmpty,
IsString,
ValidateNested,
} from 'class-validator';
import { BULK_MAX_COLONIES } from './bulk-limits';
const DUPLICATE_COLONY_MESSAGE = '$property enthält ein Volk doppelt';
// The size rule every bulk list shares: at least one colony, at most
// BULK_MAX_COLONIES.
function bulkListSize(): PropertyDecorator[] {
return [
IsArray({ message: '$property muss eine Liste sein' }),
ArrayMinSize(1, { message: '$property braucht mindestens ein Volk' }),
ArrayMaxSize(BULK_MAX_COLONIES, {
message: `$property darf höchstens ${BULK_MAX_COLONIES} Völker enthalten`,
}),
];
}
// The rule for one colony id - in colonyIds (each) and in every entry - so an
// empty id is invalid input on both paths, never an "unknown colony".
function colonyIdChecks(each: boolean): PropertyDecorator[] {
return [
IsString({ each, message: '$property enthält eine ungültige Volk-ID' }),
IsNotEmpty({ each, message: '$property enthält eine leere Volk-ID' }),
];
}
/** `colonyIds` of a bulk entry with shared values: distinct, non-empty ids. */
export function IsBulkColonyIds(): PropertyDecorator {
return applyDecorators(
ApiProperty({
type: [String],
minItems: 1,
maxItems: BULK_MAX_COLONIES,
uniqueItems: true,
description: `Die Völker, für die derselbe Eintrag gilt (1 bis ${BULK_MAX_COLONIES}, jedes nur einmal)`,
}),
...bulkListSize(),
ArrayUnique({ message: DUPLICATE_COLONY_MESSAGE }),
...colonyIdChecks(true),
);
}
/** `colonyId` of one entry in `entries`: a non-empty id. */
export function IsBulkEntryColonyId(): PropertyDecorator {
return applyDecorators(
ApiProperty({ description: 'Das Volk dieses Eintrags' }),
...colonyIdChecks(false),
);
}
/**
* `entries` of a bulk entry with one value set per colony: each entry is
* validated as its class, and each colony appears once.
*/
export function IsBulkEntries(
entryType: new () => { colonyId: string },
): PropertyDecorator {
return applyDecorators(
ApiProperty({
type: [entryType],
minItems: 1,
maxItems: BULK_MAX_COLONIES,
description: `Ein Eintrag je Volk (1 bis ${BULK_MAX_COLONIES}, jedes Volk nur einmal)`,
}),
...bulkListSize(),
// class-validator skips null entries before calling the selector.
ArrayUnique((entry: { colonyId?: unknown }) => entry.colonyId, {
message: DUPLICATE_COLONY_MESSAGE,
}),
ValidateNested({ each: true }),
Type(() => entryType),
);
}
@@ -0,0 +1,137 @@
import { BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { BULK_TRANSACTION_TIMEOUT_MS } from './bulk-limits';
import { BulkEntryWork, runBulk } from './run-bulk';
describe('runBulk', () => {
let tx: {
$queryRaw: jest.Mock;
colony: { findMany: jest.Mock };
write: jest.Mock;
};
let prisma: { $transaction: jest.Mock };
// Writes of the fake transaction that survived its commit.
let committed: string[];
beforeEach(() => {
committed = [];
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
colony: {
findMany: jest.fn().mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'WEAK', apiaryId: 'apiary-2' },
{ id: 'colony-3', status: 'ACTIVE', apiaryId: 'apiary-1' },
]),
},
write: jest.fn(),
};
// Keeps the writes only when the work succeeds - a rollback otherwise.
prisma = {
$transaction: jest.fn(
async (work: (txArg: typeof tx) => Promise<unknown>) => {
const pending: string[] = [];
tx.write.mockImplementation((value: string) => {
pending.push(value);
});
const result = await work(tx);
committed.push(...pending);
return result;
},
),
};
});
const run = <E extends { colonyId: string }, T>(
entries: E[],
perEntry: BulkEntryWork<E, T>,
) => runBulk(prisma as unknown as PrismaService, entries, perEntry);
it('runs every entry in request order in one transaction with the bulk timeout', async () => {
const result = await run(
[{ colonyId: 'colony-3' }, { colonyId: 'colony-1' }],
(txArg, entry) => {
(txArg as unknown as typeof tx).write(entry.colonyId);
return Promise.resolve(`record-of-${entry.colonyId}`);
},
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: BULK_TRANSACTION_TIMEOUT_MS,
});
expect(result.records).toEqual([
'record-of-colony-3',
'record-of-colony-1',
]);
expect(committed).toEqual(['colony-3', 'colony-1']);
});
it('hands every entry the transaction, its colony and the one bulkId it returns', async () => {
const perEntry = jest.fn().mockResolvedValue('record');
const { bulkId } = await run(
[{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }],
perEntry,
);
expect(bulkId).toMatch(/^[0-9a-f-]{36}$/);
expect(perEntry.mock.calls).toEqual([
[tx, { colonyId: 'colony-1' }, { apiaryId: 'apiary-1' }, bulkId],
[tx, { colonyId: 'colony-2' }, { apiaryId: 'apiary-2' }, bulkId],
]);
});
it('gives each bulk its own bulkId', async () => {
const perEntry = jest.fn().mockResolvedValue('record');
const first = await run([{ colonyId: 'colony-1' }], perEntry);
const second = await run([{ colonyId: 'colony-1' }], perEntry);
expect(first.bulkId).not.toBe(second.bulkId);
});
it('checks every colony under the share lock before the first entry runs', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'DISSOLVED', apiaryId: 'apiary-1' },
]);
const perEntry = jest.fn();
const error: unknown = await run(
[{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }],
perEntry,
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BadRequestException);
expect((error as BadRequestException).getResponse()).toEqual(
expect.objectContaining({ invalidColonyIds: ['colony-2'] }),
);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(perEntry).not.toHaveBeenCalled();
});
it('rolls back every record when one entry fails', async () => {
const perEntry = jest.fn((txArg: unknown, entry: { colonyId: string }) => {
(txArg as typeof tx).write(entry.colonyId);
return entry.colonyId === 'colony-2'
? Promise.reject(new Error('audit write failed'))
: Promise.resolve(entry.colonyId);
});
await expect(
run(
[
{ colonyId: 'colony-1' },
{ colonyId: 'colony-2' },
{ colonyId: 'colony-3' },
],
perEntry,
),
).rejects.toThrow('audit write failed');
expect(committed).toEqual([]);
// The entries after the failure are not started.
expect(perEntry).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,51 @@
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
import { assertLivingColonies, LivingColony } from './assert-living-colonies';
import { BulkCreateResult } from './bulk-create';
import { BULK_TRANSACTION_TIMEOUT_MS } from './bulk-limits';
/**
* Writes the record of one entry - record, audit entry and domain event,
* all through `tx`. `colony` is the entry's colony as read under the share
* lock; `bulkId` goes into the event payload.
*/
export type BulkEntryWork<E, T> = (
tx: Prisma.TransactionClient,
entry: E,
colony: LivingColony,
bulkId: string,
) => Promise<T>;
/**
* The frame of every bulk entry, all or none: one transaction with
* BULK_TRANSACTION_TIMEOUT_MS, first assertLivingColonies for all colonies,
* then `perEntry` once per entry in request order. The first failure rolls
* back every record; the records come back in request order with a new
* bulkId.
*/
export async function runBulk<E extends { colonyId: string }, T>(
prisma: PrismaService,
entries: readonly E[],
perEntry: BulkEntryWork<E, T>,
): Promise<BulkCreateResult<T>> {
const bulkId = randomUUID();
const records = await prisma.$transaction(
async (tx) => {
const colonies = await assertLivingColonies(
tx,
entries.map((entry) => entry.colonyId),
);
const created: T[] = [];
for (const entry of entries) {
// Every requested id is in the map - assertLivingColonies threw
// otherwise.
const colony = colonies.get(entry.colonyId)!;
created.push(await perEntry(tx, entry, colony, bulkId));
}
return created;
},
{ timeout: BULK_TRANSACTION_TIMEOUT_MS },
);
return { bulkId, records };
}
@@ -1,62 +1,75 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { readFileSync } from 'node:fs';
import * as path from 'node:path';
import { PrismaService } from '../../prisma/prisma.service';
import { IdempotencyCleanupService } from './idempotency-cleanup.service';
import {
CONSUMED_TOKEN_GRACE_HOURS,
IDEMPOTENCY_RETENTION_DAYS,
IdempotencyCleanupService,
} from './idempotency-cleanup.service';
type RawCall = [readonly string[], ...unknown[]];
describe('IdempotencyCleanupService', () => {
let executeRaw: jest.Mock<Promise<number>, RawCall>;
let service: IdempotencyCleanupService;
let prisma: {
idempotencyRecord: { deleteMany: jest.Mock };
consumedToken: { deleteMany: jest.Mock };
};
const statements = (): { sql: string; values: unknown[] }[] =>
executeRaw.mock.calls.map(([strings, ...values]) => ({
sql: strings.join('?').replace(/\s+/g, ' '),
values,
}));
beforeEach(() => {
prisma = {
idempotencyRecord: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
consumedToken: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
service = new IdempotencyCleanupService(prisma as unknown as PrismaService);
});
afterEach(() => {
jest.restoreAllMocks();
executeRaw = jest
.fn<Promise<number>, RawCall>()
.mockResolvedValueOnce(3)
.mockResolvedValueOnce(4);
service = new IdempotencyCleanupService({
$executeRaw: executeRaw,
} as unknown as PrismaService);
});
it('removes records older than the retention period and expired tokens', async () => {
prisma.idempotencyRecord.deleteMany.mockResolvedValue({ count: 3 });
const now = new Date('2026-10-12T04:00:00.000Z');
await expect(service.removeExpired(now)).resolves.toBe(3);
expect(prisma.idempotencyRecord.deleteMany).toHaveBeenCalledWith({
where: { createdAt: { lt: new Date('2026-07-14T04:00:00.000Z') } },
});
expect(prisma.consumedToken.deleteMany).toHaveBeenCalledWith({
where: { expiresAt: { lt: now } },
await expect(service.removeExpired()).resolves.toEqual({
idempotencyRecords: 3,
consumedTokens: 4,
});
const [records, tokens] = statements();
expect(records.sql).toContain('DELETE FROM "IdempotencyRecord"');
expect(records.sql).toContain(
'"createdAt" < now() - (? * interval \'1 day\')',
);
expect(records.values).toEqual([IDEMPOTENCY_RETENTION_DAYS]);
expect(tokens.sql).toContain('DELETE FROM "ConsumedToken"');
expect(tokens.sql).toContain(
'"expiresAt" < now() - (? * interval \'1 hour\')',
);
expect(tokens.values).toEqual([CONSUMED_TOKEN_GRACE_HOURS]);
});
// The cron tick's own errors would otherwise reach the scheduler's logger
// with the full Prisma message and stack.
it('the daily tick never throws and logs a failure without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.idempotencyRecord.deleteMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('key "Max Mustermann"', {
code: 'P2024',
clientVersion: '7.9.1',
meta: { modelName: 'IdempotencyRecord' },
}),
// The job pod's clock is not the clock the api pods check token expiry
// with; the database clock plus a margin keeps a used token's row until
// no api pod can accept the token any more.
it('compares only against the database clock, with a margin for tokens', async () => {
await service.removeExpired();
expect(CONSUMED_TOKEN_GRACE_HOURS).toBe(1);
for (const { sql, values } of statements()) {
expect(sql).toContain('now()');
expect(values.some((value) => value instanceof Date)).toBe(false);
}
});
// The cleanup CronJob (dist/cli.js cleanup) runs it once a day; an
// in-process timer would run it again in every api pod.
it('has no in-process schedule of its own', () => {
const source = readFileSync(
path.join(__dirname, 'idempotency-cleanup.service.ts'),
'utf8',
);
await expect(service.scheduledCleanup()).resolves.toBeUndefined();
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2024');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect(source).not.toMatch(/@nestjs\/schedule|@Cron\(/);
expect('scheduledCleanup' in service).toBe(false);
});
});
@@ -1,46 +1,45 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
import { loggableError } from '../loggable-error';
// Longer than any realistic offline stretch at the apiary: an entry queued
// on the phone must still find its key when it finally syncs, otherwise the
// repeat would create a duplicate after all.
export const IDEMPOTENCY_RETENTION_DAYS = 90;
// The api pods check token expiry against their own clock. A used token's
// row stays this long past its expiry (database clock), so a pod whose
// clock lags behind can never accept the token again.
export const CONSUMED_TOKEN_GRACE_HOURS = 1;
export interface ExpiredRemovalCounts {
idempotencyRecords: number;
consumedTokens: number;
}
/**
* Removes expired idempotency records and used one-time tokens. Runs once a
* day as part of the cleanup job (dist/cli.js cleanup, CronJob
* apicore-job-cleanup), not as an in-process timer in every pod. Ages are
* compared against now(), the database clock, like every cleanup step.
*/
@Injectable()
export class IdempotencyCleanupService {
private readonly logger = new Logger(IdempotencyCleanupService.name);
constructor(private readonly prisma: PrismaService) {}
// The daily tick. An error is logged here, Prisma errors by name/code/model
// only (see loggableError) - left uncaught, the scheduler would log the
// full Prisma message and stack.
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async scheduledCleanup(): Promise<void> {
try {
await this.removeExpired();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Idempotency cleanup failed: ${message}`, stack);
}
}
async removeExpired(now: Date = new Date()): Promise<number> {
const cutoff = new Date(
now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
);
const { count } = await this.prisma.idempotencyRecord.deleteMany({
where: { createdAt: { lt: cutoff } },
});
if (count > 0) {
this.logger.log(`Removed ${count} expired idempotency record(s)`);
async removeExpired(): Promise<ExpiredRemovalCounts> {
const idempotencyRecords = await this.prisma.$executeRaw`
DELETE FROM "IdempotencyRecord"
WHERE "createdAt" < now() - (${IDEMPOTENCY_RETENTION_DAYS} * interval '1 day')`;
if (idempotencyRecords > 0) {
this.logger.log(
`Removed ${idempotencyRecords} expired idempotency record(s)`,
);
}
// Used one-time tokens only matter until the token itself expires.
await this.prisma.consumedToken.deleteMany({
where: { expiresAt: { lt: now } },
});
return count;
const consumedTokens = await this.prisma.$executeRaw`
DELETE FROM "ConsumedToken"
WHERE "expiresAt" < now() - (${CONSUMED_TOKEN_GRACE_HOURS} * interval '1 hour')`;
return { idempotencyRecords, consumedTokens };
}
}
@@ -35,6 +35,10 @@ export class PrismaThrottlerStorage implements ThrottlerStorage {
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.
@@ -42,10 +46,10 @@ export class PrismaThrottlerStorage implements ThrottlerStorage {
INSERT INTO "ThrottlerHit" AS t ("key", "throttlerName", "hits", "expiresAt", "blockedUntil")
VALUES (
${storedKey}, ${throttlerName}, 1,
now() + (${ttl}::double precision * interval '1 millisecond'),
date_trunc('milliseconds', now()) + (${ttl}::double precision * interval '1 millisecond'),
CASE
WHEN 1 > ${limit}::int
THEN now() + (${blockDuration}::double precision * interval '1 millisecond')
THEN date_trunc('milliseconds', now()) + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
)
@@ -58,7 +62,7 @@ export class PrismaThrottlerStorage implements ThrottlerStorage {
"expiresAt" = CASE
WHEN t."blockedUntil" > now() THEN t."expiresAt"
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL
THEN now() + (${ttl}::double precision * interval '1 millisecond')
THEN date_trunc('milliseconds', now()) + (${ttl}::double precision * interval '1 millisecond')
ELSE t."expiresAt"
END,
"blockedUntil" = CASE
@@ -66,11 +70,11 @@ export class PrismaThrottlerStorage implements ThrottlerStorage {
WHEN t."expiresAt" <= now() OR t."blockedUntil" IS NOT NULL THEN
CASE
WHEN 1 > ${limit}::int
THEN now() + (${blockDuration}::double precision * interval '1 millisecond')
THEN date_trunc('milliseconds', now()) + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
WHEN t."hits" + 1 > ${limit}::int
THEN now() + (${blockDuration}::double precision * interval '1 millisecond')
THEN date_trunc('milliseconds', now()) + (${blockDuration}::double precision * interval '1 millisecond')
ELSE NULL
END
RETURNING
@@ -12,6 +12,15 @@ export interface ColonyStateSnapshot {
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'
@@ -20,11 +29,11 @@ export interface ColonyStateSnapshot {
export interface DomainEventPayloads {
InspectionRecorded: { colonyId: string };
InspectionCorrected: { colonyId: string };
VarroaMeasured: { colonyId: string };
VarroaMeasured: RecordedForColony;
VarroaCorrected: { colonyId: string };
TreatmentRecorded: { colonyId: string };
TreatmentRecorded: RecordedForColony;
TreatmentCorrected: { colonyId: string };
FeedingRecorded: { colonyId: string };
FeedingRecorded: RecordedForColony;
FeedingCorrected: { colonyId: string };
HarvestRecorded: { apiaryId: string; colonyIds: string[] };
HarvestCorrected: { apiaryId: string; colonyIds: string[] };
@@ -155,6 +164,14 @@ export type DomainEventHandlerFn<T extends DomainEventType = DomainEventType> =
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
@@ -5,6 +5,7 @@ import {
DOMAIN_EVENT_TYPES,
dailyDedupeKey,
NewDomainEvent,
utcDay,
} from './domain-event-types';
type CreateManyArg = {
@@ -208,6 +209,21 @@ describe('domain event catalog', () => {
);
},
);
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
@@ -0,0 +1,11 @@
import { OmitType } from '@nestjs/swagger';
import { IsBulkColonyIds } from '../../common/bulk/bulk-validation';
import { CreateFeedingDto } from './create-feeding.dto';
/** One feeding for several colonies: every field entered once, stored on each record. */
export class BulkCreateFeedingDto extends OmitType(CreateFeedingDto, [
'colonyId',
] as const) {
@IsBulkColonyIds()
colonyIds!: string[];
}
@@ -4,6 +4,7 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateFeedingDto } from './dto/create-feeding.dto';
import { BulkCreateFeedingDto } from './dto/bulk-create-feeding.dto';
import { UpdateFeedingDto } from './dto/update-feeding.dto';
import { FeedingsService } from './feedings.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@@ -36,6 +37,17 @@ export class FeedingsController {
return this.feedingsService.create(dto, actor.id);
}
/** One feeding for several colonies - all records or none. */
@RequirePermission('feedings:create')
@Idempotent()
@Post('bulk')
createBulk(
@Body() dto: BulkCreateFeedingDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.feedingsService.createBulk(dto, actor.id);
}
@RequirePermission('feedings:update')
@Patch(':id')
update(
@@ -1,11 +1,9 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { FeedingsController } from './feedings.controller';
import { ColonyFeedingsController } from './colony-feedings.controller';
import { FeedingsService } from './feedings.service';
@Module({
imports: [AutomationModule],
controllers: [FeedingsController, ColonyFeedingsController],
providers: [FeedingsService],
exports: [FeedingsService],
@@ -1,9 +1,9 @@
import { NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { FeedingsService } from './feedings.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
describe('FeedingsService', () => {
let service: FeedingsService;
@@ -11,33 +11,50 @@ describe('FeedingsService', () => {
feeding: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
groupBy: jest.Mock;
};
$transaction: jest.Mock;
};
let tx: {
feeding: {
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
colony: { findMany: jest.Mock };
$queryRaw: jest.Mock;
};
let auditLog: { record: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
let events: { record: jest.Mock };
const feeding = { id: 'feeding-1', colonyId: 'colony-1', date: new Date() };
beforeEach(() => {
tx = {
feeding: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
colony: { findMany: jest.fn().mockResolvedValue([]) },
$queryRaw: jest.fn().mockResolvedValue([]),
};
prisma = {
feeding: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
groupBy: jest.fn(),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn() };
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
events = { record: jest.fn() };
service = new FeedingsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
calendarSync as unknown as CalendarSyncService,
{ evaluateFeeding: jest.fn() } as unknown as AutomationService,
events as unknown as DomainEventsService,
);
});
@@ -49,7 +66,7 @@ describe('FeedingsService', () => {
});
it('create stores the acting user as the feeder', async () => {
prisma.feeding.create.mockResolvedValue(feeding);
tx.feeding.create.mockResolvedValue(feeding);
await service.create(
{
@@ -62,7 +79,7 @@ describe('FeedingsService', () => {
'user-1',
);
const [[createArg]] = prisma.feeding.create.mock.calls as [
const [[createArg]] = tx.feeding.create.mock.calls as [
[{ data: { colonyId: string; userId: string } }],
];
expect(createArg.data.colonyId).toBe('colony-1');
@@ -70,9 +87,9 @@ describe('FeedingsService', () => {
});
it('update writes an audit log entry with before and after data', async () => {
prisma.feeding.findUnique.mockResolvedValue(feeding);
tx.feeding.findUnique.mockResolvedValue(feeding);
const updated = { ...feeding, notes: 'geändert' };
prisma.feeding.update.mockResolvedValue(updated);
tx.feeding.update.mockResolvedValue(updated);
const result = await service.update(
'feeding-1',
@@ -89,16 +106,79 @@ describe('FeedingsService', () => {
beforeData: feeding,
afterData: updated,
}),
tx,
);
});
it('update re-enqueues the calendar sync so a changed date moves the event', async () => {
prisma.feeding.findUnique.mockResolvedValue(feeding);
prisma.feeding.update.mockResolvedValue(feeding);
it('create writes feeding, audit entry and FeedingRecorded in one transaction', async () => {
tx.feeding.create.mockResolvedValue(feeding);
await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
feedType: 'SUGAR_WATER',
amount: 2,
unit: 'l',
},
'user-1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Feeding',
entityId: 'feeding-1',
action: 'CREATE',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'FeedingRecorded',
aggregateType: 'Feeding',
aggregateId: 'feeding-1',
payload: { colonyId: 'colony-1' },
actorUserId: 'user-1',
});
});
it('update takes "before" from the locked row inside the transaction, not from a pre-read', async () => {
const stale = { ...feeding, notes: 'veraltet' };
const locked = { ...feeding, notes: 'gesperrt gelesen' };
prisma.feeding.findUnique.mockResolvedValue(stale);
tx.feeding.findUnique.mockResolvedValue(locked);
tx.feeding.update.mockResolvedValue(feeding);
await service.update('feeding-1', { notes: 'neu' }, 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ beforeData: locked }),
tx,
);
});
it('update records FeedingCorrected and no automation', async () => {
tx.feeding.findUnique.mockResolvedValue(feeding);
tx.feeding.update.mockResolvedValue(feeding);
await service.update('feeding-1', { date: '2026-05-02' }, 'user-1');
expect(calendarSync.enqueue).toHaveBeenCalledWith('FEEDING', 'feeding-1');
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'UPDATE' }),
tx,
);
expect(events.record).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'FeedingCorrected',
aggregateType: 'Feeding',
aggregateId: 'feeding-1',
payload: { colonyId: 'colony-1' },
}),
);
});
it('summaryByColony sums amounts per unit', async () => {
@@ -110,4 +190,90 @@ describe('FeedingsService', () => {
expect(summary).toEqual([{ unit: 'l', totalAmount: 5.5 }]);
});
describe('createBulk', () => {
const shared = {
date: '2026-09-01',
feedType: 'SUGAR_WATER' as const,
amount: 5,
unit: 'l',
batchNumber: 'F-7',
};
type CreateArg = { data: Record<string, unknown> & { colonyId: string } };
beforeEach(() => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'QUEENLESS', apiaryId: 'apiary-2' },
]);
tx.feeding.create.mockImplementation(({ data }: CreateArg) =>
Promise.resolve({ ...data, id: `feeding-of-${data.colonyId}` }),
);
});
it('createBulk writes one feeding per colony with audit entry and FeedingRecorded carrying the bulkId', async () => {
const result = await service.createBulk(
{ ...shared, colonyIds: ['colony-1', 'colony-2'] },
'user-1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: BULK_TRANSACTION_TIMEOUT_MS,
});
expect(result.records.map((record) => record.id)).toEqual([
'feeding-of-colony-1',
'feeding-of-colony-2',
]);
for (const colonyId of ['colony-1', 'colony-2']) {
expect(tx.feeding.create).toHaveBeenCalledWith({
data: expect.objectContaining({
...shared,
colonyId,
date: new Date('2026-09-01'),
userId: 'user-1',
}) as unknown,
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Feeding',
entityId: `feeding-of-${colonyId}`,
action: 'CREATE',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'FeedingRecorded',
aggregateType: 'Feeding',
aggregateId: `feeding-of-${colonyId}`,
payload: { colonyId, bulkId: result.bulkId },
actorUserId: 'user-1',
});
}
});
it('createBulk stores the same row as a single create', async () => {
await service.create({ ...shared, colonyId: 'colony-1' }, 'user-1');
await service.createBulk(
{ ...shared, colonyIds: ['colony-1'] },
'user-1',
);
const [[single], [bulk]] = tx.feeding.create.mock.calls as [CreateArg][];
expect(bulk).toEqual(single);
expect(bulk.data).not.toHaveProperty('colonyIds');
});
it('createBulk writes nothing when a colony has ended', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'MERGED', apiaryId: 'apiary-1' },
]);
await expect(
service.createBulk({ ...shared, colonyIds: ['colony-1'] }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.feeding.create).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
});
});
+105 -32
View File
@@ -1,10 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { Feeding } from '../../generated/prisma';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { Feeding, Prisma } from '../../generated/prisma';
import {
BulkCreateResult,
recordedForColony,
} from '../common/bulk/bulk-create';
import { runBulk } from '../common/bulk/run-bulk';
import { lockRowForUpdate } from '../prisma/row-lock';
import { CreateFeedingDto } from './dto/create-feeding.dto';
import { BulkCreateFeedingDto } from './dto/bulk-create-feeding.dto';
import { UpdateFeedingDto } from './dto/update-feeding.dto';
export interface FeedingSummaryEntry {
@@ -12,13 +18,21 @@ export interface FeedingSummaryEntry {
totalAmount: number;
}
// The row a new feeding is stored as - for create and createBulk alike, so a
// bulk entry stores exactly what single entries would.
function feedingRow(
dto: CreateFeedingDto,
actingUserId: string,
): Prisma.FeedingUncheckedCreateInput {
return { ...dto, date: new Date(dto.date), userId: actingUserId };
}
@Injectable()
export class FeedingsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly calendarSync: CalendarSyncService,
private readonly automation: AutomationService,
private readonly events: DomainEventsService,
) {}
async findAll(): Promise<Feeding[]> {
@@ -53,18 +67,60 @@ export class FeedingsService {
}
async create(dto: CreateFeedingDto, actingUserId: string): Promise<Feeding> {
const feeding = await this.prisma.feeding.create({
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
return this.prisma.$transaction((tx) =>
this.recordIn(tx, feedingRow(dto, actingUserId), actingUserId),
);
}
/**
* One feeding per colony, all or none (see runBulk). Every record gets its
* own audit entry and FeedingRecorded, so calendar entry, follow-up check
* and webhook follow per colony exactly as for a single feeding.
*/
async createBulk(
dto: BulkCreateFeedingDto,
actingUserId: string,
): Promise<BulkCreateResult<Feeding>> {
const { colonyIds, ...shared } = dto;
return runBulk(
this.prisma,
colonyIds.map((colonyId) => ({ colonyId })),
(tx, { colonyId }, _colony, bulkId) =>
this.recordIn(
tx,
feedingRow({ ...shared, colonyId }, actingUserId),
actingUserId,
bulkId,
),
);
}
// Feeding, audit entry and FeedingRecorded in the caller's transaction;
// calendar entry and follow-up check follow from the event.
private async recordIn(
tx: Prisma.TransactionClient,
data: Prisma.FeedingUncheckedCreateInput,
actingUserId: string,
bulkId?: string,
): Promise<Feeding> {
const feeding = await tx.feeding.create({ data });
await this.auditLog.record(
{
entityType: 'Feeding',
entityId: feeding.id,
action: 'CREATE',
userId: actingUserId,
afterData: feeding,
},
tx,
);
await this.events.record(tx, {
type: 'FeedingRecorded',
aggregateType: 'Feeding',
aggregateId: feeding.id,
payload: recordedForColony(feeding.colonyId, bulkId),
actorUserId: actingUserId,
});
await this.auditLog.record({
entityType: 'Feeding',
entityId: feeding.id,
action: 'CREATE',
userId: actingUserId,
afterData: feeding,
});
await this.calendarSync.enqueue('FEEDING', feeding.id);
await this.automation.evaluateFeeding(feeding);
return feeding;
}
@@ -73,22 +129,39 @@ export class FeedingsService {
dto: UpdateFeedingDto,
actingUserId: string,
): Promise<Feeding> {
const before = await this.findById(id);
const feeding = await this.prisma.feeding.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
// FeedingCorrected moves the calendar event (changed date or colony) but
// starts no follow-up check.
return this.prisma.$transaction(async (tx) => {
// The audit "before" is read under the row lock, not before the
// transaction: a concurrent edit cannot slip in between.
await lockRowForUpdate(tx, 'Feeding', id);
const before = await tx.feeding.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('Feeding not found');
}
const feeding = await tx.feeding.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
await this.auditLog.record(
{
entityType: 'Feeding',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: feeding,
},
tx,
);
await this.events.record(tx, {
type: 'FeedingCorrected',
aggregateType: 'Feeding',
aggregateId: id,
payload: { colonyId: feeding.colonyId },
actorUserId: actingUserId,
});
return feeding;
});
await this.auditLog.record({
entityType: 'Feeding',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: feeding,
});
// A changed date or colony must move the calendar event, not leave the
// old one behind.
await this.calendarSync.enqueue('FEEDING', feeding.id);
return feeding;
}
}
@@ -2,8 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { HarvestsService } from './harvests.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
describe('HarvestsService', () => {
let service: HarvestsService;
@@ -13,21 +12,35 @@ describe('HarvestsService', () => {
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
let events: { record: jest.Mock };
let tx: {
harvest: { create: jest.Mock; update: jest.Mock };
harvest: {
create: jest.Mock;
update: jest.Mock;
findUnique: jest.Mock;
findUniqueOrThrow: jest.Mock;
};
harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock };
$queryRaw: jest.Mock;
};
const harvest = { id: 'harvest-1', apiaryId: 'apiary-1', date: new Date() };
const harvest = {
id: 'harvest-1',
apiaryId: 'apiary-1',
date: new Date(),
colonies: [{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }],
};
beforeEach(() => {
tx = {
harvest: {
create: jest.fn().mockResolvedValue(harvest),
update: jest.fn(),
findUnique: jest.fn().mockResolvedValue(harvest),
findUniqueOrThrow: jest.fn().mockResolvedValue(harvest),
},
harvestColony: { createMany: jest.fn(), deleteMany: jest.fn() },
$queryRaw: jest.fn().mockResolvedValue([]),
};
prisma = {
harvest: { findUnique: jest.fn(), update: jest.fn() },
@@ -41,13 +54,11 @@ describe('HarvestsService', () => {
),
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
events = { record: jest.fn() };
service = new HarvestsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
calendarSync as unknown as CalendarSyncService,
events as unknown as DomainEventsService,
);
});
@@ -97,9 +108,8 @@ describe('HarvestsService', () => {
it('update writes an audit log entry with before and after data', async () => {
const updated = { ...harvest, notes: 'geändert' };
prisma.harvest.findUnique
.mockResolvedValueOnce(harvest)
.mockResolvedValueOnce(updated);
tx.harvest.findUnique.mockResolvedValueOnce(harvest);
tx.harvest.findUniqueOrThrow.mockResolvedValueOnce(updated);
const result = await service.update(
'harvest-1',
@@ -117,19 +127,83 @@ describe('HarvestsService', () => {
beforeData: harvest,
afterData: updated,
}),
tx,
);
});
it('update re-enqueues the calendar sync so a changed date moves the event', async () => {
prisma.harvest.findUnique.mockResolvedValue(harvest);
it('update takes "before" from the locked row inside the transaction, not from a pre-read', async () => {
const stale = { ...harvest, notes: 'veraltet' };
const locked = { ...harvest, notes: 'gesperrt gelesen' };
prisma.harvest.findUnique.mockResolvedValue(stale);
tx.harvest.findUnique.mockResolvedValue(locked);
await service.update('harvest-1', { notes: 'neu' }, 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ beforeData: locked }),
tx,
);
});
it('update checks the weights against the locked row', async () => {
tx.harvest.findUnique.mockResolvedValue({ ...harvest, grossWeight: 10 });
await expect(
service.update('harvest-1', { netWeight: 12 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.harvest.update).not.toHaveBeenCalled();
});
it('create records HarvestRecorded with the colony ids, audit entry in the same transaction', async () => {
await service.create(
{
apiaryId: 'apiary-1',
date: '2026-07-01',
colonyIds: ['colony-1', 'colony-2'],
},
'user-1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Harvest',
entityId: 'harvest-1',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'HarvestRecorded',
aggregateType: 'Harvest',
aggregateId: 'harvest-1',
payload: { apiaryId: 'apiary-1', colonyIds: ['colony-1', 'colony-2'] },
actorUserId: 'user-1',
});
});
it('update records HarvestCorrected with the resulting colony ids', async () => {
tx.harvest.findUnique.mockResolvedValue(harvest);
await service.update('harvest-1', { date: '2026-07-02' }, 'user-1');
expect(calendarSync.enqueue).toHaveBeenCalledWith('HARVEST', 'harvest-1');
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'HarvestCorrected',
aggregateType: 'Harvest',
aggregateId: 'harvest-1',
payload: { apiaryId: 'apiary-1', colonyIds: ['colony-1', 'colony-2'] },
}),
);
});
it('update replaces the whole colony set when colonyIds is given', async () => {
prisma.harvest.findUnique.mockResolvedValue(harvest);
tx.harvest.findUnique.mockResolvedValue(harvest);
await service.update(
'harvest-1',
@@ -150,7 +224,7 @@ describe('HarvestsService', () => {
});
it('update rejects an unknown colony id before touching the database', async () => {
prisma.harvest.findUnique.mockResolvedValue(harvest);
tx.harvest.findUnique.mockResolvedValue(harvest);
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
await expect(
@@ -5,8 +5,8 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { CreateHarvestDto } from './dto/create-harvest.dto';
import { UpdateHarvestDto } from './dto/update-harvest.dto';
@@ -19,8 +19,7 @@ export class HarvestsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly calendarSync: CalendarSyncService,
private readonly events: DomainEventsService,
) {}
async findAll() {
@@ -70,8 +69,10 @@ export class HarvestsService {
async create(dto: CreateHarvestDto, actingUserId: string) {
this.assertWeightsPlausible(dto.grossWeight, dto.netWeight);
await this.assertColoniesExist(dto.colonyIds);
const harvest = await this.prisma.$transaction(async (tx) => {
const created = await tx.harvest.create({
// Harvest, audit entry and HarvestRecorded commit together; webhook and
// calendar entry follow from the event.
return this.prisma.$transaction(async (tx) => {
const harvest = await tx.harvest.create({
data: {
apiaryId: dto.apiaryId,
date: new Date(dto.date),
@@ -85,37 +86,57 @@ export class HarvestsService {
});
await tx.harvestColony.createMany({
data: dto.colonyIds.map((colonyId) => ({
harvestId: created.id,
harvestId: harvest.id,
colonyId,
})),
});
return created;
await this.auditLog.record(
{
entityType: 'Harvest',
entityId: harvest.id,
action: 'CREATE',
userId: actingUserId,
afterData: harvest,
},
tx,
);
await this.events.record(tx, {
type: 'HarvestRecorded',
aggregateType: 'Harvest',
aggregateId: harvest.id,
payload: { apiaryId: harvest.apiaryId, colonyIds: dto.colonyIds },
actorUserId: actingUserId,
});
return tx.harvest.findUniqueOrThrow({
where: { id: harvest.id },
include: harvestInclude,
});
});
await this.auditLog.record({
entityType: 'Harvest',
entityId: harvest.id,
action: 'CREATE',
userId: actingUserId,
afterData: harvest,
});
const created = await this.findById(harvest.id);
await this.webhooks.dispatch('harvest.created', created);
await this.calendarSync.enqueue('HARVEST', harvest.id);
return created;
}
async update(id: string, dto: UpdateHarvestDto, actingUserId: string) {
const before = await this.findById(id);
this.assertWeightsPlausible(
dto.grossWeight !== undefined ? dto.grossWeight : before.grossWeight,
dto.netWeight !== undefined ? dto.netWeight : before.netWeight,
);
const { colonyIds, ...scalarFields } = dto;
if (colonyIds) {
await this.assertColoniesExist(colonyIds);
}
await this.prisma.$transaction(async (tx) => {
// HarvestCorrected moves the calendar event (changed date) but sends no
// webhook.
return this.prisma.$transaction(async (tx) => {
// "Before" and the weight check read under the row lock, not before
// the transaction: a concurrent edit cannot slip in between.
await lockRowForUpdate(tx, 'Harvest', id);
const before = await tx.harvest.findUnique({
where: { id },
include: harvestInclude,
});
if (!before) {
throw new NotFoundException('Harvest not found');
}
this.assertWeightsPlausible(
dto.grossWeight !== undefined ? dto.grossWeight : before.grossWeight,
dto.netWeight !== undefined ? dto.netWeight : before.netWeight,
);
await tx.harvest.update({
where: { id },
data: {
@@ -131,20 +152,32 @@ export class HarvestsService {
data: colonyIds.map((colonyId) => ({ harvestId: id, colonyId })),
});
}
const harvest = await tx.harvest.findUniqueOrThrow({
where: { id },
include: harvestInclude,
});
await this.auditLog.record(
{
entityType: 'Harvest',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: harvest,
},
tx,
);
await this.events.record(tx, {
type: 'HarvestCorrected',
aggregateType: 'Harvest',
aggregateId: id,
payload: {
apiaryId: harvest.apiaryId,
colonyIds: harvest.colonies.map((entry) => entry.colonyId),
},
actorUserId: actingUserId,
});
return harvest;
});
const harvest = await this.findById(id);
await this.auditLog.record({
entityType: 'Harvest',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: harvest,
});
// A changed date must move the calendar event, not leave the old one
// behind.
await this.calendarSync.enqueue('HARVEST', harvest.id);
return harvest;
}
}
@@ -1,11 +1,15 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { TasksModule } from '../tasks/tasks.module';
import { InspectionsController } from './inspections.controller';
import { ColonyInspectionsController } from './colony-inspections.controller';
import { InspectionsService } from './inspections.service';
/**
* Rules, calendar and webhooks follow from the inspection's domain events
* (DomainEventsModule is global), so no AutomationModule here.
*/
@Module({
imports: [AutomationModule],
imports: [TasksModule],
controllers: [InspectionsController, ColonyInspectionsController],
providers: [InspectionsService],
exports: [InspectionsService],
@@ -1,24 +1,30 @@
import { NotFoundException } from '@nestjs/common';
import { InspectionsService } from './inspections.service';
import { PrismaService } from '../prisma/prisma.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { AuditLogService } from '../audit/audit-log.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { TasksService } from '../tasks/tasks.service';
import { Prisma } from '../../generated/prisma';
describe('InspectionsService', () => {
let service: InspectionsService;
let tx: {
$queryRaw: jest.Mock;
inspection: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
};
let prisma: {
$connect: jest.Mock;
$transaction: jest.Mock;
inspection: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
task: { create: jest.Mock };
};
let auditLog: { record: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
let events: { record: jest.Mock };
let tasks: { createInTx: jest.Mock };
const inspection = {
id: 'inspection-1',
@@ -27,30 +33,48 @@ describe('InspectionsService', () => {
};
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
inspection: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
prisma = {
$connect: jest.fn(),
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
inspection: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
task: { create: jest.fn() },
};
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = {
evaluateInspection: jest.fn().mockResolvedValue(undefined),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
events = { record: jest.fn().mockResolvedValue(undefined) };
tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) };
service = new InspectionsService(
prisma as unknown as PrismaService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
auditLog as unknown as AuditLogService,
calendarSync as unknown as CalendarSyncService,
events as unknown as DomainEventsService,
tasks as unknown as TasksService,
);
});
it('has no side-effect dependency: rules, calendar and webhooks follow from its events', () => {
expect(
Reflect.getMetadata('design:paramtypes', InspectionsService),
).toEqual([
PrismaService,
AuditLogService,
DomainEventsService,
TasksService,
]);
});
it('findById throws NotFoundException when missing', async () => {
prisma.inspection.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -58,96 +82,180 @@ describe('InspectionsService', () => {
);
});
it('create stores the acting user as the inspector', async () => {
prisma.inspection.create.mockResolvedValue(inspection);
it('create writes inspection, audit entry and InspectionRecorded in one transaction and calls no side effect directly', async () => {
tx.inspection.create.mockResolvedValue(inspection);
await service.create(
const result = await service.create(
{ colonyId: 'colony-1', date: '2026-01-01' },
'user-1',
);
const [[createArg]] = prisma.inspection.create.mock.calls as [
[{ data: { colonyId: string; userId: string } }],
];
expect(createArg.data.colonyId).toBe('colony-1');
expect(createArg.data.userId).toBe('user-1');
});
it('create writes an audit log entry with the created inspection', async () => {
prisma.inspection.create.mockResolvedValue(inspection);
await service.create(
{ colonyId: 'colony-1', date: '2026-01-01' },
'user-1',
);
expect(auditLog.record).toHaveBeenCalledWith({
entityType: 'Inspection',
entityId: 'inspection-1',
action: 'CREATE',
userId: 'user-1',
afterData: inspection,
expect(result).toBe(inspection);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tx.inspection.create).toHaveBeenCalledWith({
data: {
colonyId: 'colony-1',
date: new Date('2026-01-01'),
userId: 'user-1',
},
});
expect(auditLog.record).toHaveBeenCalledWith(
{
entityType: 'Inspection',
entityId: 'inspection-1',
action: 'CREATE',
userId: 'user-1',
afterData: inspection,
},
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'inspection-1',
payload: { colonyId: 'colony-1' },
actorUserId: 'user-1',
});
// Nothing is written outside the transaction - no rule evaluation,
// calendar entry or webhook runs in the request.
expect(prisma.inspection.create).not.toHaveBeenCalled();
expect(tasks.createInTx).not.toHaveBeenCalled();
});
it('update writes an audit log entry with before and after data', async () => {
prisma.inspection.findUnique.mockResolvedValue(inspection);
const updated = { ...inspection, notes: 'geändert' };
prisma.inspection.update.mockResolvedValue(updated);
it('create fails as a whole when its event cannot be written', async () => {
tx.inspection.create.mockResolvedValue(inspection);
events.record.mockRejectedValue(new Error('outbox down'));
await expect(
service.create({ colonyId: 'colony-1', date: '2026-01-01' }, 'user-1'),
).rejects.toThrow('outbox down');
});
it('update writes the correction, audit entry and InspectionCorrected in one transaction', async () => {
tx.inspection.findUnique.mockResolvedValue(inspection);
// Re-filed against another colony: the event names the colony it is
// filed against now.
const updated = { ...inspection, colonyId: 'colony-2', notes: 'geändert' };
tx.inspection.update.mockResolvedValue(updated);
const result = await service.update(
'inspection-1',
{ notes: 'geändert' },
{ colonyId: 'colony-2', notes: 'geändert', date: '2026-05-02' },
'user-1',
);
expect(result).toBe(updated);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tx.inspection.findUnique).toHaveBeenCalledWith({
where: { id: 'inspection-1' },
});
expect(tx.inspection.update).toHaveBeenCalledWith({
where: { id: 'inspection-1' },
data: {
colonyId: 'colony-2',
notes: 'geändert',
date: new Date('2026-05-02'),
},
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
{
entityType: 'Inspection',
entityId: 'inspection-1',
action: 'UPDATE',
userId: 'user-1',
beforeData: inspection,
afterData: updated,
}),
},
tx,
);
// The calendar entry follows from the event (calendar-sync.inspection);
// a correction evaluates no automation rule.
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'InspectionCorrected',
aggregateType: 'Inspection',
aggregateId: 'inspection-1',
payload: { colonyId: 'colony-2' },
actorUserId: 'user-1',
});
expect(prisma.inspection.update).not.toHaveBeenCalled();
});
it('update locks the inspection row before re-reading it as "before"', async () => {
tx.inspection.findUnique.mockResolvedValue(inspection);
tx.inspection.update.mockResolvedValue(inspection);
await service.update('inspection-1', { notes: 'x' }, 'user-1');
const [[lock]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(lock.sql).toBe(
'SELECT id FROM "Inspection" WHERE id = ? FOR UPDATE',
);
expect(lock.values).toEqual(['inspection-1']);
const [lockedAt] = tx.$queryRaw.mock.invocationCallOrder;
const [readAt] = tx.inspection.findUnique.mock.invocationCallOrder;
expect(lockedAt).toBeLessThan(readAt);
});
it('update uses the locked row, not a read outside the transaction, as "before"', async () => {
// Another request re-filed the inspection while this one waited.
prisma.inspection.findUnique.mockResolvedValue(inspection);
const locked = { ...inspection, colonyId: 'colony-3', notes: 'parallel' };
tx.inspection.findUnique.mockResolvedValue(locked);
const updated = { ...locked, notes: 'geändert' };
tx.inspection.update.mockResolvedValue(updated);
await service.update('inspection-1', { notes: 'geändert' }, 'user-1');
expect(prisma.inspection.findUnique).not.toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ beforeData: locked, afterData: updated }),
tx,
);
});
it('update re-enqueues the calendar sync so a changed date moves the event', async () => {
prisma.inspection.findUnique.mockResolvedValue(inspection);
prisma.inspection.update.mockResolvedValue(inspection);
it('update throws NotFoundException for a missing inspection and writes nothing', async () => {
tx.inspection.findUnique.mockResolvedValue(null);
await service.update('inspection-1', { date: '2026-05-02' }, 'user-1');
expect(calendarSync.enqueue).toHaveBeenCalledWith(
'INSPECTION',
'inspection-1',
);
await expect(
service.update('missing', { notes: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.inspection.update).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('createTaskFromInspection links the task to the inspection and its colony', async () => {
it('createTaskFromInspection creates the task through TasksService.createInTx in one transaction', async () => {
prisma.inspection.findUnique.mockResolvedValue(inspection);
prisma.task.create.mockResolvedValue({ id: 'task-1' });
await service.createTaskFromInspection(
const result = await service.createTaskFromInspection(
'inspection-1',
{ title: 'Nachschauen' },
{
title: 'Nachschauen',
priority: 'HIGH',
dueDate: '2026-06-01T00:00:00.000Z',
},
'user-1',
);
const [[taskArg]] = prisma.task.create.mock.calls as [
[
{
data: {
sourceInspectionId: string;
relatedType: string;
relatedId: string;
};
},
],
];
expect(taskArg.data.sourceInspectionId).toBe('inspection-1');
expect(taskArg.data.relatedType).toBe('COLONY');
expect(taskArg.data.relatedId).toBe('colony-1');
expect(result).toEqual({ id: 'task-1' });
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tasks.createInTx).toHaveBeenCalledWith(
tx,
{
title: 'Nachschauen',
description: undefined,
priority: 'HIGH',
dueDate: new Date('2026-06-01T00:00:00.000Z'),
assignedUserId: undefined,
relatedType: 'COLONY',
relatedId: 'colony-1',
sourceInspectionId: 'inspection-1',
},
'user-1',
);
// Audit entry and TaskCreated come from createInTx - calendar entry and
// webhook follow from that event, not from here.
expect(auditLog.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
});
@@ -1,23 +1,31 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { TasksService } from '../tasks/tasks.service';
import { Inspection, Task } from '../../generated/prisma';
import { CreateInspectionDto } from './dto/create-inspection.dto';
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
import { UpdateInspectionDto } from './dto/update-inspection.dto';
import { optionalDate } from '../common/patch-values';
/**
* Inspections are written together with their audit entry and domain event
* in one transaction: InspectionRecorded, InspectionCorrected. Automation
* rules (automation.inspection-rules, on InspectionRecorded only), calendar
* entry (calendar-sync.inspection) and the inspection.created webhook
* (webhooks.forward) follow from the event, never from a direct call - so
* none of them can fail the request or run twice on a retried one.
*/
@Injectable()
export class InspectionsService {
constructor(
private readonly prisma: PrismaService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
private readonly auditLog: AuditLogService,
private readonly calendarSync: CalendarSyncService,
private readonly events: DomainEventsService,
private readonly tasks: TasksService,
) {}
async findAll(): Promise<Inspection[]> {
@@ -25,13 +33,7 @@ export class InspectionsService {
}
async findById(id: string): Promise<Inspection> {
const inspection = await this.prisma.inspection.findUnique({
where: { id },
});
if (!inspection) {
throw new NotFoundException('Inspection not found');
}
return inspection;
return this.findIn(this.prisma, id);
}
async findByColony(colonyId: string): Promise<Inspection[]> {
@@ -45,78 +47,104 @@ export class InspectionsService {
dto: CreateInspectionDto,
actingUserId: string,
): Promise<Inspection> {
const inspection = await this.prisma.inspection.create({
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
return this.prisma.$transaction(async (tx) => {
const inspection = await tx.inspection.create({
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
});
await this.auditLog.record(
{
entityType: 'Inspection',
entityId: inspection.id,
action: 'CREATE',
userId: actingUserId,
afterData: inspection,
},
tx,
);
await this.events.record(tx, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: inspection.id,
payload: { colonyId: inspection.colonyId },
actorUserId: actingUserId,
});
return inspection;
});
await this.auditLog.record({
entityType: 'Inspection',
entityId: inspection.id,
action: 'CREATE',
userId: actingUserId,
afterData: inspection,
});
await this.webhooks.dispatch('inspection.created', inspection);
await this.automation.evaluateInspection(inspection);
await this.calendarSync.enqueue('INSPECTION', inspection.id);
return inspection;
}
/**
* A correction evaluates no automation rule, as before the event
* backbone. InspectionCorrected moves the calendar entry when the date or
* colony changed instead of leaving the old one behind.
*/
async update(
id: string,
dto: UpdateInspectionDto,
actingUserId: string,
): Promise<Inspection> {
const before = await this.findById(id);
const inspection = await this.prisma.inspection.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
return this.prisma.$transaction(async (tx) => {
// "before" is read under the row lock: a concurrent correction can
// neither slip in between nor be mislabelled in the audit entry.
await lockRowForUpdate(tx, 'Inspection', id);
const before = await this.findIn(tx, id);
const inspection = await tx.inspection.update({
where: { id },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
await this.auditLog.record(
{
entityType: 'Inspection',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: inspection,
},
tx,
);
await this.events.record(tx, {
type: 'InspectionCorrected',
aggregateType: 'Inspection',
aggregateId: id,
payload: { colonyId: inspection.colonyId },
actorUserId: actingUserId,
});
return inspection;
});
await this.auditLog.record({
entityType: 'Inspection',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: inspection,
});
// A changed date or colony must move the calendar event, not leave the
// old one behind.
await this.calendarSync.enqueue('INSPECTION', inspection.id);
return inspection;
}
// Through TasksService like a task from the task page: audit entry,
// TaskCreated and with it calendar entry and webhook do not depend on
// where the task was started.
async createTaskFromInspection(
id: string,
dto: CreateTaskFromInspectionDto,
actingUserId: string,
): Promise<Task> {
const inspection = await this.findById(id);
const task = await this.prisma.task.create({
data: {
title: dto.title,
description: dto.description,
priority: dto.priority,
dueDate: optionalDate(dto.dueDate),
assignedUserId: dto.assignedUserId,
createdByUserId: actingUserId,
relatedType: 'COLONY',
relatedId: inspection.colonyId,
sourceInspectionId: inspection.id,
},
});
// Same trail as a task created on the task page: audit entry, webhook
// and calendar entry must not depend on where the task was started.
await this.auditLog.record({
entityType: 'Task',
entityId: task.id,
action: 'CREATE',
userId: actingUserId,
afterData: task,
});
await this.webhooks.dispatch('task.created', task);
if (task.dueDate) {
await this.calendarSync.enqueue('TASK', task.id);
return this.prisma.$transaction((tx) =>
this.tasks.createInTx(
tx,
{
title: dto.title,
description: dto.description,
priority: dto.priority,
dueDate: optionalDate(dto.dueDate),
assignedUserId: dto.assignedUserId,
relatedType: 'COLONY',
relatedId: inspection.colonyId,
sourceInspectionId: inspection.id,
},
actingUserId,
),
);
}
private async findIn(db: DbClient, id: string): Promise<Inspection> {
const inspection = await db.inspection.findUnique({ where: { id } });
if (!inspection) {
throw new NotFoundException('Inspection not found');
}
return task;
return inspection;
}
}
@@ -0,0 +1,48 @@
import { BackupSchedulerService } from '../backup/backup-scheduler.service';
import { AppBackupJob } from './app-backup.job';
describe('AppBackupJob', () => {
let scheduler: { runIfDue: jest.Mock };
let job: AppBackupJob;
beforeEach(() => {
scheduler = { runIfDue: jest.fn() };
job = new AppBackupJob(scheduler as unknown as BackupSchedulerService);
});
it('is the app-backup job', () => {
expect(job.name).toBe('app-backup');
});
it('writes a backup when one is due and reports it', async () => {
scheduler.runIfDue.mockResolvedValue({
written: true,
fileName: 'apicore-backup-2026-10-13T01-00-04-000Z.json',
sizeBytes: 2048,
pruned: 1,
});
await expect(job.run()).resolves.toEqual({
written: true,
fileName: 'apicore-backup-2026-10-13T01-00-04-000Z.json',
sizeBytes: 2048,
pruned: 1,
});
expect(scheduler.runIfDue).toHaveBeenCalledWith();
});
it('reports why nothing was written', async () => {
scheduler.runIfDue.mockResolvedValue({ written: false, reason: 'not-due' });
await expect(job.run()).resolves.toEqual({
written: false,
reason: 'not-due',
});
});
it('fails the run when the backup fails', async () => {
scheduler.runIfDue.mockRejectedValue(new Error('disk full'));
await expect(job.run()).rejects.toThrow('disk full');
});
});
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { BackupSchedulerService } from '../backup/backup-scheduler.service';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/**
* The hourly CronJob check of the automatic backup: writes a backup only if
* the schedule (env or GUI, Europe/Berlin) says one is due. Without a
* configured path it succeeds without writing; a failed backup fails the
* run, which notifies the holders of backup:configure.
*/
@Injectable()
export class AppBackupJob implements Job {
readonly name: JobName = 'app-backup';
constructor(private readonly scheduler: BackupSchedulerService) {}
run(): Promise<JobResult> {
return this.scheduler.runIfDue();
}
}
@@ -0,0 +1,140 @@
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import {
CleanupJob,
DOMAIN_EVENT_RETENTION_DAYS,
FAILED_RETENTION_DAYS,
JOB_RUN_RETENTION_DAYS,
JOB_RUN_STALE_HOURS,
WEBHOOK_DELIVERY_RETENTION_DAYS,
} from './cleanup.job';
type RawCall = [readonly string[], ...unknown[]];
describe('CleanupJob', () => {
let executeRaw: jest.Mock<Promise<number>, RawCall>;
let idempotencyCleanup: { removeExpired: jest.Mock };
let jobRuns: { abortStale: jest.Mock };
let job: CleanupJob;
const statements = (): { sql: string; values: unknown[] }[] =>
executeRaw.mock.calls.map(([strings, ...values]) => ({
sql: strings.join('?').replace(/\s+/g, ' '),
values,
}));
const statementOn = (table: string, status?: string) => {
const found = statements().filter(
({ sql }) =>
sql.includes(`DELETE FROM "${table}"`) &&
(status === undefined || sql.includes(`"status" = '${status}'`)),
);
expect(found).toHaveLength(1);
return found[0];
};
beforeEach(() => {
let count = 10;
executeRaw = jest.fn<Promise<number>, RawCall>(() =>
Promise.resolve(count++),
);
idempotencyCleanup = {
removeExpired: jest
.fn()
.mockResolvedValue({ idempotencyRecords: 2, consumedTokens: 3 }),
};
jobRuns = { abortStale: jest.fn().mockResolvedValue(1) };
job = new CleanupJob(
{ $executeRaw: executeRaw } as unknown as PrismaService,
idempotencyCleanup as unknown as IdempotencyCleanupService,
jobRuns as unknown as JobRunService,
);
});
it('keeps the retention periods of the backbone spec', () => {
expect(DOMAIN_EVENT_RETENTION_DAYS).toBe(30);
expect(WEBHOOK_DELIVERY_RETENTION_DAYS).toBe(30);
expect(FAILED_RETENTION_DAYS).toBe(90);
expect(JOB_RUN_RETENTION_DAYS).toBe(90);
expect(JOB_RUN_STALE_HOURS).toBe(6);
});
it('cleanup removes only expired rows and reports counts', async () => {
const result = await job.run();
expect(job.name).toBe('cleanup');
expect(idempotencyCleanup.removeExpired).toHaveBeenCalledTimes(1);
expect(jobRuns.abortStale).toHaveBeenCalledWith(JOB_RUN_STALE_HOURS);
// One count per statement, in the order the statements ran.
expect(result).toEqual({
idempotencyRecords: 2,
consumedTokens: 3,
throttlerHits: 10,
'domainEvents.done': 11,
'domainEvents.failed': 12,
'webhookDeliveries.delivered': 13,
'webhookDeliveries.failed': 14,
'jobRuns.aborted': 1,
'jobRuns.deleted': 15,
});
});
// An expired window can still carry an active block (blockDuration
// outlasts the window); deleting it would lift the block early.
it('deletes a throttler counter only when neither its window nor a block is active', async () => {
await job.run();
const { sql, values } = statementOn('ThrottlerHit');
expect(sql).toContain('"expiresAt" < now()');
expect(sql).toContain('("blockedUntil" IS NULL OR "blockedUntil" < now())');
expect(values).toEqual([]);
});
it('keeps finished outbox entries 30 days and failed ones 90 days, by status', async () => {
await job.run();
expect(statementOn('DomainEvent', 'DONE').values).toEqual([
DOMAIN_EVENT_RETENTION_DAYS,
]);
expect(statementOn('DomainEvent', 'FAILED').values).toEqual([
FAILED_RETENTION_DAYS,
]);
expect(statementOn('WebhookDelivery', 'DELIVERED').values).toEqual([
WEBHOOK_DELIVERY_RETENTION_DAYS,
]);
expect(statementOn('WebhookDelivery', 'FAILED').values).toEqual([
FAILED_RETENTION_DAYS,
]);
expect(statementOn('DomainEvent', 'DONE').sql).toContain(
'"occurredAt" < now() - (? * interval \'1 day\')',
);
expect(statementOn('WebhookDelivery', 'FAILED').sql).toContain(
'"createdAt" < now() - (? * interval \'1 day\')',
);
// PENDING and PROCESSING entries are never touched, however old.
for (const { sql } of statements()) {
expect(sql).not.toMatch(/PENDING|PROCESSING/);
}
});
it('deletes job runs after 90 days, only after stale runs were aborted', async () => {
await job.run();
const { sql, values } = statementOn('JobRun');
expect(sql).toContain('"startedAt" < now() - (? * interval \'1 day\')');
expect(values).toEqual([JOB_RUN_RETENTION_DAYS]);
const deleteOrder = executeRaw.mock.invocationCallOrder.at(-1) ?? 0;
expect(jobRuns.abortStale.mock.invocationCallOrder[0]).toBeLessThan(
deleteOrder,
);
});
it('compares only against the database clock', async () => {
await job.run();
for (const { sql, values } of statements()) {
expect(sql).toContain('now()');
expect(values.some((value) => value instanceof Date)).toBe(false);
}
});
});
+78
View File
@@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
// DONE events must outlive the catch-up window of the daily evaluation
// (7 days): a deleted event frees its dedupeKey, and an occurrence still in
// the window would be recorded a second time.
export const DOMAIN_EVENT_RETENTION_DAYS = 30;
export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
/** FAILED events and deliveries stay longer, for diagnosis and retry. */
export const FAILED_RETENTION_DAYS = 90;
export const JOB_RUN_RETENTION_DAYS = 90;
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
export const JOB_RUN_STALE_HOURS = 6;
/**
* Daily housekeeping (CronJob 04:00). Every age is compared against now(),
* the database clock. PENDING and PROCESSING entries are never removed,
* however old: they are work that has not been done yet.
*/
@Injectable()
export class CleanupJob implements Job {
readonly name: JobName = 'cleanup';
constructor(
private readonly prisma: PrismaService,
private readonly idempotencyCleanup: IdempotencyCleanupService,
private readonly jobRuns: JobRunService,
) {}
async run(): Promise<JobResult> {
const jobRunsAborted = await this.jobRuns.abortStale(JOB_RUN_STALE_HOURS);
const { idempotencyRecords, consumedTokens } =
await this.idempotencyCleanup.removeExpired();
// An expired window can still carry an active block (blockDuration
// outlasts the window, e.g. the failed-attempt lock of API keys);
// deleting that row would lift the block early.
const throttlerHits = await this.prisma.$executeRaw`
DELETE FROM "ThrottlerHit"
WHERE "expiresAt" < now()
AND ("blockedUntil" IS NULL OR "blockedUntil" < now())`;
// Deliveries of a deleted event go with it (ON DELETE CASCADE).
const domainEventsDone = await this.prisma.$executeRaw`
DELETE FROM "DomainEvent"
WHERE "status" = 'DONE'
AND "occurredAt" < now() - (${DOMAIN_EVENT_RETENTION_DAYS} * interval '1 day')`;
const domainEventsFailed = await this.prisma.$executeRaw`
DELETE FROM "DomainEvent"
WHERE "status" = 'FAILED'
AND "occurredAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
const webhookDeliveriesDelivered = await this.prisma.$executeRaw`
DELETE FROM "WebhookDelivery"
WHERE "status" = 'DELIVERED'
AND "createdAt" < now() - (${WEBHOOK_DELIVERY_RETENTION_DAYS} * interval '1 day')`;
const webhookDeliveriesFailed = await this.prisma.$executeRaw`
DELETE FROM "WebhookDelivery"
WHERE "status" = 'FAILED'
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
// After abortStale: no run this old can still be RUNNING.
const jobRunsDeleted = await this.prisma.$executeRaw`
DELETE FROM "JobRun"
WHERE "startedAt" < now() - (${JOB_RUN_RETENTION_DAYS} * interval '1 day')`;
return {
idempotencyRecords,
consumedTokens,
throttlerHits,
'domainEvents.done': domainEventsDone,
'domainEvents.failed': domainEventsFailed,
'webhookDeliveries.delivered': webhookDeliveriesDelivered,
'webhookDeliveries.failed': webhookDeliveriesFailed,
'jobRuns.aborted': jobRunsAborted,
'jobRuns.deleted': jobRunsDeleted,
};
}
}
@@ -0,0 +1,146 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
type RawCall = [readonly string[], ...unknown[]];
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('DailyEvaluationJob', () => {
const tx = { tx: true } as unknown as Prisma.TransactionClient;
/** The database clock: a day the JS clock of the test never shows. */
const dbNow = new Date('2026-06-10T02:30:00.000Z');
let queryRaw: jest.Mock<Promise<unknown[]>, RawCall>;
let taskFindMany: jest.Mock;
let transaction: jest.Mock;
let recordAll: jest.Mock<
Promise<number>,
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
>;
let job: DailyEvaluationJob;
beforeEach(() => {
queryRaw = jest.fn<Promise<unknown[]>, RawCall>(([sql]) =>
Promise.resolve(
sql.includes('now()')
? [{ now: dbNow }]
: [
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-25'),
endDate: null,
withdrawalPeriodDays: 14,
},
],
),
);
taskFindMany = jest
.fn()
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
transaction = jest.fn(
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
);
// Like createMany(skipDuplicates) on the unique dedupeKey.
const stored = new Set<string>();
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
fresh.forEach((event) => stored.add(event.dedupeKey!));
return Promise.resolve(fresh.length);
});
const prisma = {
$queryRaw: queryRaw,
$transaction: transaction,
task: { findMany: taskFindMany },
queen: { findMany: jest.fn().mockResolvedValue([]) },
colony: { findMany: jest.fn().mockResolvedValue([]) },
varroaMeasurement: { groupBy: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
} as unknown as PrismaService;
job = new DailyEvaluationJob(prisma, {
recordAll,
} as unknown as DomainEventsService);
});
afterEach(() => jest.restoreAllMocks());
it('is the daily-evaluation job', () => {
expect(job.name).toBe('daily-evaluation');
});
it('evaluates the day of the database clock and reports new events per type', async () => {
const result = await job.run();
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
// window of 2026-06-10 - not in the one of the test's own clock.
expect(result).toEqual({
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()');
});
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
await job.run();
// Types without an event open no transaction.
expect(transaction).toHaveBeenCalledTimes(2);
expect(recordAll.mock.calls).toEqual([
[
tx,
[
expect.objectContaining({
type: 'WithdrawalEnded',
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
}),
],
],
[
tx,
[
expect.objectContaining({
type: 'TaskDue',
dedupeKey: 'TaskDue:task-1:2026-06-09',
}),
],
],
]);
});
it('a second run on the same day records nothing new', async () => {
await job.run();
expect(await job.run()).toEqual({
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
it('a failing rule does not stop the others, but fails the run', async () => {
const logged = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
taskFindMany.mockRejectedValue(new Error('connection reset'));
await expect(job.evaluate(dbNow)).rejects.toThrow(
'Daily evaluation failed for: TaskDue',
);
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'WithdrawalEnded' }),
]);
expect(logged).toHaveBeenCalledWith(
'Rule "TaskDue" failed: connection reset',
expect.any(String),
);
});
});
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import {
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyRule,
} from './daily-evaluation.rules';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/**
* Records the time-based domain events (CronJob 04:30): one rule per type,
* see DAILY_EVALUATION_RULES. An occurrence is recorded on every run within
* CATCH_UP_DAYS after its day under the same daily dedupe key, so a missed
* run is caught up and a repeated one adds nothing. The result counts the
* events that were new, per type.
*/
@Injectable()
export class DailyEvaluationJob implements Job {
readonly name: JobName = 'daily-evaluation';
private readonly logger = new Logger(DailyEvaluationJob.name);
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
) {}
/** Evaluates the current day of the database clock. */
async run(): Promise<JobResult> {
const [{ now }] = await this.prisma.$queryRaw<
{ now: Date }[]
>`SELECT now() AS "now"`;
return this.evaluate(now);
}
/**
* Runs every rule for `today`. A failing rule is logged and does not stop
* the others - their events are committed - but fails the run, so it
* shows on the System page; the next run catches up.
*/
async evaluate(today: Date): Promise<JobResult> {
const counts: [string, number][] = [];
const failed: string[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
try {
counts.push([rule.type, await this.evaluateRule(rule, today)]);
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
failed.push(rule.type);
}
}
if (failed.length > 0) {
throw new Error(`Daily evaluation failed for: ${failed.join(', ')}`);
}
return Object.fromEntries(counts);
}
/** One transaction per type; returns how many events were new. */
private async evaluateRule(rule: DailyRule, today: Date): Promise<number> {
const events = catchUpEvents(
await rule.occurrences(this.prisma, today),
today,
);
if (events.length === 0) {
return 0;
}
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
}
}
@@ -0,0 +1,447 @@
import { PrismaService } from '../prisma/prisma.service';
import {
ACTIVE_SEASON_MONTHS,
CATCH_UP_DAYS,
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyEvent,
inCatchUpWindow,
INSPECTION_INTERVAL_DAYS,
latestCycleOccurrence,
QUEEN_AGED_MIN_YEARS,
VARROA_MEASUREMENT_INTERVAL_DAYS,
withdrawalEndDay,
} from './daily-evaluation.rules';
const DAY_MS = 24 * 60 * 60 * 1000;
/** Midnight UTC of a 'YYYY-MM-DD' day. */
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
/** Every day from `from` to `to`, both included. */
function daysBetween(from: string, to: string): string[] {
const days: string[] = [];
for (let t = d(from).getTime(); t <= d(to).getTime(); t += DAY_MS) {
days.push(new Date(t).toISOString().slice(0, 10));
}
return days;
}
interface FakeRows {
treatments?: {
id: string;
colonyId: string;
date: Date;
endDate: Date | null;
withdrawalPeriodDays: number | null;
}[];
tasks?: { id: string; dueDate: Date | null }[];
queens?: {
id: string;
hatchYear: number | null;
colony: { id: string } | null;
}[];
colonies?: { id: string; createdAt: Date }[];
/** The latest varroa measurement per colony. */
varroa?: { colonyId: string; date: Date }[];
/** The latest inspection per colony. */
inspections?: { colonyId: string; date: Date }[];
}
interface FakeDb {
db: PrismaService;
queryRaw: jest.Mock;
taskFindMany: jest.Mock;
queenFindMany: jest.Mock;
colonyFindMany: jest.Mock;
}
/**
* Returns the given rows whatever the query filters - the rules decide by
* day themselves, the database filters only narrow the candidates.
*/
function fakeDb(rows: FakeRows): FakeDb {
const latest = (records: { colonyId: string; date: Date }[] = []) =>
records.map(({ colonyId, date }) => ({ colonyId, _max: { date } }));
const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []);
const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []);
const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []);
const db = {
$queryRaw: queryRaw,
task: { findMany: taskFindMany },
queen: { findMany: queenFindMany },
colony: { findMany: colonyFindMany },
varroaMeasurement: {
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
},
inspection: {
groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)),
},
} as unknown as PrismaService;
return { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany };
}
/** One job run on `day`: every rule's events in the catch-up window. */
async function eventsOn(db: PrismaService, day: string): Promise<DailyEvent[]> {
const events: DailyEvent[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
events.push(...catchUpEvents(await rule.occurrences(db, at(day)), at(day)));
}
return events;
}
/**
* Runs the evaluation on each of `days` into a store keyed like the unique
* DomainEvent.dedupeKey: an event whose key exists is skipped, as
* recordAll's createMany(skipDuplicates) does.
*/
async function runDaily(
db: PrismaService,
days: readonly string[],
store = new Map<string, DailyEvent>(),
): Promise<Map<string, DailyEvent>> {
for (const day of days) {
for (const event of await eventsOn(db, day)) {
if (!store.has(event.dedupeKey)) {
store.set(event.dedupeKey, event);
}
}
}
return store;
}
describe('daily evaluation rules', () => {
it('keeps the values of the backbone plan', () => {
expect(CATCH_UP_DAYS).toBe(7);
expect(QUEEN_AGED_MIN_YEARS).toBe(2);
expect(VARROA_MEASUREMENT_INTERVAL_DAYS).toBe(28);
expect(INSPECTION_INTERVAL_DAYS).toBe(21);
expect(ACTIVE_SEASON_MONTHS).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
});
it('runs one rule per time-based event type', () => {
expect(DAILY_EVALUATION_RULES.map((rule) => rule.type)).toEqual([
'WithdrawalEnded',
'TaskDue',
'QueenAged',
'VarroaMeasurementDue',
'InspectionOverdue',
]);
});
describe('withdrawalEndDay', () => {
it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () =>
expect(
withdrawalEndDay({
date: d('2026-09-28'),
endDate: d('2026-10-01'),
withdrawalPeriodDays: 14,
}),
).toBe('2026-10-15'));
it('counts from the treatment day when the treatment has no end date', () =>
expect(
withdrawalEndDay({
date: d('2026-09-28'),
endDate: null,
withdrawalPeriodDays: 14,
}),
).toBe('2026-10-12'));
it('ends on the treatment day itself without a waiting time and ignores the time of day', () =>
expect(
withdrawalEndDay({
date: new Date('2026-09-28T22:15:00.000Z'),
endDate: null,
withdrawalPeriodDays: 0,
}),
).toBe('2026-09-28'));
it('has no end when the treatment has no withdrawal period', () =>
expect(
withdrawalEndDay({
date: d('2026-09-28'),
endDate: d('2026-10-01'),
withdrawalPeriodDays: null,
}),
).toBeNull());
});
describe('latestCycleOccurrence', () => {
it('is the latest base + k × interval up to today, with k ≥ 1', () => {
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-06-15'))).toBe(
'2026-06-12',
);
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-22'))).toBe(
'2026-05-22',
);
});
it('is null before the first full interval and for a base in the future', () => {
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-21')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-01')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-07-01'), 21, at('2026-05-01')),
).toBeNull();
});
it('counts whole UTC days, not hours', () =>
expect(
latestCycleOccurrence(
new Date('2026-05-01T20:00:00.000Z'),
21,
at('2026-05-22'),
),
).toBe('2026-05-22'));
it('refuses an interval that is not a positive whole number of days', () => {
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 0, at('2026-06-01')),
).toThrow(RangeError);
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 1.5, at('2026-06-01')),
).toThrow(RangeError);
});
});
describe('inCatchUpWindow', () => {
it('spans today and the 7 days before it', () => {
const today = at('2026-06-10');
expect(inCatchUpWindow('2026-06-03', today)).toBe(true);
expect(inCatchUpWindow('2026-06-07', today)).toBe(true);
expect(inCatchUpWindow('2026-06-10', today)).toBe(true);
expect(inCatchUpWindow('2026-06-02', today)).toBe(false);
expect(inCatchUpWindow('2026-06-11', today)).toBe(false);
});
it('works across a year boundary', () => {
expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true);
expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false);
});
});
it('builds each event from its rule: aggregate, payload and daily dedupe key', async () => {
const { db } = fakeDb({
treatments: [
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-15'),
endDate: d('2026-05-20'),
withdrawalPeriodDays: 5,
},
],
tasks: [{ id: 'task-1', dueDate: new Date('2026-05-27T09:00:00Z') }],
colonies: [
// Measured and inspected: the intervals count from the records.
{ id: 'colony-1', createdAt: d('2026-03-01') },
// Never measured or inspected: they count from its creation.
{ id: 'colony-2', createdAt: d('2026-04-12') },
],
varroa: [{ colonyId: 'colony-1', date: d('2026-05-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }],
});
expect(await eventsOn(db, '2026-05-29')).toEqual([
{
type: 'WithdrawalEnded',
aggregateType: 'Treatment',
aggregateId: 'treatment-1',
payload: { colonyId: 'colony-1', endedOn: '2026-05-25' },
dedupeKey: 'WithdrawalEnded:treatment-1:2026-05-25',
},
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 'task-1',
payload: { dueOn: '2026-05-27' },
dedupeKey: 'TaskDue:task-1:2026-05-27',
},
// colony-2: 2026-04-12 + 28 = 2026-05-10, outside the window.
{
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: 'colony-1',
payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' },
dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29',
},
// colony-1: 2026-05-20 + 21 = 2026-06-10, not yet due.
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: 'colony-2',
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
},
]);
});
it('catches up occurrences of the last 7 days exactly once', async () => {
// The job last ran on 2026-06-01 and is back on 2026-06-10 (CronJob
// down for a week). Then it runs daily again.
const { db } = fakeDb({
treatments: [
// Ends 2026-06-02: 8 days before the comeback, outside the window.
{
id: 'missed',
colonyId: 'colony-1',
date: d('2026-05-19'),
endDate: null,
withdrawalPeriodDays: 14,
},
// Ends 2026-06-03: 7 days before the comeback, caught up.
{
id: 'caught-up',
colonyId: 'colony-1',
date: d('2026-05-20'),
endDate: null,
withdrawalPeriodDays: 14,
},
],
tasks: [
{ id: 'due-while-down', dueDate: d('2026-06-06') },
{ id: 'due-later', dueDate: d('2026-06-14') },
],
});
const store = await runDaily(db, ['2026-06-01']);
expect([...store.keys()]).toEqual([]);
const comeback = await eventsOn(db, '2026-06-10');
expect(comeback.map((event) => event.dedupeKey)).toEqual([
'WithdrawalEnded:caught-up:2026-06-03',
'TaskDue:due-while-down:2026-06-06',
]);
// Every later run within the window repeats the same keys, so the
// unique dedupeKey keeps one event per occurrence.
await runDaily(db, daysBetween('2026-06-10', '2026-06-30'), store);
expect([...store.keys()].sort()).toEqual(
[
'TaskDue:due-later:2026-06-14',
'TaskDue:due-while-down:2026-06-06',
'WithdrawalEnded:caught-up:2026-06-03',
].sort(),
);
// Once the window has passed, the occurrence is no longer produced.
expect(await eventsOn(db, '2026-06-22')).toEqual([]);
});
it('emits no inspection or varroa event outside March–October', async () => {
// Never measured or inspected: due every 28 resp. 21 days from 2026-01-10.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }],
});
const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31'));
const dueDays = (type: string) =>
[...store.values()]
.filter((event) => event.type === type)
.map((event) => event.dedupeKey.split(':')[2]);
// Not 2026-02-07, 2026-11-14 or 2026-12-12.
expect(dueDays('VarroaMeasurementDue')).toEqual([
'2026-03-07',
'2026-04-04',
'2026-05-02',
'2026-05-30',
'2026-06-27',
'2026-07-25',
'2026-08-22',
'2026-09-19',
'2026-10-17',
]);
// Not 2026-01-31, 2026-02-21, 2026-11-21 or 2026-12-12.
expect(dueDays('InspectionOverdue')).toEqual([
'2026-03-14',
'2026-04-04',
'2026-04-25',
'2026-05-16',
'2026-06-06',
'2026-06-27',
'2026-07-18',
'2026-08-08',
'2026-08-29',
'2026-09-19',
'2026-10-10',
'2026-10-31',
]);
});
it('reports a queen once a year on January 1 from her second year after hatching', async () => {
const { db } = fakeDb({
queens: [
{ id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } },
{ id: 'queen-2027', hatchYear: 2027, colony: null },
{ id: 'queen-unknown', hatchYear: null, colony: null },
],
});
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20'));
expect([...store.values()]).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2026',
payload: { hatchYear: 2026, colonyId: 'colony-1' },
dedupeKey: 'QueenAged:queen-2026:2028-01-01',
},
expect.objectContaining({
dedupeKey: 'QueenAged:queen-2026:2029-01-01',
}) as DailyEvent,
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2027',
payload: { hatchYear: 2027, colonyId: null },
dedupeKey: 'QueenAged:queen-2027:2029-01-01',
},
]);
});
it('asks the database only for the candidates of the catch-up window', async () => {
const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } =
fakeDb({});
await eventsOn(db, '2026-06-10');
expect(taskFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
dueDate: { gte: d('2026-06-03'), lt: d('2026-06-11') },
},
}),
);
expect(colonyFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
}),
);
const [strings, ...values] = queryRaw.mock.calls[0] as [
readonly string[],
...unknown[],
];
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
expect(values).toEqual(['2026-06-03', '2026-06-10']);
// QueenAged only looks on January 1-8.
expect(queenFindMany).not.toHaveBeenCalled();
await eventsOn(db, '2027-01-08');
expect(queenFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
}),
);
});
});
@@ -0,0 +1,337 @@
import { TaskStatus, Treatment } from '../../generated/prisma';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import {
AnyNewDomainEvent,
dailyDedupeKey,
utcDay,
} from '../domain-events/domain-event-types';
import { PrismaService } from '../prisma/prisma.service';
/** How many days back a run still records an occurrence it missed. */
export const CATCH_UP_DAYS = 7;
/** A queen counts as aged from this many years after her hatch year. */
export const QUEEN_AGED_MIN_YEARS = 2;
export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28;
export const INSPECTION_INTERVAL_DAYS = 21;
/** March to October: varroa and inspection reminders only in these months. */
export const ACTIVE_SEASON_MONTHS: readonly number[] = [
3, 4, 5, 6, 7, 8, 9, 10,
];
const DAY_MS = 24 * 60 * 60 * 1000;
const OPEN_TASK_STATUSES: TaskStatus[] = ['OPEN', 'IN_PROGRESS'];
/** The event types the daily evaluation records. */
export type DailyEventType =
| 'WithdrawalEnded'
| 'TaskDue'
| 'QueenAged'
| 'VarroaMeasurementDue'
| 'InspectionOverdue';
/** An event as the job records it: always with its daily dedupe key. */
export type DailyEvent<T extends DailyEventType = DailyEventType> =
AnyNewDomainEvent<T> & { dedupeKey: string };
/** One occurrence of a rule: the event and the UTC day it happens on. */
export interface DailyOccurrence<T extends DailyEventType = DailyEventType> {
day: string;
event: AnyNewDomainEvent<T>;
}
/**
* One row of the daily evaluation. `occurrences` reads the candidates and
* names the day of each; the job keeps those in the catch-up window
* (catchUpEvents) and records them under their daily dedupe key. A rule
* may narrow its query to the window, but must not rely on that: the day
* it reports decides.
*/
export interface DailyRule<T extends DailyEventType = DailyEventType> {
readonly type: T;
occurrences(db: PrismaService, today: Date): Promise<DailyOccurrence<T>[]>;
}
/** Midnight UTC of the UTC day of `date`. */
function dayStart(date: Date): Date {
return new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
}
/** The UTC day `days` days after the UTC day of `date`. */
function dayAfter(date: Date, days: number): Date {
return new Date(dayStart(date).getTime() + days * DAY_MS);
}
/** The UTC day the withdrawal period ends on, null without one. */
export function withdrawalEndDay(
t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>,
): string | null {
if (t.withdrawalPeriodDays === null) {
return null;
}
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
}
/**
* The latest day base + k × intervalDays (k ≥ 1) that is not after today,
* in whole UTC days; null if not even the first interval has passed.
*/
export function latestCycleOccurrence(
base: Date,
intervalDays: number,
today: Date,
): string | null {
if (!Number.isInteger(intervalDays) || intervalDays < 1) {
throw new RangeError(
`Interval must be a positive whole number of days, got ${intervalDays}`,
);
}
const elapsedDays = Math.round(
(dayStart(today).getTime() - dayStart(base).getTime()) / DAY_MS,
);
const cycles = Math.floor(elapsedDays / intervalDays);
if (cycles < 1) {
return null;
}
return utcDay(dayAfter(base, cycles * intervalDays));
}
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
export function inCatchUpWindow(day: string, today: Date): boolean {
return day >= utcDay(dayAfter(today, -CATCH_UP_DAYS)) && day <= utcDay(today);
}
function inActiveSeason(day: string): boolean {
return ACTIVE_SEASON_MONTHS.includes(Number(day.slice(5, 7)));
}
/**
* The occurrences in the catch-up window, as events with their daily dedupe
* key: every run within the window repeats the same key, so the unique
* DomainEvent.dedupeKey keeps one event per occurrence.
*/
export function catchUpEvents(
occurrences: readonly DailyOccurrence[],
today: Date,
): DailyEvent[] {
return occurrences
.filter(({ day }) => inCatchUpWindow(day, today))
.map(({ day, event }) => ({
...event,
dedupeKey: dailyDedupeKey(event.type, event.aggregateId, day),
}));
}
interface TreatmentRow {
id: string;
colonyId: string;
date: Date;
endDate: Date | null;
withdrawalPeriodDays: number | null;
}
/** Treatment: the day after which honey may be harvested again. */
const withdrawalEnded: DailyRule<'WithdrawalEnded'> = {
type: 'WithdrawalEnded',
async occurrences(db, today) {
// Prisma cannot compare column arithmetic; the days are compared as
// dates, so the session time zone plays no part.
const treatments = await db.$queryRaw<TreatmentRow[]>`
SELECT "id", "colonyId", "date", "endDate", "withdrawalPeriodDays"
FROM "Treatment"
WHERE "withdrawalPeriodDays" IS NOT NULL
AND (COALESCE("endDate", "date") + "withdrawalPeriodDays" * interval '1 day')::date
BETWEEN ${utcDay(dayAfter(today, -CATCH_UP_DAYS))}::date AND ${utcDay(today)}::date`;
return treatments.flatMap((treatment) => {
const day = withdrawalEndDay(treatment);
if (day === null) {
return [];
}
return [
{
day,
event: {
type: 'WithdrawalEnded',
aggregateType: 'Treatment',
aggregateId: treatment.id,
payload: { colonyId: treatment.colonyId, endedOn: day },
},
},
];
});
},
};
/** Open task: its due day. */
const taskDue: DailyRule<'TaskDue'> = {
type: 'TaskDue',
async occurrences(db, today) {
const tasks = await db.task.findMany({
where: {
status: { in: OPEN_TASK_STATUSES },
dueDate: {
gte: dayAfter(today, -CATCH_UP_DAYS),
lt: dayAfter(today, 1),
},
},
select: { id: true, dueDate: true },
});
return tasks.flatMap(({ id, dueDate }) => {
if (dueDate === null) {
return [];
}
const day = utcDay(dueDate);
return [
{
day,
event: {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: id,
payload: { dueOn: day },
},
},
];
});
},
};
/** Active queen from her second year after hatching: once a year, on January 1. */
const queenAged: DailyRule<'QueenAged'> = {
type: 'QueenAged',
async occurrences(db, today) {
const year = today.getUTCFullYear();
const day = `${year}-01-01`;
if (!inCatchUpWindow(day, today)) {
return [];
}
const queens = await db.queen.findMany({
where: {
status: 'ACTIVE',
hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS },
},
select: { id: true, hatchYear: true, colony: { select: { id: true } } },
});
return queens.flatMap(({ id, hatchYear, colony }) => {
if (hatchYear === null || year - hatchYear < QUEEN_AGED_MIN_YEARS) {
return [];
}
return [
{
day,
event: {
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: id,
payload: { hatchYear, colonyId: colony?.id ?? null },
},
},
];
});
},
};
interface CycleDue {
colonyId: string;
day: string;
/** The UTC day of the last record, null if the colony has none. */
lastOn: string | null;
}
/**
* Living colonies whose cycle comes due in the active season: counted from
* the last record, or from the colony's creation if it has none.
*/
async function colonyCyclesDue(
db: PrismaService,
lastRecordOn: ReadonlyMap<string, Date>,
intervalDays: number,
today: Date,
): Promise<CycleDue[]> {
const colonies = await db.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, createdAt: true },
});
return colonies.flatMap(({ id, createdAt }) => {
const last = lastRecordOn.get(id) ?? null;
const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today);
if (day === null || !inActiveSeason(day)) {
return [];
}
return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }];
});
}
function latestByColony(
rows: { colonyId: string; _max: { date: Date | null } }[],
): ReadonlyMap<string, Date> {
return new Map(
rows.flatMap(({ colonyId, _max }) =>
_max.date === null ? [] : [[colonyId, _max.date] as const],
),
);
}
/** Living colony: varroa measurement due again in the active season. */
const varroaMeasurementDue: DailyRule<'VarroaMeasurementDue'> = {
type: 'VarroaMeasurementDue',
async occurrences(db, today) {
const latest = await db.varroaMeasurement.groupBy({
by: ['colonyId'],
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
});
const due = await colonyCyclesDue(
db,
latestByColony(latest),
VARROA_MEASUREMENT_INTERVAL_DAYS,
today,
);
return due.map(({ colonyId, day, lastOn }) => ({
day,
event: {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastMeasuredOn: lastOn },
},
}));
},
};
/** Living colony: inspection overdue in the active season. */
const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
type: 'InspectionOverdue',
async occurrences(db, today) {
const latest = await db.inspection.groupBy({
by: ['colonyId'],
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
});
const due = await colonyCyclesDue(
db,
latestByColony(latest),
INSPECTION_INTERVAL_DAYS,
today,
);
return due.map(({ colonyId, day, lastOn }) => ({
day,
event: {
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastInspectedOn: lastOn },
},
}));
},
};
/** The rules of the daily evaluation, one row per time-based event type. */
export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
withdrawalEnded,
taskDue,
queenAged,
varroaMeasurementDue,
inspectionOverdue,
];
@@ -0,0 +1,39 @@
import { PERMISSION_CATALOG } from '../auth/permissions/permission-catalog';
import {
JOB_ALLOWED_ARGS,
JOB_FAILURE_PERMISSION,
JOB_NAMES,
} from './job-names';
// Failure permissions that join the catalog with the System page. The
// change that adds them to the catalog must empty this list: from then on
// the second test fails until it is empty.
const PENDING_FAILURE_PERMISSIONS: readonly string[] = [];
const catalogKeys = new Set<string>(PERMISSION_CATALOG.map((e) => e.key));
describe('job names', () => {
it('every job failure permission is in the permission catalog, except the pending ones', () => {
const unknown = Object.values(JOB_FAILURE_PERMISSION).filter(
(key) =>
!catalogKeys.has(key) && !PENDING_FAILURE_PERMISSIONS.includes(key),
);
expect(unknown).toEqual([]);
});
it('the pending list holds only permissions still missing from the catalog', () => {
expect(
PENDING_FAILURE_PERMISSIONS.filter((key) => catalogKeys.has(key)),
).toEqual([]);
});
it('only rekey accepts an argument, and only --dry-run', () => {
expect(Object.keys(JOB_ALLOWED_ARGS)).toEqual([...JOB_NAMES]);
for (const name of JOB_NAMES) {
expect(JOB_ALLOWED_ARGS[name]).toEqual(
name === 'rekey' ? ['--dry-run'] : [],
);
}
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { PermissionKey } from '../auth/permissions/permission-catalog';
/** The jobs of `node dist/cli.js <job> [args]`, one CronJob each (deploy/k8s). */
export const JOB_NAMES = [
'daily-evaluation',
'task-templates',
'cleanup',
'app-backup',
'rekey',
] as const;
export type JobName = (typeof JOB_NAMES)[number];
/** How the UI and the failure notification name a job. */
export const JOB_LABELS_DE: Record<JobName, string> = {
'daily-evaluation': 'Tägliche Auswertung',
'task-templates': 'Aufgaben aus Vorlagen',
cleanup: 'Aufräumen',
'app-backup': 'App-Backup',
rekey: 'Schlüssel umstellen',
};
/**
* Who is told about a failed run: the holders of the permission that
* manages what the job does. system:manage joins the permission catalog
* together with the System page; until then a failed cleanup or rekey run
* notifies nobody and shows in JobRun and the pod log only.
*/
export const JOB_FAILURE_PERMISSION: Record<JobName, PermissionKey> = {
'daily-evaluation': 'automation:update',
'task-templates': 'automation:update',
cleanup: 'system:manage',
'app-backup': 'backup:configure',
rekey: 'system:manage',
};
/**
* The arguments each job accepts after its name. Anything else is a wrong
* call (exit code 2) and starts nothing: a typo such as `rekey --dry-rn`
* must not run the real job.
*/
export const JOB_ALLOWED_ARGS: Record<JobName, readonly string[]> = {
'daily-evaluation': [],
'task-templates': [],
cleanup: [],
'app-backup': [],
rekey: ['--dry-run'],
};
export function isJobName(value: string): value is JobName {
return (JOB_NAMES as readonly string[]).includes(value);
}
@@ -0,0 +1,52 @@
import { Test } from '@nestjs/testing';
import { Job, JobResult } from './job';
import { JOB_NAMES, JobName } from './job-names';
import { JobRegistry, JOBS } from './job-registry';
import { JobsModule } from './jobs.module';
function stubJob(name: string): Job {
return {
name: name as JobName,
run: (): Promise<JobResult> => Promise.resolve({}),
};
}
const allStubs = (): Job[] => JOB_NAMES.map(stubJob);
describe('JobRegistry', () => {
// Compiled only - no init, so nothing connects to the database.
it('the registry covers every JOB_NAME exactly once', async () => {
const moduleRef = await Test.createTestingModule({
imports: [JobsModule],
}).compile();
try {
const registry = moduleRef.get(JobRegistry);
for (const name of JOB_NAMES) {
expect(registry.get(name).name).toBe(name);
}
expect(moduleRef.get<Job[]>(JOBS)).toHaveLength(JOB_NAMES.length);
} finally {
await moduleRef.close();
}
});
it('refuses a job registered twice', () => {
expect(() => new JobRegistry([...allStubs(), stubJob('cleanup')])).toThrow(
'Job "cleanup" is registered twice',
);
});
it('refuses a registry that misses a job', () => {
const withoutRekey = allStubs().filter((job) => job.name !== 'rekey');
expect(() => new JobRegistry(withoutRekey)).toThrow(
'No job registered for: rekey',
);
});
it('refuses a job whose name is not in JOB_NAMES', () => {
expect(() => new JobRegistry([...allStubs(), stubJob('nope')])).toThrow(
'Unknown job name "nope"',
);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { Inject, Injectable } from '@nestjs/common';
import { Job } from './job';
import { isJobName, JOB_NAMES, JobName } from './job-names';
/** Injection token of every Job provider of JobsModule. */
export const JOBS = Symbol('JOBS');
function indexJobs(jobs: readonly Job[]): ReadonlyMap<JobName, Job> {
const byName = new Map<JobName, Job>();
for (const job of jobs) {
if (!isJobName(job.name)) {
throw new Error(`Unknown job name "${String(job.name)}"`);
}
if (byName.has(job.name)) {
throw new Error(`Job "${job.name}" is registered twice`);
}
byName.set(job.name, job);
}
const missing = JOB_NAMES.filter((name) => !byName.has(name));
if (missing.length > 0) {
throw new Error(`No job registered for: ${missing.join(', ')}`);
}
return byName;
}
/**
* The job behind each JOB_NAME. Checked once when the context is built: a
* name without a job, a job registered twice or a job with a name outside
* JOB_NAMES stops every run instead of failing only the affected CronJob.
*/
@Injectable()
export class JobRegistry {
private readonly jobs: ReadonlyMap<JobName, Job>;
constructor(@Inject(JOBS) jobs: readonly Job[]) {
this.jobs = indexJobs(jobs);
}
get(name: JobName): Job {
const job = this.jobs.get(name);
if (!job) {
// Unreachable: indexJobs requires a job for every JOB_NAME.
throw new Error(`No job registered for: ${name}`);
}
return job;
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { JobName } from './job-names';
/**
* What a run reports, stored as JobRun.result and shown on the System page:
* counts, flags and short names - never personal data or secrets.
*/
export type JobResult = Record<string, number | string | boolean | null>;
/**
* One job of dist/cli.js. `args` are the command line arguments after the
* job name (e.g. ['--dry-run']). A job fails its run by throwing; the
* message is stored via loggableError().
*/
export interface Job {
readonly name: JobName;
run(args: readonly string[]): Promise<JobResult>;
}
+73
View File
@@ -0,0 +1,73 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { BackupModule } from '../backup/backup.module';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { SystemModule } from '../system/system.module';
import { TasksModule } from '../tasks/tasks.module';
import { AppBackupJob } from './app-backup.job';
import { CleanupJob } from './cleanup.job';
import { DailyEvaluationJob } from './daily-evaluation.job';
import { Job } from './job';
import { JobRegistry, JOBS } from './job-registry';
import { RekeyJob } from './rekey.job';
import { TaskTemplatesJob } from './task-templates.job';
/**
* PermissionResolverService for the job context (BackupService clears its
* cache after a restore, which no job does). Not PermissionsModule: its
* PermissionCatalogSyncService would rewrite the permission catalog on
* every job start. Global like PermissionsModule, so modules that inject
* the resolver load here unchanged.
*/
@Global()
@Module({
providers: [PermissionResolverService],
exports: [PermissionResolverService],
})
class JobPermissionsModule {}
const JOB_CLASSES = [
DailyEvaluationJob,
TaskTemplatesJob,
CleanupJob,
AppBackupJob,
RekeyJob,
];
/**
* Root module of dist/cli.js, started with
* NestFactory.createApplicationContext for exactly one run: the modules
* the jobs need, without the HTTP layer (no guards, interceptors, throttler
* or routes - controllers of the imported modules are never served) and
* without the worker loops. No in-process scheduler: no timer may run
* in a job pod.
*/
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuditModule,
NotificationsModule,
DomainEventsModule,
TasksModule,
JobPermissionsModule,
BackupModule,
SystemModule,
],
providers: [
...JOB_CLASSES,
{
provide: JOBS,
useFactory: (...jobs: Job[]): Job[] => jobs,
inject: JOB_CLASSES,
},
JobRegistry,
IdempotencyCleanupService,
],
})
export class JobsModule {}
+569
View File
@@ -0,0 +1,569 @@
import { Logger } from '@nestjs/common';
import * as mfaCrypto from '../auth/mfa/mfa-crypto.util';
import {
decryptMfaSecret,
encryptMfaSecret,
} from '../auth/mfa/mfa-crypto.util';
import { gcmSeal, legacyJwtKey } from '../common/crypto/key-ring';
import { PrismaService } from '../prisma/prisma.service';
import {
openWebhookSecret,
sealWebhookSecret,
} from '../webhooks/webhook-secret';
import { RekeyJob } from './rekey.job';
// Written by the code before the key ring, with JWT_ACCESS_SECRET = 'x' * 40
// (the same bytes as in mfa-crypto.util.spec.ts and webhook-secret.spec.ts).
const LEGACY_MFA =
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
const LEGACY_MFA_PLAINTEXT = 'JBSWY3DPEHPK3PXP';
const LEGACY_WEBHOOK =
'enc:v1:MlOPzAuy67RTMMb9.YslrBRO2dTYumby+P28INg==.KFmilRVOsJtQ';
const LEGACY_WEBHOOK_PLAINTEXT = 'topsecret';
// From before webhook secrets were encrypted at all.
const PLAIN_WEBHOOK = '3f9c2a7d41e8b6c05a1d9e7f3b2c8a64';
interface UserRow {
id: string;
mfaSecret: string | null;
}
interface SubscriptionRow {
id: string;
secret: string;
}
interface UpdateResult {
count: number;
}
interface UserUpdate {
where: { id: string; mfaSecret: string };
data: { mfaSecret: string };
}
interface SubscriptionUpdate {
where: { id: string; secret: string };
data: { secret: string };
}
/** How the code before the key ring sealed: AES-256-GCM under sha256(JWT_ACCESS_SECRET). */
function legacyCiphertext(plaintext: string): string {
const { iv, tag, ciphertext } = gcmSeal(
legacyJwtKey(),
Buffer.from(plaintext, 'utf8'),
);
return [iv, tag, ciphertext].map((part) => part.toString('base64')).join('.');
}
/** Seals with the key that `<variable>_PREVIOUS` names, as if before the rotation. */
function sealedWithPrevious(variable: string, seal: () => string): string {
const current = process.env[variable];
process.env[variable] = process.env[`${variable}_PREVIOUS`];
try {
return seal();
} finally {
process.env[variable] = current;
}
}
/**
* The two tables, in memory. updateMany behaves like the SQL compare-and-
* swap: it writes only while id and old value still match.
*/
function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
const userRows = new Map(users.map((row) => [row.id, row]));
const subscriptionRows = new Map(subscriptions.map((row) => [row.id, row]));
const user = {
findMany: jest.fn(() =>
Promise.resolve(
[...userRows.values()].filter((row) => row.mfaSecret !== null),
),
),
updateMany: jest.fn<Promise<UpdateResult>, [UserUpdate]>(
({ where, data }) => {
const row = userRows.get(where.id);
if (!row || row.mfaSecret !== where.mfaSecret) {
return Promise.resolve({ count: 0 });
}
userRows.set(where.id, { ...row, mfaSecret: data.mfaSecret });
return Promise.resolve({ count: 1 });
},
),
};
const webhookSubscription = {
findMany: jest.fn(() => Promise.resolve([...subscriptionRows.values()])),
updateMany: jest.fn<Promise<UpdateResult>, [SubscriptionUpdate]>(
({ where, data }) => {
const row = subscriptionRows.get(where.id);
if (!row || row.secret !== where.secret) {
return Promise.resolve({ count: 0 });
}
subscriptionRows.set(where.id, { ...row, secret: data.secret });
return Promise.resolve({ count: 1 });
},
),
};
return {
prisma: { user, webhookSubscription } as unknown as PrismaService,
user,
webhookSubscription,
userRows,
subscriptionRows,
mfaSecret: (id: string): string => userRows.get(id)?.mfaSecret ?? '',
webhookSecret: (id: string): string =>
subscriptionRows.get(id)?.secret ?? '',
};
}
const keyIdOf = (stored: string): string => stored.split(':')[0];
describe('RekeyJob', () => {
const originalEnv = { ...process.env };
let warn: jest.SpyInstance<void, unknown[]>;
let error: jest.SpyInstance<void, unknown[]>;
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40);
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = 'n'.repeat(40);
process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40);
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = 'v'.repeat(40);
warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
error = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
});
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
/** Everything the job wrote to the log, as one string per call. */
const logged = (): string[] =>
[...warn.mock.calls, ...error.mock.calls].map((call) =>
call.map(String).join(' '),
);
describe('a full set of old and current secrets', () => {
let previousMfa: string;
let currentMfa: string;
let previousWebhook: string;
let currentWebhook: string;
let db: ReturnType<typeof fakeDb>;
beforeEach(() => {
previousMfa = sealedWithPrevious('MFA_ENCRYPTION_KEY', () =>
encryptMfaSecret('PREVIOUSKEYMFA22'),
);
currentMfa = encryptMfaSecret('CURRENTKEYMFA222');
previousWebhook = sealedWithPrevious('WEBHOOK_ENCRYPTION_KEY', () =>
sealWebhookSecret('previous-webhook-secret'),
);
currentWebhook = sealWebhookSecret('current-webhook-secret');
db = fakeDb(
[
{ id: 'u1', mfaSecret: LEGACY_MFA },
{ id: 'u2', mfaSecret: previousMfa },
{ id: 'u3', mfaSecret: currentMfa },
],
[
{ id: 'w1', secret: LEGACY_WEBHOOK },
{ id: 'w2', secret: PLAIN_WEBHOOK },
{ id: 'w3', secret: previousWebhook },
{ id: 'w4', secret: currentWebhook },
],
);
});
const expectedCounts = {
'mfa.total': 3,
'mfa.rekeyed': 2,
'mfa.alreadyCurrent': 1,
'mfa.plaintextSealed': 0,
'mfa.changedMeanwhile': 0,
'mfa.failed': 0,
'webhook.total': 4,
'webhook.rekeyed': 2,
'webhook.alreadyCurrent': 1,
'webhook.plaintextSealed': 1,
'webhook.changedMeanwhile': 0,
'webhook.failed': 0,
};
it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', async () => {
const job = new RekeyJob(db.prisma);
const result = await job.run([]);
expect(job.name).toBe('rekey');
expect(result).toEqual({ dryRun: false, ...expectedCounts });
// Every value is now sealed with the current key ...
for (const id of ['u1', 'u2', 'u3']) {
expect(keyIdOf(db.mfaSecret(id))).toBe(keyIdOf(currentMfa));
}
for (const id of ['w1', 'w2', 'w3', 'w4']) {
expect(keyIdOf(db.webhookSecret(id))).toBe(keyIdOf(currentWebhook));
}
// ... a current one was left exactly as it was ...
expect(db.mfaSecret('u3')).toBe(currentMfa);
expect(db.webhookSecret('w4')).toBe(currentWebhook);
expect(db.user.updateMany).toHaveBeenCalledTimes(2);
expect(db.webhookSubscription.updateMany).toHaveBeenCalledTimes(3);
// ... and each write was a compare-and-swap on the value it read.
expect(db.user.updateMany).toHaveBeenCalledWith({
where: { id: 'u1', mfaSecret: LEGACY_MFA },
data: { mfaSecret: db.mfaSecret('u1') },
});
expect(db.webhookSubscription.updateMany).toHaveBeenCalledWith({
where: { id: 'w2', secret: PLAIN_WEBHOOK },
data: { secret: db.webhookSecret('w2') },
});
// Only id and secret are read, in a stable order.
expect(db.user.findMany).toHaveBeenCalledWith({
where: { mfaSecret: { not: null } },
select: { id: true, mfaSecret: true },
orderBy: { id: 'asc' },
});
expect(db.webhookSubscription.findMany).toHaveBeenCalledWith({
select: { id: true, secret: true },
orderBy: { id: 'asc' },
});
});
it('leaves values that open to the original secrets without the old keys', async () => {
await new RekeyJob(db.prisma).run([]);
// After the run: *_PREVIOUS removed and the JWT secret rotated.
delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS;
delete process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS;
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(decryptMfaSecret(db.mfaSecret('u1'))).toBe(LEGACY_MFA_PLAINTEXT);
expect(decryptMfaSecret(db.mfaSecret('u2'))).toBe('PREVIOUSKEYMFA22');
expect(decryptMfaSecret(db.mfaSecret('u3'))).toBe('CURRENTKEYMFA222');
expect(openWebhookSecret(db.webhookSecret('w1'))).toBe(
LEGACY_WEBHOOK_PLAINTEXT,
);
expect(openWebhookSecret(db.webhookSecret('w2'))).toBe(PLAIN_WEBHOOK);
expect(openWebhookSecret(db.webhookSecret('w3'))).toBe(
'previous-webhook-secret',
);
expect(openWebhookSecret(db.webhookSecret('w4'))).toBe(
'current-webhook-secret',
);
});
it('dry-run counts what a run would change and writes nothing', async () => {
const before = {
users: [...db.userRows.values()],
subscriptions: [...db.subscriptionRows.values()],
};
const result = await new RekeyJob(db.prisma).run(['--dry-run']);
expect(result).toEqual({ dryRun: true, ...expectedCounts });
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
expect([...db.userRows.values()]).toEqual(before.users);
expect([...db.subscriptionRows.values()]).toEqual(before.subscriptions);
});
it('a second run rekeys nothing', async () => {
const job = new RekeyJob(db.prisma);
await job.run([]);
db.user.updateMany.mockClear();
db.webhookSubscription.updateMany.mockClear();
const result = await job.run([]);
expect(result).toMatchObject({
'mfa.alreadyCurrent': 3,
'mfa.rekeyed': 0,
'webhook.alreadyCurrent': 4,
'webhook.rekeyed': 0,
'webhook.plaintextSealed': 0,
});
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
});
});
it('resumes after an interruption: current rows are skipped, the rest are rekeyed', async () => {
const stored = [
'AAAAAAAAAAAAAAAA',
'BBBBBBBBBBBBBBBB',
'CCCCCCCCCCCCCCCC',
].map(legacyCiphertext);
const db = fakeDb(
stored.map((mfaSecret, index) => ({ id: `u${index + 1}`, mfaSecret })),
[],
);
const job = new RekeyJob(db.prisma);
const updateMany = db.user.updateMany;
updateMany.mockRejectedValueOnce(new Error('connection reset'));
await expect(job.run([])).rejects.toThrow(
/mfa\.rekeyed=2, .*mfa\.failed=1/,
);
// The failed row still holds its old value, the others were written.
expect(db.mfaSecret('u1')).toBe(stored[0]);
expect(db.mfaSecret('u2')).not.toBe(stored[1]);
expect(db.mfaSecret('u3')).not.toBe(stored[2]);
expect(logged()).toContainEqual(
expect.stringContaining(
'User u1: mfa secret not rekeyed: connection reset',
),
);
updateMany.mockClear();
updateMany.mockResolvedValue({ count: 1 });
expect(await job.run([])).toMatchObject({
'mfa.alreadyCurrent': 2,
'mfa.rekeyed': 1,
'mfa.failed': 0,
});
expect(updateMany).toHaveBeenCalledTimes(1);
expect(updateMany).toHaveBeenCalledWith({
where: { id: 'u1', mfaSecret: stored[0] },
data: { mfaSecret: expect.stringMatching(/^k[0-9a-f]{8}:/) as string },
});
});
it('skips a row changed since it was read and reports it', async () => {
const oldMfa = legacyCiphertext('OLDMFASECRET2222');
const db = fakeDb(
[{ id: 'u1', mfaSecret: oldMfa }],
[{ id: 'w1', secret: PLAIN_WEBHOOK }],
);
// Right after the read, the user sets MFA up anew and the
// subscription is deleted.
const reenrolled = encryptMfaSecret('NEWMFASECRET2222');
db.user.findMany.mockImplementationOnce(() => {
const read = [...db.userRows.values()];
db.userRows.set('u1', { id: 'u1', mfaSecret: reenrolled });
return Promise.resolve(read);
});
db.webhookSubscription.findMany.mockImplementationOnce(() => {
const read = [...db.subscriptionRows.values()];
db.subscriptionRows.delete('w1');
return Promise.resolve(read);
});
const result = await new RekeyJob(db.prisma).run([]);
expect(result).toMatchObject({
'mfa.total': 1,
'mfa.rekeyed': 0,
'mfa.changedMeanwhile': 1,
'mfa.failed': 0,
'webhook.total': 1,
'webhook.plaintextSealed': 0,
'webhook.changedMeanwhile': 1,
'webhook.failed': 0,
});
// The new MFA secret was not overwritten with the old one.
expect(db.mfaSecret('u1')).toBe(reenrolled);
expect(db.subscriptionRows.has('w1')).toBe(false);
expect(logged()).toEqual(
expect.arrayContaining([
expect.stringContaining(
'User u1: mfa secret changed since it was read',
),
expect.stringContaining(
'WebhookSubscription w1: webhook secret changed since it was read',
),
]),
);
});
describe('rows already on the current key', () => {
/** A current-key value whose ciphertext no longer authenticates. */
function corrupted(sealed: string): string {
const parts = sealed.split('.');
const last = parts[parts.length - 1];
parts[parts.length - 1] = (last[0] === 'A' ? 'B' : 'A') + last.slice(1);
return parts.join('.');
}
let brokenMfa: string;
let db: ReturnType<typeof fakeDb>;
beforeEach(() => {
brokenMfa = corrupted(encryptMfaSecret('CURRENTKEYMFA222'));
db = fakeDb(
[
{ id: 'u1', mfaSecret: brokenMfa },
{ id: 'u2', mfaSecret: encryptMfaSecret('CURRENTKEYMFA333') },
],
[
// Plain text that happens to look like a current-key value.
{
id: 'w1',
secret: `${keyIdOf(sealWebhookSecret('probe'))}:looks-sealed`,
},
{ id: 'w2', secret: sealWebhookSecret('fine') },
],
);
});
it('dry-run opens them and fails the run on a value that does not open', async () => {
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
/^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1$/,
);
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
});
it('a real run skips them without opening and leaves them as they are', async () => {
const decrypt = jest.spyOn(mfaCrypto, 'decryptMfaSecret');
const result = await new RekeyJob(db.prisma).run([]);
expect(result).toMatchObject({
'mfa.alreadyCurrent': 2,
'mfa.failed': 0,
'webhook.alreadyCurrent': 2,
'webhook.failed': 0,
});
expect(decrypt).not.toHaveBeenCalled();
expect(db.mfaSecret('u1')).toBe(brokenMfa);
});
});
describe('secrets that cannot be opened', () => {
let unknownKey: string;
let staleLegacy: string;
let previousMfa: string;
let db: ReturnType<typeof fakeDb>;
beforeEach(() => {
// Sealed with a key that is neither current nor previous (e.g. the
// previous key was removed before the rekey ran).
unknownKey = `k00000000:${LEGACY_MFA}`;
staleLegacy = legacyCiphertext('STALELEGACYMFA22');
previousMfa = sealedWithPrevious('MFA_ENCRYPTION_KEY', () =>
encryptMfaSecret('PREVIOUSKEYMFA22'),
);
db = fakeDb(
[
{ id: 'u1', mfaSecret: unknownKey },
{ id: 'u2', mfaSecret: staleLegacy },
{ id: 'u3', mfaSecret: previousMfa },
],
[
{ id: 'w1', secret: 'enc:v1:not-a-ciphertext' },
{ id: 'w2', secret: PLAIN_WEBHOOK },
],
);
// JWT_ACCESS_SECRET was rotated before the rekey ran.
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
});
it('fails the run after every other row was rekeyed, leaving those rows as they are', async () => {
const run = new RekeyJob(db.prisma).run([]);
await expect(run).rejects.toThrow(
'rekey: 3 secret(s) could not be rekeyed - dryRun=false, mfa.total=3, mfa.rekeyed=1, mfa.alreadyCurrent=0, mfa.plaintextSealed=0, mfa.changedMeanwhile=0, mfa.failed=2, webhook.total=2, webhook.rekeyed=0, webhook.alreadyCurrent=0, webhook.plaintextSealed=1, webhook.changedMeanwhile=0, webhook.failed=1',
);
expect(db.mfaSecret('u1')).toBe(unknownKey);
expect(db.mfaSecret('u2')).toBe(staleLegacy);
expect(db.webhookSecret('w1')).toBe('enc:v1:not-a-ciphertext');
expect(decryptMfaSecret(db.mfaSecret('u3'))).toBe('PREVIOUSKEYMFA22');
expect(openWebhookSecret(db.webhookSecret('w2'))).toBe(PLAIN_WEBHOOK);
expect(logged()).toEqual(
expect.arrayContaining([
expect.stringContaining(
'User u1: mfa secret not rekeyed: unknown key id',
),
expect.stringContaining(
'User u2: mfa secret not rekeyed: ciphertext failed authentication',
),
expect.stringContaining(
'WebhookSubscription w1: webhook secret not rekeyed: malformed ciphertext',
),
]),
);
});
it('dry-run reports them too and writes nothing', async () => {
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
/^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1$/,
);
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
});
it('never logs or reports a secret or ciphertext', async () => {
const values = [
unknownKey,
staleLegacy,
previousMfa,
'enc:v1:not-a-ciphertext',
PLAIN_WEBHOOK,
'STALELEGACYMFA22',
'PREVIOUSKEYMFA22',
];
const fragments = values.flatMap((value) =>
value.split(/[:.]/).filter((part) => part.length >= 8),
);
const message = await new RekeyJob(db.prisma).run([]).then(
() => '',
(failure: Error) => failure.message,
);
expect(message).toContain('mfa.failed=2');
for (const text of [message, ...logged()]) {
for (const fragment of fragments) {
expect(text).not.toContain(fragment);
}
}
});
});
it('checks that the new value opens to the same secret before it replaces the old one', async () => {
const stored = legacyCiphertext('ORIGINALMFA22222');
const db = fakeDb([{ id: 'u1', mfaSecret: stored }], []);
const wrongSeal = encryptMfaSecret('SOMETHINGELSE222');
jest.spyOn(mfaCrypto, 'encryptMfaSecret').mockReturnValue(wrongSeal);
await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow(
'mfa.failed=1',
);
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.mfaSecret('u1')).toBe(stored);
expect(logged()).toContainEqual(
expect.stringContaining(
'User u1: mfa secret not rekeyed: resealed secret does not open to the same value',
),
);
});
it('fails before reading any row when a key is set but unusable', async () => {
process.env.WEBHOOK_ENCRYPTION_KEY = 'too-short';
const db = fakeDb([{ id: 'u1', mfaSecret: LEGACY_MFA }], []);
await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow(
'WEBHOOK_ENCRYPTION_KEY must be at least 32 characters',
);
expect(db.user.findMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.findMany).not.toHaveBeenCalled();
});
// dist/cli.js refuses these already (JOB_ALLOWED_ARGS); a typo must never
// run the real rekey, however the job is called.
it.each([[['--dry-rn']], [['--dry-run', 'extra']]])(
'refuses %j before reading any row',
async (args) => {
const db = fakeDb([{ id: 'u1', mfaSecret: LEGACY_MFA }], []);
await expect(new RekeyJob(db.prisma).run(args)).rejects.toThrow(
'rekey does not accept',
);
expect(db.user.findMany).not.toHaveBeenCalled();
},
);
});
+265
View File
@@ -0,0 +1,265 @@
import { Injectable, Logger } from '@nestjs/common';
import {
decryptMfaSecret,
encryptMfaSecret,
mfaSecretNeedsRekey,
} from '../auth/mfa/mfa-crypto.util';
import { loadKeyRing, SecretPurpose } from '../common/crypto/key-ring';
import { loggableError } from '../common/loggable-error';
import { PrismaService } from '../prisma/prisma.service';
import {
isPlaintextWebhookSecret,
openWebhookSecret,
sealWebhookSecret,
webhookSecretNeedsRekey,
} from '../webhooks/webhook-secret';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/** `rekey --dry-run`: open and count every secret, write none. */
export const REKEY_DRY_RUN_ARG = '--dry-run';
/** What one run did with the secrets of one purpose (mfa, webhook). */
export interface RekeyCounts {
/** Rows with a stored secret. */
total: number;
/** Sealed with the previous key or before the key ring, resealed. */
rekeyed: number;
/**
* Already sealed with the current key, left as they are. Only a dry-run
* opens them (a value that does not open counts as `failed`).
*/
alreadyCurrent: number;
/** Webhook secrets stored in plain text, now sealed. */
plaintextSealed: number;
/** Written by someone else since this run read them; not overwritten. */
changedMeanwhile: number;
/** Could not be opened or written; they keep their old value. */
failed: number;
}
type RowOutcome = Exclude<keyof RekeyCounts, 'total'>;
interface StoredSecret {
id: string;
stored: string;
}
/** One column of sealed secrets, with the helpers of its purpose. */
interface SecretColumn {
purpose: SecretPurpose;
/** How log lines name a row: "<entity> <id>". */
entity: string;
read(): Promise<StoredSecret[]>;
/**
* Writes `next` only while the row still holds `previous` - one UPDATE
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
* another run) is never overwritten. False if nothing matched.
*/
swap(id: string, previous: string, next: string): Promise<boolean>;
needsRekey(stored: string): boolean;
isPlaintext(stored: string): boolean;
open(stored: string): string;
seal(plaintext: string): string;
}
/** Refuses anything but --dry-run: a typo must never start the real run. */
function isDryRun(args: readonly string[]): boolean {
const refused = args.filter((arg) => arg !== REKEY_DRY_RUN_ARG);
if (refused.length > 0) {
throw new Error(
`rekey does not accept ${refused.map((arg) => JSON.stringify(arg)).join(', ')}`,
);
}
return args.length > 0;
}
function countOutcomes(outcomes: readonly RowOutcome[]): RekeyCounts {
const count = (outcome: RowOutcome): number =>
outcomes.filter((each) => each === outcome).length;
return {
total: outcomes.length,
rekeyed: count('rekeyed'),
alreadyCurrent: count('alreadyCurrent'),
plaintextSealed: count('plaintextSealed'),
changedMeanwhile: count('changedMeanwhile'),
failed: count('failed'),
};
}
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
function toJobResult(
dryRun: boolean,
counts: readonly (readonly [SecretPurpose, RekeyCounts])[],
): JobResult {
return {
dryRun,
...Object.fromEntries(
counts.flatMap(([purpose, perPurpose]) =>
(Object.keys(perPurpose) as (keyof RekeyCounts)[]).map((key) => [
`${purpose}.${key}`,
perPurpose[key],
]),
),
),
};
}
function describeResult(result: JobResult): string {
return Object.entries(result)
.map(([key, value]) => `${key}=${String(value)}`)
.join(', ');
}
/**
* Reseals every stored MFA (TOTP) and webhook signing secret with the
* current key of its purpose (common/crypto/key-ring.ts): values sealed
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
* still stored in plain text. Run it after setting new keys, before
* removing `*_PREVIOUS` and before rotating the JWT secrets.
*
* One row at a time, each write its own compare-and-swap statement, no
* transaction around the run: an interrupted run keeps what it resealed and
* the next run skips those rows. A row that cannot be opened or written
* keeps its old value; the run goes on and fails at the end, with the
* counts in its error. Logs name rows by id, never a secret or ciphertext.
*/
@Injectable()
export class RekeyJob implements Job {
readonly name: JobName = 'rekey';
private readonly logger = new Logger(RekeyJob.name);
constructor(private readonly prisma: PrismaService) {}
async run(args: readonly string[]): Promise<JobResult> {
const dryRun = isDryRun(args);
const columns = [this.mfaColumn(), this.webhookColumn()];
// A key that is set but unusable fails the run before any row is read.
for (const column of columns) {
loadKeyRing(column.purpose);
}
const counts: (readonly [SecretPurpose, RekeyCounts])[] = [];
for (const column of columns) {
counts.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
}
const result = toJobResult(dryRun, counts);
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
if (failed > 0) {
throw new Error(
`rekey: ${failed} secret(s) could not be rekeyed - ${describeResult(result)}`,
);
}
return result;
}
private async rekeyColumn(
column: SecretColumn,
dryRun: boolean,
): Promise<RekeyCounts> {
const rows = await column.read();
const outcomes: RowOutcome[] = [];
for (const row of rows) {
outcomes.push(await this.rekeyRow(column, row, dryRun));
}
return countOutcomes(outcomes);
}
private async rekeyRow(
column: SecretColumn,
row: StoredSecret,
dryRun: boolean,
): Promise<RowOutcome> {
const current = !column.needsRekey(row.stored);
// A real run skips current rows unopened; a dry-run opens them too, so
// the closing all-zero check also catches a corrupted current value.
if (current && !dryRun) {
return 'alreadyCurrent';
}
const outcome: RowOutcome = current
? 'alreadyCurrent'
: column.isPlaintext(row.stored)
? 'plaintextSealed'
: 'rekeyed';
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
try {
// Opened in a dry-run too: it reports what a real run could not reseal.
const plaintext = column.open(row.stored);
if (dryRun) {
return outcome;
}
const resealed = column.seal(plaintext);
// The swap replaces the only copy of the old value.
if (column.open(resealed) !== plaintext) {
throw new Error('resealed secret does not open to the same value');
}
if (await column.swap(row.id, row.stored, resealed)) {
return outcome;
}
this.logger.warn(
`${subject} changed since it was read, left as it is (run rekey --dry-run to check)`,
);
return 'changedMeanwhile';
} catch (error: unknown) {
this.logger.error(
`${subject} not rekeyed: ${loggableError(error).message}`,
);
return 'failed';
}
}
private mfaColumn(): SecretColumn {
return {
purpose: 'mfa',
entity: 'User',
read: async () => {
const users = await this.prisma.user.findMany({
where: { mfaSecret: { not: null } },
select: { id: true, mfaSecret: true },
orderBy: { id: 'asc' },
});
return users.flatMap(({ id, mfaSecret }) =>
mfaSecret === null ? [] : [{ id, stored: mfaSecret }],
);
},
swap: async (id, previous, next) => {
const { count } = await this.prisma.user.updateMany({
where: { id, mfaSecret: previous },
data: { mfaSecret: next },
});
return count === 1;
},
needsRekey: mfaSecretNeedsRekey,
// TOTP secrets were never stored in plain text.
isPlaintext: () => false,
open: decryptMfaSecret,
seal: encryptMfaSecret,
};
}
private webhookColumn(): SecretColumn {
return {
purpose: 'webhook',
entity: 'WebhookSubscription',
read: async () => {
const subscriptions = await this.prisma.webhookSubscription.findMany({
select: { id: true, secret: true },
orderBy: { id: 'asc' },
});
return subscriptions.map(({ id, secret }) => ({ id, stored: secret }));
},
swap: async (id, previous, next) => {
const { count } = await this.prisma.webhookSubscription.updateMany({
where: { id, secret: previous },
data: { secret: next },
});
return count === 1;
},
needsRekey: webhookSecretNeedsRekey,
isPlaintext: isPlaintextWebhookSecret,
open: openWebhookSecret,
seal: sealWebhookSecret,
};
}
}
+216
View File
@@ -0,0 +1,216 @@
import { INestApplicationContext, Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { JobRun, Prisma } from '../../generated/prisma';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { JobResult } from './job';
import { JobName } from './job-names';
import { JobRegistry } from './job-registry';
import { JobsModule } from './jobs.module';
import { runCli } from './run-cli';
type RawCall = [readonly string[], ...unknown[]];
const STARTED_AT = new Date('2026-10-13T04:00:00.000Z');
describe('runCli', () => {
let job: { name: JobName; run: jest.Mock<Promise<JobResult>, [string[]]> };
let jobRun: { create: jest.Mock };
// The run is finished by one UPDATE … RETURNING * (finishedAt = now()).
let finish: jest.Mock<Promise<JobRun[]>, RawCall>;
let app: { get: jest.Mock; close: jest.Mock };
let createContext: jest.Mock<Promise<INestApplicationContext>, []>;
let errorSpy: jest.SpyInstance;
beforeEach(() => {
job = {
name: 'cleanup',
run: jest
.fn<Promise<JobResult>, [string[]]>()
.mockResolvedValue({ deleted: 2 }),
};
jobRun = {
create: jest.fn(({ data }: { data: { job: string } }): Promise<JobRun> =>
Promise.resolve({
id: 'run-1',
job: data.job,
status: 'RUNNING',
startedAt: STARTED_AT,
finishedAt: null,
result: null,
error: null,
}),
),
};
finish = jest.fn<Promise<JobRun[]>, RawCall>((_sql, status) =>
Promise.resolve([
{
id: 'run-1',
job: job.name,
status: status as JobRun['status'],
startedAt: STARTED_AT,
finishedAt: new Date(),
result: null,
error: null,
},
]),
);
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
$queryRaw: jest.fn().mockResolvedValue([{ notified: false }]),
};
const prisma = {
jobRun,
$queryRaw: finish,
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
};
const notifications = {
recordForPermission: jest.fn().mockResolvedValue(1),
};
const jobRuns = new JobRunService(
prisma as unknown as PrismaService,
notifications as unknown as NotificationsService,
);
const registry = { get: jest.fn(() => job) };
app = {
get: jest.fn((token: unknown) =>
token === JobRegistry ? registry : jobRuns,
),
close: jest.fn().mockResolvedValue(undefined),
};
createContext = jest
.fn<Promise<INestApplicationContext>, []>()
.mockResolvedValue(app as unknown as INestApplicationContext);
errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('rejects an unknown job with exit code 2 and writes no JobRun', async () => {
expect(await runCli(['nope'], createContext)).toBe(2);
expect(createContext).not.toHaveBeenCalled();
expect(jobRun.create).not.toHaveBeenCalled();
});
it('rejects a call without a job name with exit code 2', async () => {
expect(await runCli([], createContext)).toBe(2);
expect(createContext).not.toHaveBeenCalled();
});
it('runs the job, records SUCCEEDED with its result and returns 0', async () => {
expect(await runCli(['cleanup'], createContext)).toBe(0);
expect(jobRun.create).toHaveBeenCalledWith({ data: { job: 'cleanup' } });
expect(finish.mock.calls[0].slice(1)).toEqual([
'SUCCEEDED',
'{"deleted":2}',
null,
'run-1',
]);
expect(app.close).toHaveBeenCalledTimes(1);
});
it('passes extra arguments such as --dry-run to the job', async () => {
job.name = 'rekey';
expect(await runCli(['rekey', '--dry-run'], createContext)).toBe(0);
expect(job.run).toHaveBeenCalledWith(['--dry-run']);
});
// A typo or a flag the job does not know must not start the real run -
// `rekey --dry-rn` would otherwise rewrite every secret.
it.each([
[['cleanup', '--dry-run']],
[['rekey', '--dry-rn']],
[['rekey', '--dry-run', '--dry-run', 'extra']],
])('rejects %j with exit code 2 before any context exists', async (argv) => {
expect(await runCli(argv, createContext)).toBe(2);
expect(createContext).not.toHaveBeenCalled();
expect(job.run).not.toHaveBeenCalled();
});
it('returns 1 and stores the error via loggableError when the job fails', async () => {
job.run.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('name "Max Mustermann"', {
code: 'P2024',
clientVersion: '7.9.1',
meta: { modelName: 'User' },
}),
);
expect(await runCli(['cleanup'], createContext)).toBe(1);
expect(finish.mock.calls[0].slice(1)).toEqual([
'FAILED',
null,
'PrismaClientKnownRequestError P2024 (User)',
'run-1',
]);
const logged = JSON.stringify(errorSpy.mock.calls);
expect(logged).not.toContain('Mustermann');
expect(app.close).toHaveBeenCalledTimes(1);
});
// No JobRun can be written then - the exit code and the log are all the
// CronJob shows.
it('returns 1 when the context cannot be created, without the Prisma message', async () => {
createContext.mockRejectedValue(
new Prisma.PrismaClientInitializationError(
'Can\'t reach database at "db.internal" for user "geheim"',
'7.9.1',
),
);
expect(await runCli(['cleanup'], createContext)).toBe(1);
const logged = JSON.stringify(errorSpy.mock.calls);
expect(logged).toContain('PrismaClientInitializationError');
expect(logged).not.toContain('geheim');
});
// Without abortOnError: false, Nest exits the process (code 1, no log
// through our logger) on a DI or registry error instead of rejecting.
it('builds the default context without aborting the process on a module error', async () => {
const createApplicationContext = jest
.spyOn(NestFactory, 'createApplicationContext')
.mockRejectedValue(new Error('No job registered for: rekey'));
expect(await runCli(['cleanup'])).toBe(1);
expect(createApplicationContext).toHaveBeenCalledWith(JobsModule, {
abortOnError: false,
});
expect(JSON.stringify(errorSpy.mock.calls)).toContain(
'No job registered for: rekey',
);
});
it('returns 1 and still closes the context when the run cannot be recorded', async () => {
jobRun.create.mockRejectedValue(new Error('connection reset'));
expect(await runCli(['cleanup'], createContext)).toBe(1);
expect(job.run).not.toHaveBeenCalled();
expect(app.close).toHaveBeenCalledTimes(1);
});
it('keeps the exit code when closing the context fails', async () => {
app.close.mockRejectedValue(new Error('pool already closed'));
expect(await runCli(['cleanup'], createContext)).toBe(0);
expect(JSON.stringify(errorSpy.mock.calls)).toContain(
'pool already closed',
);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { INestApplicationContext, Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { loggableError } from '../common/loggable-error';
import { JobRunService } from '../system/job-run.service';
import { isJobName, JOB_ALLOWED_ARGS, JOB_NAMES, JobName } from './job-names';
import { JobRegistry } from './job-registry';
import { JobsModule } from './jobs.module';
/** Exit codes of dist/cli.js: run succeeded, run failed, wrong call. */
export type CliExitCode = 0 | 1 | 2;
const logger = new Logger('Cli');
const USAGE = `Usage: node dist/cli.js <job> [args] - jobs: ${JOB_NAMES.join(', ')}`;
/**
* The context of one job run. abortOnError: false makes a module error
* (DI, JobRegistry) reject, so runCli logs it and exits 1 - by default Nest
* would end the process itself. No shutdown hooks: a pod that is killed
* leaves its run RUNNING, and the cleanup job marks it aborted.
*/
export function createJobsContext(): Promise<INestApplicationContext> {
return NestFactory.createApplicationContext(JobsModule, {
abortOnError: false,
});
}
async function closeContext(app: INestApplicationContext): Promise<void> {
try {
await app.close();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
logger.error(`Closing the job context failed: ${message}`, stack);
}
}
/** True for a known job with accepted arguments; otherwise logs why not. */
function isValidCall(
name: string | undefined,
args: readonly string[],
): name is JobName {
if (name === undefined || !isJobName(name)) {
logger.error(
name === undefined
? 'No job given'
: `Unknown job ${JSON.stringify(name)}`,
);
logger.error(USAGE);
return false;
}
const allowed = JOB_ALLOWED_ARGS[name];
const refused = args.filter((arg) => !allowed.includes(arg));
if (refused.length > 0) {
logger.error(
`Job "${name}" does not accept ${refused.map((arg) => JSON.stringify(arg)).join(', ')} (allowed: ${allowed.join(', ') || 'no arguments'})`,
);
return false;
}
return true;
}
/**
* Runs `node dist/cli.js <job> [args]`: argv[0] is the job, the rest goes
* to the job unchanged. A missing or unknown job, or an argument outside
* JOB_ALLOWED_ARGS, is refused before anything connects, so it writes no
* JobRun.
*/
export async function runCli(
argv: readonly string[],
createContext: () => Promise<INestApplicationContext> = createJobsContext,
): Promise<CliExitCode> {
const [name, ...args] = argv;
if (!isValidCall(name, args)) {
return 2;
}
let app: INestApplicationContext | undefined;
try {
app = await createContext();
const job = app.get(JobRegistry).get(name);
const run = await app.get(JobRunService).run(name, () => job.run(args));
return run.status === 'SUCCEEDED' ? 0 : 1;
} catch (error: unknown) {
// The run could not even be recorded (no context, no database).
const { message, stack } = loggableError(error);
logger.error(`Job "${name}" could not run: ${message}`, stack);
return 1;
} finally {
if (app) {
await closeContext(app);
}
}
}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { TasksService } from '../tasks/tasks.service';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/**
* Creates the due tasks of the recurring templates (CronJob 05:00), so they
* exist without anyone opening the task list first. Running twice, or
* alongside the lazy generation before GET /tasks, creates each occurrence
* once: generateFromTemplates claims it by compare-and-swap.
*/
@Injectable()
export class TaskTemplatesJob implements Job {
readonly name: JobName = 'task-templates';
constructor(private readonly tasks: TasksService) {}
async run(): Promise<JobResult> {
return { created: await this.tasks.generateFromTemplates() };
}
}
+145
View File
@@ -0,0 +1,145 @@
import { Prisma } from '../../generated/prisma';
import {
lockRowForUpdate,
lockRowsForShare,
lockRowsForUpdate,
} from './row-lock';
describe('lockRowForUpdate', () => {
it('locks the row by id with a parameterised FOR UPDATE on the mapped table', async () => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lockRowForUpdate(tx as never, 'Colony', 'c1');
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe('SELECT id FROM "Colony" WHERE id = ? FOR UPDATE');
expect(query.values).toEqual(['c1']);
});
it('refuses the root client', async () => {
const root = { $connect: jest.fn(), $queryRaw: jest.fn() };
await expect(
lockRowForUpdate(root as never, 'Colony', 'c1'),
).rejects.toThrow('lockRowForUpdate needs the transaction client');
expect(root.$queryRaw).not.toHaveBeenCalled();
});
it('refuses a table outside the fixed map', async () => {
const tx = { $queryRaw: jest.fn() };
await expect(
lockRowForUpdate(tx as never, 'User"; DROP TABLE x; --' as never, 'c1'),
).rejects.toThrow('lockRowForUpdate: table not lockable');
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
describe('lockRowsForShare', () => {
it('locks every row with one parameterised FOR SHARE in ascending id order', async () => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
const ids = ['c3', 'c1', 'c2'];
await lockRowsForShare(tx as never, 'Colony', ids);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
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', 'c3']]);
// The caller's list stays as it was.
expect(ids).toEqual(['c3', 'c1', 'c2']);
});
it('sends no statement for an empty list', async () => {
const tx = { $queryRaw: jest.fn() };
await lockRowsForShare(tx as never, 'Colony', []);
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('refuses the root client', async () => {
const root = { $connect: jest.fn(), $queryRaw: jest.fn() };
await expect(
lockRowsForShare(root as never, 'Colony', ['c1']),
).rejects.toThrow('lockRowsForShare needs the transaction client');
expect(root.$queryRaw).not.toHaveBeenCalled();
});
it('refuses a table outside the fixed map', async () => {
const tx = { $queryRaw: jest.fn() };
await expect(
lockRowsForShare(tx as never, 'User"; DROP TABLE x; --' as never, ['c1']),
).rejects.toThrow('lockRowsForShare: table not lockable');
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
describe('lockRowForUpdate result', () => {
it('resolves to whether the row exists', async () => {
const tx = {
$queryRaw: jest
.fn()
.mockResolvedValueOnce([{ id: 'p1' }])
.mockResolvedValueOnce([]),
};
await expect(lockRowForUpdate(tx as never, 'Product', 'p1')).resolves.toBe(
true,
);
await expect(lockRowForUpdate(tx as never, 'Product', 'p2')).resolves.toBe(
false,
);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe('SELECT id FROM "Product" WHERE id = ? FOR UPDATE');
});
});
describe('lockRowsForUpdate', () => {
it('locks every row with one parameterised FOR UPDATE in ascending id order', async () => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
const ids = ['c3', 'c1', 'c2'];
await lockRowsForUpdate(tx as never, 'Colony', ids);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
);
expect(query.values).toEqual([['c1', 'c2', 'c3']]);
expect(ids).toEqual(['c3', 'c1', 'c2']);
});
it('sends no statement for an empty list', async () => {
const tx = { $queryRaw: jest.fn() };
await lockRowsForUpdate(tx as never, 'Colony', []);
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('refuses the root client', async () => {
const root = { $connect: jest.fn(), $queryRaw: jest.fn() };
await expect(
lockRowsForUpdate(root as never, 'Colony', ['c1']),
).rejects.toThrow('lockRowsForUpdate needs the transaction client');
expect(root.$queryRaw).not.toHaveBeenCalled();
});
it('refuses a table outside the fixed map', async () => {
const tx = { $queryRaw: jest.fn() };
await expect(
lockRowsForUpdate(tx as never, 'User"; DROP TABLE x; --' as never, [
'c1',
]),
).rejects.toThrow('lockRowsForUpdate: table not lockable');
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { Prisma } from '../../generated/prisma';
import { assertTransactionClient } from './db-client';
// Fixed identifier map: a table name is never taken from input.
const LOCKABLE_TABLES = {
Colony: Prisma.raw('"Colony"'),
Task: Prisma.raw('"Task"'),
BottlingBatch: Prisma.raw('"BottlingBatch"'),
Feeding: Prisma.raw('"Feeding"'),
Harvest: Prisma.raw('"Harvest"'),
Inspection: Prisma.raw('"Inspection"'),
Product: Prisma.raw('"Product"'),
ProductionBatch: Prisma.raw('"ProductionBatch"'),
RawMaterialLot: Prisma.raw('"RawMaterialLot"'),
StockItem: Prisma.raw('"StockItem"'),
Treatment: Prisma.raw('"Treatment"'),
VarroaMeasurement: Prisma.raw('"VarroaMeasurement"'),
} as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES;
/**
* Locks one row (SELECT ... FOR UPDATE) until the transaction ends, so a
* read-modify-write on it is serialised with concurrent writers. Re-read the
* row after locking and use that copy as "before". Resolves to whether the
* row exists.
*/
export async function lockRowForUpdate(
tx: Prisma.TransactionClient,
table: LockableTable,
id: string,
): Promise<boolean> {
assertTransactionClient(tx, 'lockRowForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowForUpdate: table not lockable');
}
const locked = await tx.$queryRaw<{ id: string }[]>(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ${id} FOR UPDATE`,
);
return locked.length > 0;
}
/**
* Locks several rows (SELECT ... FOR UPDATE) until the transaction ends, with
* ONE statement in ascending id order: it meets lockRowsForShare on the same
* order, so two transactions locking overlapping sets cannot deadlock. Unknown
* ids lock nothing; re-read the rows after locking.
*/
export async function lockRowsForUpdate(
tx: Prisma.TransactionClient,
table: LockableTable,
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForUpdate: table not lockable');
}
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR UPDATE`,
);
}
/**
* Locks several rows (SELECT ... FOR SHARE) until the transaction ends: they
* cannot be changed or deleted meanwhile, other readers are not blocked. The
* rows are locked in ascending id order, so two transactions locking
* overlapping sets cannot deadlock. Unknown ids lock nothing; re-read the rows
* after locking.
*/
export async function lockRowsForShare(
tx: Prisma.TransactionClient,
table: LockableTable,
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForShare');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForShare: table not lockable');
}
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR SHARE`,
);
}
@@ -0,0 +1,299 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { ProductionBatchesService } from './production-batches.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { StockService } from '../stock/stock.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { AutomationService } from '../automation/automation.service';
/**
* Production batches announce their stock through StockService.recordChange
* in the transaction that writes them - with the article and the raw
* material kinds consumed or given back. The minimum-stock rule runs in the
* automation.raw-material-low handler, never from here.
*/
describe('ProductionBatchesService', () => {
let service: ProductionBatchesService;
let tx: {
$queryRaw: jest.Mock;
productionBatch: {
create: jest.Mock;
update: jest.Mock;
delete: jest.Mock;
findUnique: jest.Mock;
};
};
let prisma: {
product: { findUnique: jest.Mock };
productionBatch: { findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let stock: {
consumeForProduction: jest.Mock;
returnProductionInputs: jest.Mock;
recordChange: jest.Mock;
remainingByProductionBatch: jest.Mock;
hasMovements: jest.Mock;
assertInitialCovers: jest.Mock;
lock: jest.Mock;
};
let automation: {
generateNumber: jest.Mock;
};
let steps: string[];
const batch = {
id: 'batch-1',
productId: 'product-1',
lotNumber: 'HS-1',
quantity: 20,
inputs: [],
};
beforeEach(() => {
steps = [];
tx = {
$queryRaw: jest.fn(() => {
steps.push('lock batch');
return Promise.resolve([]);
}),
productionBatch: {
create: jest.fn().mockResolvedValue(batch),
update: jest.fn().mockResolvedValue(batch),
delete: jest.fn().mockResolvedValue(batch),
findUnique: jest.fn(() => {
steps.push('read');
return Promise.resolve(batch);
}),
},
};
prisma = {
product: {
findUnique: jest
.fn()
.mockResolvedValue({ id: 'product-1', kind: 'MANUFACTURED' }),
},
productionBatch: { findUnique: jest.fn().mockResolvedValue(batch) },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn() };
stock = {
consumeForProduction: jest
.fn()
.mockResolvedValue(['BEESWAX', 'PROPOLIS']),
returnProductionInputs: jest.fn().mockResolvedValue(['PROPOLIS']),
recordChange: jest.fn(),
remainingByProductionBatch: jest.fn().mockResolvedValue(new Map()),
hasMovements: jest.fn().mockResolvedValue(false),
assertInitialCovers: jest.fn(),
lock: jest.fn(() => {
steps.push('lock article');
return Promise.resolve();
}),
};
automation = {
generateNumber: jest.fn().mockResolvedValue(null),
};
const permissions = {
getPermissions: jest
.fn()
.mockResolvedValue(new Set(['raw-material:withdraw'])),
};
service = new ProductionBatchesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
stock as unknown as StockService,
permissions as unknown as PermissionResolverService,
automation as unknown as AutomationService,
);
});
it('create records StockChanged with the article and the consumed kinds in the batch transaction', async () => {
await service.create(
{
productId: 'product-1',
lotNumber: 'HS-1',
date: '2026-06-10',
quantity: 20,
inputs: [
{ rawMaterialLotId: 'wax', quantity: 300 },
{ rawMaterialLotId: 'propolis', quantity: 50 },
],
},
'user-1',
);
expect(stock.consumeForProduction).toHaveBeenCalledWith(
tx,
'batch-1',
'HS-1',
[
{ rawMaterialLotId: 'wax', quantity: 300 },
{ rawMaterialLotId: 'propolis', quantity: 50 },
],
'user-1',
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ProductionBatch',
entityId: 'batch-1',
action: 'CREATE',
}),
tx,
);
expect(stock.recordChange).toHaveBeenCalledTimes(1);
expect(stock.recordChange).toHaveBeenCalledWith(
tx,
{
bottlingBatchIds: [],
productIds: ['product-1'],
rawMaterialKinds: ['BEESWAX', 'PROPOLIS'],
},
'user-1',
{ type: 'ProductionBatch', id: 'batch-1' },
);
});
it('create without raw material records StockChanged for the article only', async () => {
await service.create(
{
productId: 'product-1',
lotNumber: 'HS-1',
date: '2026-06-10',
quantity: 20,
},
'user-1',
);
expect(stock.consumeForProduction).not.toHaveBeenCalled();
expect(stock.recordChange).toHaveBeenCalledWith(
tx,
{
bottlingBatchIds: [],
productIds: ['product-1'],
rawMaterialKinds: [],
},
'user-1',
{ type: 'ProductionBatch', id: 'batch-1' },
);
});
it('update locks article and batch, then reads "before" through the transaction', async () => {
// A read outside the transaction would see this stale copy.
prisma.productionBatch.findUnique.mockResolvedValue({
...batch,
lotNumber: 'STALE',
quantity: 99,
});
tx.productionBatch.findUnique
.mockImplementationOnce(() => {
steps.push('read');
return Promise.resolve(batch);
})
.mockImplementationOnce(() =>
Promise.resolve({ ...batch, quantity: 25 }),
);
const result = await service.update('batch-1', { quantity: 25 }, 'user-1');
// A shop sale locks the article before it books onto the batch - the
// same order here keeps the two from deadlocking.
expect(steps.slice(0, 3)).toEqual(['lock article', 'lock batch', 'read']);
expect(stock.lock).toHaveBeenCalledWith(tx, {
kind: 'production',
id: 'batch-1',
});
expect(prisma.productionBatch.findUnique).not.toHaveBeenCalled();
// Checked against the locked quantity (20), not the stale one (99).
expect(stock.assertInitialCovers).toHaveBeenCalledWith(
tx,
{ kind: 'production', id: 'batch-1' },
25,
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ProductionBatch',
action: 'UPDATE',
beforeData: { ...batch, remainingCount: 20 },
afterData: { ...batch, quantity: 25, remainingCount: 25 },
}),
tx,
);
expect(stock.recordChange).toHaveBeenCalledWith(
tx,
{ bottlingBatchIds: [], productIds: ['product-1'], rawMaterialKinds: [] },
'user-1',
{ type: 'ProductionBatch', id: 'batch-1' },
);
expect(result).toEqual({ ...batch, quantity: 25, remainingCount: 25 });
});
it('update skips the cover check when the quantity stays the same', async () => {
await service.update('batch-1', { quantity: 20, notes: 'x' }, 'user-1');
expect(stock.assertInitialCovers).not.toHaveBeenCalled();
});
it('update throws NotFoundException for an unknown batch', async () => {
stock.lock.mockRejectedValueOnce(
new NotFoundException('Production batch not found'),
);
await expect(
service.update('missing', { notes: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(stock.recordChange).not.toHaveBeenCalled();
});
it('remove gives the raw material back and records StockChanged with the returned kinds', async () => {
const result = await service.remove('batch-1', 'user-1');
expect(result).toEqual({ id: 'batch-1' });
expect(steps.slice(0, 3)).toEqual(['lock article', 'lock batch', 'read']);
expect(stock.hasMovements).toHaveBeenCalledWith(
{ kind: 'production', id: 'batch-1' },
tx,
);
expect(stock.returnProductionInputs).toHaveBeenCalledWith(
tx,
'batch-1',
'HS-1',
'user-1',
);
expect(tx.productionBatch.delete).toHaveBeenCalledWith({
where: { id: 'batch-1' },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ProductionBatch',
action: 'DELETE',
beforeData: { ...batch, remainingCount: 20 },
}),
tx,
);
expect(stock.recordChange).toHaveBeenCalledWith(
tx,
{
bottlingBatchIds: [],
productIds: ['product-1'],
rawMaterialKinds: ['PROPOLIS'],
},
'user-1',
{ type: 'ProductionBatch', id: 'batch-1' },
);
});
it('remove refuses a batch with stock movements and books nothing', async () => {
stock.hasMovements.mockResolvedValueOnce(true);
await expect(service.remove('batch-1', 'user-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(stock.returnProductionInputs).not.toHaveBeenCalled();
expect(tx.productionBatch.delete).not.toHaveBeenCalled();
expect(stock.recordChange).not.toHaveBeenCalled();
});
});
@@ -7,10 +7,12 @@ import {
} from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.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 { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
import { RawMaterialKind } from '../../generated/prisma';
import { Prisma, RawMaterialKind } from '../../generated/prisma';
import { optionalDate } from '../common/patch-values';
import { CreateProductionBatchDto } from './dto/create-production-batch.dto';
import { UpdateProductionBatchDto } from './dto/update-production-batch.dto';
@@ -31,11 +33,18 @@ export interface ProductionBatchFilter {
productId?: string;
}
type Tx = Prisma.TransactionClient;
/**
* Production batches of MANUFACTURED articles (candles, propolis tinctures,
* salves, ...) - the counterpart of a honey bottling. Each one carries its
* own stock in the movement ledger (StockService); later a production run
* will create them from raw-material batches.
*
* Every write announces its stock in its own transaction (StockChanged via
* StockService.recordChange): the article for the shop, the raw material
* kinds consumed or given back for the minimum-stock rule, which the
* automation.raw-material-low handler evaluates.
*/
@Injectable()
export class ProductionBatchesService {
@@ -62,13 +71,15 @@ export class ProductionBatchesService {
}));
}
async findById(id: string) {
const batch = await this.prisma.productionBatch.findUnique({
/** With `tx` it reads what the caller's transaction sees. */
async findById(id: string, tx?: Tx) {
const db: DbClient = tx ?? this.prisma;
const batch = await db.productionBatch.findUnique({
where: { id },
include,
});
if (!batch) throw new NotFoundException('Production batch not found');
const remaining = await this.stock.remainingByProductionBatch([id]);
const remaining = await this.stock.remainingByProductionBatch([id], tx);
return { ...batch, remainingCount: remaining.get(id) ?? batch.quantity };
}
@@ -118,42 +129,30 @@ export class ProductionBatchesService {
userId: actingUserId,
},
});
if (dto.inputs && dto.inputs.length > 0) {
await this.stock.consumeForProduction(
tx,
created.id,
created.lotNumber,
dto.inputs,
actingUserId,
);
}
const consumedKinds =
dto.inputs && dto.inputs.length > 0
? await this.stock.consumeForProduction(
tx,
created.id,
created.lotNumber,
dto.inputs,
actingUserId,
)
: [];
await this.auditLog.record(
{
entityType: 'ProductionBatch',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
await this.recordChange(tx, created, consumedKinds, actingUserId);
return created;
});
await this.auditLog.record({
entityType: 'ProductionBatch',
entityId: batch.id,
action: 'CREATE',
userId: actingUserId,
afterData: batch,
});
await this.stock.notify({
bottlingBatchIds: [],
productIds: [batch.productId],
});
const created = await this.findById(batch.id);
await this.checkRawMaterialLow(created.inputs);
return created;
}
/** Runs the RAW_MATERIAL_LOW rule for every kind this batch consumed. */
private async checkRawMaterialLow(
inputs: { rawMaterialLot: { kind: RawMaterialKind } }[],
): Promise<void> {
const kinds = new Set(inputs.map((input) => input.rawMaterialLot.kind));
for (const kind of kinds) {
const remaining = await this.stock.remainingByRawMaterialKind(kind);
await this.automation.evaluateRawMaterialLow(kind, remaining);
}
return this.findById(batch.id);
}
async update(
@@ -161,8 +160,8 @@ export class ProductionBatchesService {
dto: UpdateProductionBatchDto,
actingUserId: string,
) {
const before = await this.findById(id);
await this.prisma.$transaction(async (tx) => {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockBatch(tx, id);
if (dto.quantity !== undefined && dto.quantity !== before.quantity) {
await this.stock.assertInitialCovers(
tx,
@@ -180,21 +179,23 @@ export class ProductionBatchesService {
notes: dto.notes,
},
});
const after = await this.findById(id, tx);
await this.auditLog.record(
{
entityType: 'ProductionBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: after,
},
tx,
);
// A corrected quantity changes the article's stock; no raw material
// moves (inputs are fixed once the batch exists).
await this.recordChange(tx, after, [], actingUserId);
return after;
});
const after = await this.findById(id);
await this.auditLog.record({
entityType: 'ProductionBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: after,
});
await this.stock.notify({
bottlingBatchIds: [],
productIds: [after.productId],
});
return after;
}
/**
@@ -203,34 +204,61 @@ export class ProductionBatchesService {
* an inventory correction.
*/
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
if (await this.stock.hasMovements({ kind: 'production', id })) {
throw new ConflictException(
'Charge hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
// The raw material goes back to its lots as a movement of its own; the
// input rows go with the batch (cascade).
await this.prisma.$transaction(async (tx) => {
await this.stock.returnProductionInputs(
const before = await this.lockBatch(tx, id);
if (await this.stock.hasMovements({ kind: 'production', id }, tx)) {
throw new ConflictException(
'Charge hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
// The raw material goes back to its lots as a movement of its own;
// the input rows go with the batch (cascade).
const returnedKinds = await this.stock.returnProductionInputs(
tx,
id,
before.lotNumber,
actingUserId,
);
await tx.productionBatch.delete({ where: { id } });
});
await this.auditLog.record({
entityType: 'ProductionBatch',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
});
await this.stock.notify({
bottlingBatchIds: [],
productIds: [before.productId],
await this.auditLog.record(
{
entityType: 'ProductionBatch',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
},
tx,
);
await this.recordChange(tx, before, returnedKinds, actingUserId);
});
return { id };
}
/**
* Locks the batch and re-reads it in the transaction - use the result,
* not an earlier read, as "before". The article is locked first, as
* every booking on it does: a shop sale holds the article while its
* movement key-share locks the batch, so the other order could deadlock.
*/
private async lockBatch(tx: Tx, id: string) {
await this.stock.lock(tx, { kind: 'production', id });
await lockRowForUpdate(tx, 'ProductionBatch', id);
return this.findById(id, tx);
}
/** StockChanged for the batch's article and the raw material it moved. */
private async recordChange(
tx: Tx,
batch: { id: string; productId: string },
rawMaterialKinds: RawMaterialKind[],
actingUserId: string,
): Promise<void> {
await this.stock.recordChange(
tx,
{ bottlingBatchIds: [], productIds: [batch.productId], rawMaterialKinds },
actingUserId,
{ type: 'ProductionBatch', id: batch.id },
);
}
}
@@ -0,0 +1,205 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { RawMaterialsService } from './raw-materials.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { StockService } from '../stock/stock.service';
import { AutomationService } from '../automation/automation.service';
/**
* Raw material lots: withdrawals and inventory counts go through the stock
* ledger, which records StockChanged with the lot's kind - the minimum-stock
* rule is evaluated by the automation.raw-material-low handler, not here.
* Corrections and removals decide on the row locked in their transaction.
*/
describe('RawMaterialsService', () => {
let service: RawMaterialsService;
let tx: {
$queryRaw: jest.Mock;
rawMaterialLot: {
findUnique: jest.Mock;
update: jest.Mock;
delete: jest.Mock;
};
};
let prisma: {
rawMaterialLot: { findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let stock: {
withdraw: jest.Mock;
correct: jest.Mock;
remainingByRawMaterialLot: jest.Mock;
remainingByRawMaterialKind: jest.Mock;
hasMovements: jest.Mock;
assertInitialCovers: jest.Mock;
};
let automation: {
generateNumber: jest.Mock;
};
let steps: string[];
const lot = {
id: 'lot-1',
kind: 'BEESWAX',
lotNumber: 'WA-1',
quantity: 1000,
usedIn: [],
};
const bookedStock = {
summary: { initial: 1000, remaining: 900 },
movements: [],
};
beforeEach(() => {
steps = [];
tx = {
$queryRaw: jest.fn(() => {
steps.push('lock');
return Promise.resolve([]);
}),
rawMaterialLot: {
findUnique: jest.fn(() => {
steps.push('read');
return Promise.resolve(lot);
}),
update: jest.fn().mockResolvedValue(lot),
delete: jest.fn().mockResolvedValue(lot),
},
};
prisma = {
rawMaterialLot: { findUnique: jest.fn().mockResolvedValue(lot) },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn() };
stock = {
withdraw: jest.fn().mockResolvedValue(bookedStock),
correct: jest.fn().mockResolvedValue(bookedStock),
remainingByRawMaterialLot: jest.fn().mockResolvedValue(new Map()),
remainingByRawMaterialKind: jest.fn().mockResolvedValue(900),
hasMovements: jest.fn().mockResolvedValue(false),
assertInitialCovers: jest.fn(),
};
automation = {
generateNumber: jest.fn().mockResolvedValue(null),
};
service = new RawMaterialsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
stock as unknown as StockService,
automation as unknown as AutomationService,
);
});
it('withdraw books through the stock ledger and leaves the low-stock rule to its handler', async () => {
const result = await service.withdraw(
'lot-1',
{ quantity: 100, reason: 'SONSTIGES' },
'user-1',
);
expect(result).toBe(bookedStock);
expect(stock.withdraw).toHaveBeenCalledWith(
{ kind: 'raw', id: 'lot-1' },
{ quantity: 100, reason: 'SONSTIGES' },
'user-1',
);
expect(stock.remainingByRawMaterialKind).not.toHaveBeenCalled();
});
it('correct books through the stock ledger and leaves the low-stock rule to its handler', async () => {
const result = await service.correct(
'lot-1',
{ countedQuantity: 900, notes: 'gewogen' },
'user-1',
);
expect(result).toBe(bookedStock);
expect(stock.correct).toHaveBeenCalledWith(
{ kind: 'raw', id: 'lot-1' },
{ countedQuantity: 900, notes: 'gewogen' },
'user-1',
);
});
it('update takes "before" from the row locked in its transaction and audits there', async () => {
// A read outside the transaction would see this stale copy.
prisma.rawMaterialLot.findUnique.mockResolvedValue({
...lot,
quantity: 5,
lotNumber: 'STALE',
});
tx.rawMaterialLot.findUnique
.mockImplementationOnce(() => {
steps.push('read');
return Promise.resolve(lot);
})
.mockImplementationOnce(() => Promise.resolve({ ...lot, quantity: 950 }));
const result = await service.update('lot-1', { quantity: 950 }, 'user-1');
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
expect(prisma.rawMaterialLot.findUnique).not.toHaveBeenCalled();
expect(stock.assertInitialCovers).toHaveBeenCalledWith(
tx,
{ kind: 'raw', id: 'lot-1' },
950,
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'RawMaterialLot',
action: 'UPDATE',
beforeData: { ...lot, remainingCount: 1000 },
afterData: { ...lot, quantity: 950, remainingCount: 950 },
}),
tx,
);
expect(result).toEqual({ ...lot, quantity: 950, remainingCount: 950 });
});
it('update refuses a quantity below what was already booked and audits nothing', async () => {
stock.assertInitialCovers.mockRejectedValueOnce(
new BadRequestException('zu wenig'),
);
await expect(
service.update('lot-1', { quantity: 50 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.rawMaterialLot.update).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('remove checks movements under the row lock and audits in its transaction', async () => {
const result = await service.remove('lot-1', 'user-1');
expect(result).toEqual({ id: 'lot-1' });
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
expect(stock.hasMovements).toHaveBeenCalledWith(
{ kind: 'raw', id: 'lot-1' },
tx,
);
expect(tx.rawMaterialLot.delete).toHaveBeenCalledWith({
where: { id: 'lot-1' },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'RawMaterialLot',
action: 'DELETE',
beforeData: { ...lot, remainingCount: 1000 },
}),
tx,
);
});
it('remove keeps a lot with movements as history', async () => {
stock.hasMovements.mockResolvedValueOnce(true);
await expect(service.remove('lot-1', 'user-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tx.rawMaterialLot.delete).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
});
@@ -5,13 +5,15 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import {
AutomationService,
RAW_MATERIAL_KIND_CODES,
} from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
import { RawMaterialKind } from '../../generated/prisma';
import { Prisma, RawMaterialKind } from '../../generated/prisma';
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
import { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto';
@@ -41,10 +43,14 @@ export interface RawMaterialLotFilter {
kind?: RawMaterialKind;
}
type Tx = Prisma.TransactionClient;
/**
* Raw material lots (beeswax, propolis) in grams. Their stock lives in the
* same movement ledger as bottlings and production batches: withdrawals and
* inventory counts by hand, consumption when a production batch is made.
* The ledger records StockChanged with the lot's kind; the minimum-stock
* rule runs in the automation.raw-material-low handler.
*/
@Injectable()
export class RawMaterialsService {
@@ -70,13 +76,15 @@ export class RawMaterialsService {
}));
}
async findById(id: string) {
const lot = await this.prisma.rawMaterialLot.findUnique({
/** With `tx` it reads what the caller's transaction sees. */
async findById(id: string, tx?: Tx) {
const db: DbClient = tx ?? this.prisma;
const lot = await db.rawMaterialLot.findUnique({
where: { id },
include,
});
if (!lot) throw new NotFoundException('Raw material lot not found');
const remaining = await this.stock.remainingByRawMaterialLot([id]);
const remaining = await this.stock.remainingByRawMaterialLot([id], tx);
return { ...lot, remainingCount: remaining.get(id) ?? lot.quantity };
}
@@ -117,8 +125,8 @@ export class RawMaterialsService {
}
async update(id: string, dto: UpdateRawMaterialLotDto, actingUserId: string) {
const before = await this.findById(id);
await this.prisma.$transaction(async (tx) => {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockLot(tx, id);
if (dto.quantity !== undefined && dto.quantity !== before.quantity) {
await this.stock.assertInitialCovers(
tx,
@@ -137,63 +145,60 @@ export class RawMaterialsService {
notes: dto.notes,
},
});
const after = await this.findById(id, tx);
await this.auditLog.record(
{
entityType: 'RawMaterialLot',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: after,
},
tx,
);
return after;
});
const after = await this.findById(id);
await this.auditLog.record({
entityType: 'RawMaterialLot',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: after,
});
return after;
}
/** The ledger locks the lot (404 if unknown) and records StockChanged. */
async withdraw(id: string, dto: WithdrawStockDto, actingUserId: string) {
const lot = await this.findById(id);
const stock = await this.stock.withdraw(
{ kind: 'raw', id },
dto,
actingUserId,
);
await this.checkLowStock(lot.kind);
return stock;
return this.stock.withdraw({ kind: 'raw', id }, dto, actingUserId);
}
async correct(id: string, dto: CorrectStockDto, actingUserId: string) {
const lot = await this.findById(id);
const stock = await this.stock.correct(
{ kind: 'raw', id },
dto,
actingUserId,
);
await this.checkLowStock(lot.kind);
return stock;
}
/** Runs the RAW_MATERIAL_LOW rule against what is left of the kind. */
private async checkLowStock(kind: RawMaterialKind): Promise<void> {
const remaining = await this.stock.remainingByRawMaterialKind(kind);
await this.automation.evaluateRawMaterialLow(kind, remaining);
return this.stock.correct({ kind: 'raw', id }, dto, actingUserId);
}
/** Only for lots entered by mistake: once anything was booked, it stays. */
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
if (await this.stock.hasMovements({ kind: 'raw', id })) {
throw new ConflictException(
'Rohstoff-Charge hat bereits Bewegungen (Verbrauch, Entnahme oder Inventur) und kann nicht gelöscht werden',
await this.prisma.$transaction(async (tx) => {
// Consumption and withdrawals lock the same row, so the movement
// check cannot be raced.
const before = await this.lockLot(tx, id);
if (await this.stock.hasMovements({ kind: 'raw', id }, tx)) {
throw new ConflictException(
'Rohstoff-Charge hat bereits Bewegungen (Verbrauch, Entnahme oder Inventur) und kann nicht gelöscht werden',
);
}
await tx.rawMaterialLot.delete({ where: { id } });
await this.auditLog.record(
{
entityType: 'RawMaterialLot',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
},
tx,
);
}
await this.prisma.rawMaterialLot.delete({ where: { id } });
await this.auditLog.record({
entityType: 'RawMaterialLot',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: before,
});
return { id };
}
/** Locks the lot and re-reads it - use the result, not an earlier read, as "before". */
private async lockLot(tx: Tx, id: string) {
await lockRowForUpdate(tx, 'RawMaterialLot', id);
return this.findById(id, tx);
}
}
@@ -0,0 +1,54 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
// Spec section 9 guard: recurring work runs as Kubernetes CronJobs through
// dist/cli.js, never as a timer inside an api or worker process.
const SRC = join(__dirname, '..');
const PACKAGE_JSON = join(__dirname, '..', '..', 'package.json');
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 offenders(pattern: RegExp): string[] {
return productionSources(SRC)
.filter((file) => pattern.test(withoutComments(readFileSync(file, 'utf8'))))
.map((file) => relative(SRC, file).split(sep).join('/'))
.sort();
}
describe('no in-process schedulers', () => {
it('has no @Cron/@Interval/@Timeout, SchedulerRegistry, ScheduleModule or setInterval in src', () => {
expect(
offenders(
/@(Cron|Interval|Timeout)\(|SchedulerRegistry|ScheduleModule|setInterval\(|@nestjs\/schedule/,
),
).toEqual([]);
});
it('no longer depends on @nestjs/schedule', () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')) as {
dependencies: Record<string, string>;
};
expect(pkg.dependencies['@nestjs/schedule']).toBeUndefined();
});
it('depends on cron directly, for the cron-expression parsing in the backup schedule', () => {
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')) as {
dependencies: Record<string, string>;
};
expect(pkg.dependencies['cron']).toBeDefined();
});
});
@@ -1,8 +1,10 @@
import { forwardRef, Module } from '@nestjs/common';
import { forwardRef, Module, Type } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import { AllInOneModule } from '../all-in-one.module';
import { AppModule } from '../app.module';
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
import { DomainEventRegistry } from '../domain-events/domain-event-registry';
import { WorkerModule } from '../worker/worker.module';
import { WorkerRuntimeModule } from '../worker/worker-runtime.module';
import { isHttpRole, rootModuleFor } from './root-module';
@@ -10,6 +12,24 @@ import { isHttpRole, rootModuleFor } from './root-module';
const importsOf = (module: object): unknown[] =>
(Reflect.getMetadata('imports', module) as unknown[] | undefined) ?? [];
/**
* The handler ids the DomainEventRegistry discovers under `root`. Compiled
* only - no init, so nothing connects or starts a loop; the registry's
* boot scan runs by hand on the constructed providers.
*/
async function discoveredHandlerIds(root: Type<unknown>): Promise<string[]> {
const moduleRef = await Test.createTestingModule({
imports: [root],
}).compile();
try {
const registry = moduleRef.get(DomainEventRegistry);
registry.onModuleInit();
return registry.handlerIds();
} finally {
await moduleRef.close();
}
}
/**
* Every module class reachable from `root` through imports - including
* the imports of dynamic modules (also async ones like
@@ -76,6 +96,16 @@ describe('rootModuleFor', () => {
expect(reachable).toContain(WorkerRuntimeModule);
expect(reachable).not.toContain(AppModule);
});
// Only the worker dispatches events. A handler in a module the worker
// does not import would be registered by the API and never run.
it('the worker discovers exactly the domain event handlers of the API', async () => {
const api = await discoveredHandlerIds(AppModule);
const worker = await discoveredHandlerIds(WorkerModule);
expect(api).toContain('webhooks.forward');
expect(worker).toEqual(api);
}, 30_000);
});
describe('reachableModules', () => {
@@ -0,0 +1,517 @@
import { BadRequestException } from '@nestjs/common';
import { StockService, type ShopStockRequestInput } from './stock.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
interface StoredRequest {
id: string;
externalId: string;
kind: 'SALE' | 'RETURN';
bottlingBatchId: string | null;
productId: string | null;
movements: {
bottlingBatchId: string | null;
productionBatchId: string | null;
quantity: number;
bottlingBatch: { lotNumber: string } | null;
productionBatch: { lotNumber: string } | null;
}[];
}
/**
* The movement ledger books and announces in one transaction: the movement
* (or shop booking), its audit entry and StockChanged commit together or not
* at all. Prisma is replaced by plain mocks; `tx` is what $transaction hands
* the callback, so "in the transaction" means "written through tx".
*/
describe('StockService', () => {
let service: StockService;
let tx: {
$queryRaw: jest.Mock;
batchMovement: {
create: jest.Mock;
createMany: jest.Mock;
groupBy: jest.Mock;
};
bottlingBatch: { findUnique: jest.Mock };
productionBatch: { findUnique: jest.Mock; findUniqueOrThrow: jest.Mock };
rawMaterialLot: { findUnique: jest.Mock; findUniqueOrThrow: jest.Mock };
productionInput: { create: jest.Mock; findMany: jest.Mock };
shopStockRequest: { create: jest.Mock };
};
let prisma: {
bottlingBatch: { findUnique: jest.Mock; findMany: jest.Mock };
productionBatch: { findUnique: jest.Mock };
rawMaterialLot: { findUnique: jest.Mock };
batchMovement: { findMany: jest.Mock; groupBy: jest.Mock };
shopStockRequest: { findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
let storedRequests: Map<string, StoredRequest>;
const lots: Record<string, { quantity: number; kind: string }> = {
'lot-a': { quantity: 500, kind: 'BEESWAX' },
'lot-b': { quantity: 200, kind: 'PROPOLIS' },
};
beforeEach(() => {
storedRequests = new Map();
tx = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'locked' }]),
batchMovement: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'movement-1', ...data }),
),
createMany: jest.fn().mockResolvedValue({ count: 1 }),
groupBy: jest.fn().mockResolvedValue([]),
},
bottlingBatch: {
findUnique: jest.fn(({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id.startsWith('bottling')
? { id: where.id, jarCount: 10 }
: null,
),
),
},
productionBatch: {
findUnique: jest
.fn()
.mockResolvedValue({ productId: 'product-1', quantity: 5 }),
findUniqueOrThrow: jest
.fn()
.mockResolvedValue({ productId: 'product-1', quantity: 5 }),
},
rawMaterialLot: {
findUnique: jest.fn(({ where }: { where: { id: string } }) =>
Promise.resolve(lots[where.id] ?? null),
),
findUniqueOrThrow: jest.fn(({ where }: { where: { id: string } }) =>
Promise.resolve({ lotNumber: `${where.id}-nr`, ...lots[where.id] }),
),
},
productionInput: {
create: jest.fn().mockResolvedValue({}),
findMany: jest.fn().mockResolvedValue([]),
},
shopStockRequest: {
create: jest.fn(
({ data }: { data: Omit<StoredRequest, 'id' | 'movements'> }) => {
const stored: StoredRequest = {
id: `request-${data.externalId}`,
externalId: data.externalId,
kind: data.kind,
bottlingBatchId: data.bottlingBatchId ?? null,
productId: data.productId ?? null,
movements: [],
};
storedRequests.set(data.externalId, stored);
return Promise.resolve(stored);
},
),
},
};
prisma = {
bottlingBatch: {
findUnique: jest.fn().mockResolvedValue({ jarCount: 10 }),
findMany: jest.fn(({ where }: { where: { id: { in: string[] } } }) =>
Promise.resolve(where.id.in.map((id) => ({ id, jarCount: 10 }))),
),
},
productionBatch: {
findUnique: jest.fn().mockResolvedValue({ quantity: 5 }),
},
rawMaterialLot: {
findUnique: jest.fn().mockResolvedValue({ quantity: 500 }),
},
batchMovement: {
findMany: jest.fn().mockResolvedValue([]),
groupBy: jest.fn().mockResolvedValue([]),
},
shopStockRequest: {
findUnique: jest.fn(({ where }: { where: { externalId: string } }) =>
Promise.resolve(storedRequests.get(where.externalId) ?? null),
),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn() };
events = { record: jest.fn() };
service = new StockService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
);
});
describe('withdraw and correct', () => {
it('withdraw books the movement, the audit entry and StockChanged in one transaction', async () => {
await service.withdraw(
{ kind: 'bottling', id: 'bottling-1' },
{ quantity: 2, reason: 'HOFLADEN' },
'user-1',
);
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
bottlingBatchId: 'bottling-1',
type: 'WITHDRAWAL',
quantity: -2,
userId: 'user-1',
}) as unknown,
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BatchMovement',
entityId: 'movement-1',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'StockChanged',
aggregateType: 'BatchMovement',
aggregateId: 'movement-1',
payload: {
bottlingBatchIds: ['bottling-1'],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: 'user-1',
});
});
it('a refused withdrawal books nothing and records no event', async () => {
await expect(
service.withdraw(
{ kind: 'bottling', id: 'bottling-1' },
{ quantity: 11, reason: 'BRUCH' },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.batchMovement.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('a raw material withdrawal records StockChanged with the lot kind and no shop ids', async () => {
await service.withdraw(
{ kind: 'raw', id: 'lot-a' },
{ quantity: 100, reason: 'SONSTIGES' },
'user-1',
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'StockChanged',
aggregateType: 'BatchMovement',
payload: {
bottlingBatchIds: [],
productIds: [],
rawMaterialKinds: ['BEESWAX'],
},
}),
);
});
it('an inventory correction of a production batch records StockChanged for its article', async () => {
await service.correct(
{ kind: 'production', id: 'batch-1' },
{ countedQuantity: 3, notes: 'gezählt' },
'user-1',
);
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
productionBatchId: 'batch-1',
type: 'CORRECTION',
quantity: -2,
}) as unknown,
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'BatchMovement' }),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'StockChanged',
aggregateType: 'BatchMovement',
aggregateId: 'movement-1',
payload: {
bottlingBatchIds: [],
productIds: ['product-1'],
rawMaterialKinds: [],
},
actorUserId: 'user-1',
});
});
});
describe('shop bookings', () => {
function sale(
externalId: string,
target: Partial<ShopStockRequestInput>,
): ShopStockRequestInput {
return { externalId, kind: 'SALE', quantity: 1, ...target };
}
it('a shop request records one StockChanged per accepted booking', async () => {
const results = await service.applyShopRequests(
[
sale('s-1', { bottlingBatchId: 'bottling-1' }),
// Rejected: names both a bottling and an article.
sale('s-2', { bottlingBatchId: 'bottling-1', productId: 'p-1' }),
// Rejected: unknown bottling.
sale('s-3', { bottlingBatchId: 'unknown' }),
sale('s-4', { bottlingBatchId: 'bottling-2' }),
],
'api-key-1',
);
expect(results.map((r) => r.status)).toEqual([
'created',
'rejected',
'rejected',
'created',
]);
expect(events.record).toHaveBeenCalledTimes(2);
expect(events.record).toHaveBeenNthCalledWith(1, tx, {
type: 'StockChanged',
aggregateType: 'ShopStockRequest',
aggregateId: 'request-s-1',
payload: {
bottlingBatchIds: ['bottling-1'],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: null,
});
expect(events.record).toHaveBeenNthCalledWith(2, tx, {
type: 'StockChanged',
aggregateType: 'ShopStockRequest',
aggregateId: 'request-s-4',
payload: {
bottlingBatchIds: ['bottling-2'],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: null,
});
});
it('a repeated shop request books nothing and records no event', async () => {
storedRequests.set('s-1', {
id: 'request-s-1',
externalId: 's-1',
kind: 'SALE',
bottlingBatchId: 'bottling-1',
productId: null,
movements: [],
});
const [result] = await service.applyShopRequests(
[sale('s-1', { bottlingBatchId: 'bottling-1' })],
'api-key-1',
);
expect(result.status).toBe('duplicate');
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
});
describe('stockChangedEvent', () => {
it('builds the event once per id and kind, and nothing for an empty change', () => {
expect(
service.stockChangedEvent(
{ bottlingBatchIds: [], productIds: [], rawMaterialKinds: [] },
'user-1',
{ type: 'BottlingBatch', id: 'b-1' },
),
).toBeNull();
expect(
service.stockChangedEvent(
{
bottlingBatchIds: ['b-1', 'b-1'],
productIds: [],
rawMaterialKinds: [],
},
'user-1',
{ type: 'BottlingBatch', id: 'b-1' },
),
).toEqual({
type: 'StockChanged',
aggregateType: 'BottlingBatch',
aggregateId: 'b-1',
payload: {
bottlingBatchIds: ['b-1'],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: 'user-1',
});
// A builder only: nothing is written.
expect(events.record).not.toHaveBeenCalled();
});
});
describe('recordChange', () => {
it('records nothing for an empty change', async () => {
await service.recordChange(
tx as never,
{ bottlingBatchIds: [], productIds: [], rawMaterialKinds: [] },
'user-1',
{ type: 'ProductionBatch', id: 'batch-1' },
);
expect(events.record).not.toHaveBeenCalled();
});
it('records each id and kind once', async () => {
await service.recordChange(
tx as never,
{
bottlingBatchIds: [],
productIds: ['p-1', 'p-1'],
rawMaterialKinds: ['BEESWAX', 'PROPOLIS', 'BEESWAX'],
},
null,
{ type: 'ProductionBatch', id: 'batch-1' },
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'StockChanged',
aggregateType: 'ProductionBatch',
aggregateId: 'batch-1',
payload: {
bottlingBatchIds: [],
productIds: ['p-1'],
rawMaterialKinds: ['BEESWAX', 'PROPOLIS'],
},
actorUserId: null,
});
});
it('refuses the root client, so the event cannot commit on its own', async () => {
await expect(
service.recordChange(
// Only the root client has $connect.
{ ...prisma, $connect: jest.fn() } as never,
{ bottlingBatchIds: ['b-1'], productIds: [], rawMaterialKinds: [] },
null,
{ type: 'BottlingBatch', id: 'b-1' },
),
).rejects.toThrow(
'StockService.recordChange needs the transaction client',
);
});
});
describe('production inputs', () => {
it('consumeForProduction books every lot and returns the kinds it drew on', async () => {
const kinds = await service.consumeForProduction(
tx as never,
'batch-1',
'HS-1',
[
{ rawMaterialLotId: 'lot-b', quantity: 50 },
{ rawMaterialLotId: 'lot-a', quantity: 100 },
{ rawMaterialLotId: 'lot-a', quantity: 25 },
],
'user-1',
);
expect([...kinds].sort()).toEqual(['BEESWAX', 'PROPOLIS']);
expect(tx.productionInput.create).toHaveBeenCalledTimes(2);
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
rawMaterialLotId: 'lot-a',
type: 'CONSUMPTION',
quantity: -125,
}) as unknown,
});
});
it('consumeForProduction refuses more than a lot holds', async () => {
await expect(
service.consumeForProduction(
tx as never,
'batch-1',
'HS-1',
[{ rawMaterialLotId: 'lot-b', quantity: 201 }],
'user-1',
),
).rejects.toThrow('Rohstoff lot-b-nr: nur noch 200 g vorhanden, 201 g');
});
it('returnProductionInputs gives every input back and returns their kinds', async () => {
tx.productionInput.findMany.mockResolvedValueOnce([
{
rawMaterialLotId: 'lot-a',
quantity: 100,
rawMaterialLot: { kind: 'BEESWAX' },
},
{
rawMaterialLotId: 'lot-c',
quantity: 30,
rawMaterialLot: { kind: 'BEESWAX' },
},
{
rawMaterialLotId: 'lot-b',
quantity: 50,
rawMaterialLot: { kind: 'PROPOLIS' },
},
]);
const kinds = await service.returnProductionInputs(
tx as never,
'batch-1',
'HS-1',
'user-1',
);
expect([...kinds].sort()).toEqual(['BEESWAX', 'PROPOLIS']);
expect(tx.batchMovement.create).toHaveBeenCalledTimes(3);
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
rawMaterialLotId: 'lot-a',
type: 'CONSUMPTION',
quantity: 100,
notes: 'Rückgabe: Charge HS-1 gelöscht',
}) as unknown,
});
});
});
describe('lock', () => {
const lockedSql = () =>
(tx.$queryRaw.mock.calls as [[{ sql: string }]]).map(
([query]) => query.sql,
);
it('locks the bottling, the raw lot and (for a batch) its Product through lockRowForUpdate', async () => {
await service.lock(tx as never, { kind: 'bottling', id: 'bottling-1' });
await service.lock(tx as never, { kind: 'raw', id: 'lot-a' });
await service.lock(tx as never, { kind: 'production', id: 'batch-1' });
expect(lockedSql()).toEqual([
'SELECT id FROM "BottlingBatch" WHERE id = ? FOR UPDATE',
'SELECT id FROM "RawMaterialLot" WHERE id = ? FOR UPDATE',
'SELECT id FROM "Product" WHERE id = ? FOR UPDATE',
]);
});
it('throws NotFound when the raw lot is gone', async () => {
tx.$queryRaw.mockResolvedValue([]);
await expect(
service.lock(tx as never, { kind: 'raw', id: 'gone' }),
).rejects.toThrow('Raw material lot not found');
});
});
});
+183 -77
View File
@@ -5,7 +5,10 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { NewDomainEvent } from '../domain-events/domain-event-types';
import { assertTransactionClient, DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import {
Prisma,
RawMaterialKind,
@@ -35,10 +38,22 @@ export interface ProductionInputRequest {
quantity: number;
}
/** What changed - the payload of the stock.changed webhook. */
/**
* What changed - the payload of StockChanged. The shop hears about
* bottlings and articles (stock.changed webhook); the raw material kinds
* feed the minimum-stock rule (automation.raw-material-low).
*/
export interface StockChange {
bottlingBatchIds: string[];
productIds: string[];
rawMaterialKinds: RawMaterialKind[];
}
/** The record a StockChanged event hangs on. */
export interface StockChangeAggregate {
type:
'BatchMovement' | 'BottlingBatch' | 'ProductionBatch' | 'ShopStockRequest';
id: string;
}
const movementSelect = {
@@ -115,30 +130,39 @@ export class StockService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly events: DomainEventsService,
) {}
// -------------------------------------------------------------------------
// Reading
// -------------------------------------------------------------------------
/** Remaining stock per bottling; all bottlings when `ids` is omitted. */
async remainingByBottling(ids?: string[]): Promise<Map<string, number>> {
return this.remainingFor('bottling', ids);
/**
* Remaining stock per bottling; all bottlings when `ids` is omitted. With
* `tx` it reads what the caller's transaction sees (e.g. a corrected jar
* count before commit).
*/
async remainingByBottling(
ids?: string[],
tx?: Tx,
): Promise<Map<string, number>> {
return this.remainingFor('bottling', ids, tx);
}
/** Remaining stock per production batch; all when `ids` is omitted. */
async remainingByProductionBatch(
ids?: string[],
tx?: Tx,
): Promise<Map<string, number>> {
return this.remainingFor('production', ids);
return this.remainingFor('production', ids, tx);
}
/** Remaining grams per raw material lot; all when `ids` is omitted. */
async remainingByRawMaterialLot(
ids?: string[],
tx?: Tx,
): Promise<Map<string, number>> {
return this.remainingFor('raw', ids);
return this.remainingFor('raw', ids, tx);
}
/** Remaining grams of one raw material kind, summed over all its lots. */
@@ -189,8 +213,10 @@ export class StockService {
return { summary: summarizeStock(initial, movements), movements };
}
async hasMovements(target: StockTarget): Promise<boolean> {
const count = await this.prisma.batchMovement.count({
/** With `tx` (under the batch's lock) a concurrent booking cannot slip by. */
async hasMovements(target: StockTarget, tx?: Tx): Promise<boolean> {
const db: DbClient = tx ?? this.prisma;
const count = await db.batchMovement.count({
where: this.movementWhere(target),
});
return count > 0;
@@ -210,7 +236,7 @@ export class StockService {
input: { quantity: number; reason: WithdrawalReason; notes?: string },
actingUserId: string,
): Promise<BatchStock> {
const movement = await this.prisma.$transaction(async (tx) => {
await this.prisma.$transaction(async (tx) => {
await this.lock(tx, target);
const remaining = await this.remainingInTx(tx, target);
if (input.quantity > remaining) {
@@ -218,7 +244,7 @@ export class StockService {
`Entnahme übersteigt den Rest (${remaining})`,
);
}
return tx.batchMovement.create({
const movement = await tx.batchMovement.create({
data: {
...this.movementTarget(target),
type: 'WITHDRAWAL',
@@ -228,8 +254,8 @@ export class StockService {
userId: actingUserId,
},
});
await this.recordMovement(tx, target, movement, actingUserId);
});
await this.recordAndNotify(target, movement, actingUserId);
return this.stockOf(target);
}
@@ -242,7 +268,7 @@ export class StockService {
input: { countedQuantity: number; notes: string },
actingUserId: string,
): Promise<BatchStock> {
const movement = await this.prisma.$transaction(async (tx) => {
await this.prisma.$transaction(async (tx) => {
await this.lock(tx, target);
const remaining = await this.remainingInTx(tx, target);
const difference = input.countedQuantity - remaining;
@@ -251,7 +277,7 @@ export class StockService {
'Gezählter Bestand entspricht dem gebuchten Rest',
);
}
return tx.batchMovement.create({
const movement = await tx.batchMovement.create({
data: {
...this.movementTarget(target),
type: 'CORRECTION',
@@ -260,8 +286,8 @@ export class StockService {
userId: actingUserId,
},
});
await this.recordMovement(tx, target, movement, actingUserId);
});
await this.recordAndNotify(target, movement, actingUserId);
return this.stockOf(target);
}
@@ -274,7 +300,8 @@ export class StockService {
* caller's transaction, so the batch and its consumption exist together
* or not at all. Never more than a lot's remaining stock: the person
* producing can weigh again. Lots are locked in id order, so two
* productions drawing on the same lots cannot deadlock.
* productions drawing on the same lots cannot deadlock. Returns the raw
* material kinds drawn on - for the caller's StockChanged.
*/
async consumeForProduction(
tx: Tx,
@@ -282,7 +309,8 @@ export class StockService {
lotNumber: string,
inputs: ProductionInputRequest[],
actingUserId: string,
): Promise<void> {
): Promise<RawMaterialKind[]> {
const kinds = new Set<RawMaterialKind>();
const perLot = new Map<string, number>();
for (const input of inputs) {
perLot.set(
@@ -295,16 +323,18 @@ export class StockService {
)) {
const target: StockTarget = { kind: 'raw', id: rawMaterialLotId };
await this.lock(tx, target);
// The lock throws for an unknown lot, so the row is there.
const lot = await tx.rawMaterialLot.findUniqueOrThrow({
where: { id: rawMaterialLotId },
select: { lotNumber: true, kind: true },
});
const remaining = await this.remainingInTx(tx, target);
if (quantity > remaining) {
const lot = await tx.rawMaterialLot.findUnique({
where: { id: rawMaterialLotId },
select: { lotNumber: true },
});
throw new BadRequestException(
`Rohstoff ${lot?.lotNumber ?? rawMaterialLotId}: nur noch ${remaining} g vorhanden, ${quantity} g angegeben`,
`Rohstoff ${lot.lotNumber}: nur noch ${remaining} g vorhanden, ${quantity} g angegeben`,
);
}
kinds.add(lot.kind);
await tx.productionInput.create({
data: { productionBatchId, rawMaterialLotId, quantity },
});
@@ -318,6 +348,7 @@ export class StockService {
},
});
}
return [...kinds];
}
/**
@@ -345,16 +376,22 @@ export class StockService {
/**
* Gives the raw material of a production batch back - only for a batch
* deleted as a typo. The give-back is a movement of its own, so the raw
* lot's ledger still shows that it was booked and returned.
* lot's ledger still shows that it was booked and returned. Returns the
* raw material kinds given back - for the caller's StockChanged.
*/
async returnProductionInputs(
tx: Tx,
productionBatchId: string,
lotNumber: string,
actingUserId: string,
): Promise<void> {
): Promise<RawMaterialKind[]> {
const inputs = await tx.productionInput.findMany({
where: { productionBatchId },
select: {
rawMaterialLotId: true,
quantity: true,
rawMaterialLot: { select: { kind: true } },
},
});
for (const input of inputs) {
await tx.batchMovement.create({
@@ -367,6 +404,7 @@ export class StockService {
},
});
}
return [...new Set(inputs.map((input) => input.rawMaterialLot.kind))];
}
// -------------------------------------------------------------------------
@@ -376,26 +414,16 @@ export class StockService {
/**
* Books the shop's sales and returns. Each request runs on its own, so one
* invalid request does not hold back the rest of the batch. A request with
* a known externalId books nothing and reports the original result.
* a known externalId books nothing and reports the original result. Each
* accepted booking records its own StockChanged in its transaction.
*/
async applyShopRequests(
requests: ShopStockRequestInput[],
apiKeyId: string | null,
): Promise<ShopStockResult[]> {
const results: ShopStockResult[] = [];
const change: StockChange = { bottlingBatchIds: [], productIds: [] };
for (const request of requests) {
const result = await this.applyShopRequest(request, apiKeyId);
results.push(result);
if (result.status === 'created') {
if (request.bottlingBatchId) {
change.bottlingBatchIds.push(request.bottlingBatchId);
}
if (request.productId) change.productIds.push(request.productId);
}
}
if (change.bottlingBatchIds.length > 0 || change.productIds.length > 0) {
await this.notify(change);
results.push(await this.applyShopRequest(request, apiKeyId));
}
return results;
}
@@ -441,6 +469,19 @@ export class StockService {
apiKeyId,
})),
});
// Booked by the shop's API key, not by a user.
await this.recordChange(
tx,
{
bottlingBatchIds: request.bottlingBatchId
? [request.bottlingBatchId]
: [],
productIds: request.productId ? [request.productId] : [],
rawMaterialKinds: [],
},
null,
{ type: 'ShopStockRequest', id: created.id },
);
});
} catch (error: unknown) {
if (error instanceof RejectedRequest) {
@@ -650,79 +691,140 @@ export class StockService {
// Helpers
// -------------------------------------------------------------------------
/** Tells subscribers (the shop) that stock changed; they fetch the numbers. */
async notify(change: StockChange): Promise<void> {
// Raw material is not sold in the shop - nothing to tell.
if (
change.bottlingBatchIds.length === 0 &&
change.productIds.length === 0
) {
return;
/**
* Records StockChanged in the caller's transaction, so the booking and
* its announcement commit together. Nothing for an empty change.
* Receivers fetch the numbers themselves.
*/
async recordChange(
tx: Tx,
change: StockChange,
actingUserId: string | null,
aggregate: StockChangeAggregate,
): Promise<void> {
assertTransactionClient(tx, 'StockService.recordChange');
const event = this.stockChangedEvent(change, actingUserId, aggregate);
if (event !== null) {
await this.events.record(tx, event);
}
await this.webhooks.dispatch('stock.changed', {
bottlingBatchIds: [...new Set(change.bottlingBatchIds)],
productIds: [...new Set(change.productIds)],
});
}
/** The stock.changed payload for one batch. */
async changeFor(target: StockTarget): Promise<StockChange> {
/**
* The StockChanged event of `change` - every id and kind once - or null
* when nothing changed. For a write that records it together with other
* events of the same action in one recordAll, so they share one
* correlation id; everyone else uses recordChange.
*/
stockChangedEvent(
change: StockChange,
actingUserId: string | null,
aggregate: StockChangeAggregate,
): NewDomainEvent<'StockChanged'> | null {
const payload: StockChange = {
bottlingBatchIds: [...new Set(change.bottlingBatchIds)],
productIds: [...new Set(change.productIds)],
rawMaterialKinds: [...new Set(change.rawMaterialKinds)],
};
if (
payload.bottlingBatchIds.length === 0 &&
payload.productIds.length === 0 &&
payload.rawMaterialKinds.length === 0
) {
return null;
}
return {
type: 'StockChanged',
aggregateType: aggregate.type,
aggregateId: aggregate.id,
payload,
actorUserId: actingUserId,
};
}
/** What a movement on `target` changes: its bottling, article or raw kind. */
private async changeOf(tx: Tx, target: StockTarget): Promise<StockChange> {
if (target.kind === 'bottling') {
return { bottlingBatchIds: [target.id], productIds: [] };
return {
bottlingBatchIds: [target.id],
productIds: [],
rawMaterialKinds: [],
};
}
if (target.kind === 'raw') {
return { bottlingBatchIds: [], productIds: [] };
const lot = await tx.rawMaterialLot.findUniqueOrThrow({
where: { id: target.id },
select: { kind: true },
});
return {
bottlingBatchIds: [],
productIds: [],
rawMaterialKinds: [lot.kind],
};
}
const batch = await this.prisma.productionBatch.findUnique({
const batch = await tx.productionBatch.findUniqueOrThrow({
where: { id: target.id },
select: { productId: true },
});
return { bottlingBatchIds: [], productIds: batch ? [batch.productId] : [] };
return {
bottlingBatchIds: [],
productIds: [batch.productId],
rawMaterialKinds: [],
};
}
private async recordAndNotify(
/** Audit entry and StockChanged of a manual booking, in its transaction. */
private async recordMovement(
tx: Tx,
target: StockTarget,
movement: { id: string },
actingUserId: string,
): Promise<void> {
await this.auditLog.record({
entityType: 'BatchMovement',
entityId: movement.id,
action: 'CREATE',
userId: actingUserId,
afterData: movement,
await this.auditLog.record(
{
entityType: 'BatchMovement',
entityId: movement.id,
action: 'CREATE',
userId: actingUserId,
afterData: movement,
},
tx,
);
await this.recordChange(tx, await this.changeOf(tx, target), actingUserId, {
type: 'BatchMovement',
id: movement.id,
});
await this.notify(await this.changeFor(target));
}
private async remainingFor(
kind: StockTarget['kind'],
ids?: string[],
tx?: Tx,
): Promise<Map<string, number>> {
const db: DbClient = tx ?? this.prisma;
const where = { id: ids ? { in: ids } : undefined };
const initial =
kind === 'bottling'
? (
await this.prisma.bottlingBatch.findMany({
await db.bottlingBatch.findMany({
where,
select: { id: true, jarCount: true },
})
).map((row) => ({ id: row.id, initial: row.jarCount }))
: kind === 'production'
? (
await this.prisma.productionBatch.findMany({
await db.productionBatch.findMany({
where,
select: { id: true, quantity: true },
})
).map((row) => ({ id: row.id, initial: row.quantity }))
: (
await this.prisma.rawMaterialLot.findMany({
await db.rawMaterialLot.findMany({
where,
select: { id: true, quantity: true },
})
).map((row) => ({ id: row.id, initial: row.quantity }));
const sums = await this.sumMovements(
this.prisma,
db,
kind,
initial.map((row) => row.id),
);
@@ -732,7 +834,7 @@ export class StockService {
}
private async sumMovements(
db: Tx | PrismaService,
db: DbClient,
kind: StockTarget['kind'],
ids: string[],
): Promise<Map<string, number>> {
@@ -774,7 +876,7 @@ export class StockService {
}
private async initialQuantity(
db: Tx | PrismaService,
db: DbClient,
target: StockTarget,
): Promise<number> {
if (target.kind === 'bottling') {
@@ -816,16 +918,20 @@ export class StockService {
return { productionBatchId: target.id };
}
private async lock(tx: Tx, target: StockTarget): Promise<void> {
/**
* Locks the row every booking on `target` serializes on: the bottling,
* the raw material lot - or, for a production batch, its article, since
* a shop sale allocates across all batches of the article. A caller that
* also locks the production batch row takes this lock first: a shop sale
* holds the article while its movement key-share locks the batch.
*/
async lock(tx: Tx, target: StockTarget): Promise<void> {
if (target.kind === 'bottling') {
await tx.$queryRaw`SELECT id FROM "BottlingBatch" WHERE id = ${target.id} FOR UPDATE`;
await lockRowForUpdate(tx, 'BottlingBatch', target.id);
return;
}
if (target.kind === 'raw') {
const locked = await tx.$queryRaw<
{ id: string }[]
>`SELECT id FROM "RawMaterialLot" WHERE id = ${target.id} FOR UPDATE`;
if (locked.length === 0) {
if (!(await lockRowForUpdate(tx, 'RawMaterialLot', target.id))) {
throw new NotFoundException('Raw material lot not found');
}
return;
@@ -839,6 +945,6 @@ export class StockService {
}
private async lockProduct(tx: Tx, productId: string): Promise<void> {
await tx.$queryRaw`SELECT id FROM "Product" WHERE id = ${productId} FOR UPDATE`;
await lockRowForUpdate(tx, 'Product', productId);
}
}
@@ -0,0 +1,32 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
import { JOB_NAMES, type JobName } from '../../jobs/job-names';
export const SYSTEM_LIST_DEFAULT_LIMIT = 50;
export const SYSTEM_LIST_MAX_LIMIT = 100;
export class SystemListQueryDto {
@ApiPropertyOptional({
minimum: 1,
maximum: SYSTEM_LIST_MAX_LIMIT,
default: SYSTEM_LIST_DEFAULT_LIMIT,
description: 'Anzahl der neuesten Einträge',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(SYSTEM_LIST_MAX_LIMIT)
limit?: number;
}
export class JobRunListQueryDto extends SystemListQueryDto {
@ApiPropertyOptional({
enum: JOB_NAMES,
description: 'Nur Läufe dieses Jobs',
})
@IsOptional()
@IsIn(JOB_NAMES)
job?: JobName;
}
@@ -0,0 +1,287 @@
import { Logger } from '@nestjs/common';
import { JobRun, Prisma } from '../../generated/prisma';
import { JOB_NAMES } from '../jobs/job-names';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { JOB_ABORTED_ERROR, JobRunService } from './job-run.service';
type RawCall = [readonly string[], ...unknown[]];
const STARTED_AT = new Date('2026-10-13T04:00:00.000Z');
function runRow(overrides: Partial<JobRun> = {}): JobRun {
return {
id: 'run-1',
job: 'daily-evaluation',
status: 'RUNNING',
startedAt: STARTED_AT,
finishedAt: null,
result: null,
error: null,
...overrides,
};
}
describe('JobRunService', () => {
let jobRun: {
create: jest.Mock;
findMany: jest.Mock;
findFirst: jest.Mock;
};
let tx: {
$executeRaw: jest.Mock<Promise<number>, RawCall>;
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
};
let prisma: {
jobRun: typeof jobRun;
$transaction: jest.Mock;
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
};
let notifications: { recordForPermission: jest.Mock };
let service: JobRunService;
let errorSpy: jest.SpyInstance;
const sqlText = (call: RawCall): string => call[0].join('?');
beforeEach(() => {
jobRun = {
create: jest.fn(({ data }: { data: { job: string } }) =>
Promise.resolve(runRow({ job: data.job })),
),
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(null),
};
tx = {
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
$queryRaw: jest
.fn<Promise<unknown>, RawCall>()
.mockResolvedValue([{ notified: false }]),
};
prisma = {
jobRun,
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
// Finishing a run: UPDATE … RETURNING *, the status is the first value.
$queryRaw: jest.fn<Promise<unknown>, RawCall>((_sql, status) =>
Promise.resolve([runRow({ status: status as JobRun['status'] })]),
),
};
notifications = { recordForPermission: jest.fn().mockResolvedValue(2) };
service = new JobRunService(
prisma as unknown as PrismaService,
notifications as unknown as NotificationsService,
);
errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
describe('run', () => {
it('records RUNNING first, then SUCCEEDED with the result', async () => {
const work = jest.fn(() => {
expect(jobRun.create).toHaveBeenCalledWith({
data: { job: 'cleanup' },
});
return Promise.resolve({ deleted: 3, dryRun: false });
});
const run = await service.run('cleanup', work);
expect(work).toHaveBeenCalledTimes(1);
const [finish] = prisma.$queryRaw.mock.calls;
expect(finish.slice(1)).toEqual([
'SUCCEEDED',
'{"deleted":3,"dryRun":false}',
null,
'run-1',
]);
expect(run.status).toBe('SUCCEEDED');
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
it('records FAILED with the loggable message, never the Prisma message', async () => {
const run = await service.run('cleanup', () =>
Promise.reject(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2002',
clientVersion: '7.9.1',
meta: { modelName: 'Task' },
}),
),
);
expect(run.status).toBe('FAILED');
const [finish] = prisma.$queryRaw.mock.calls;
expect(finish.slice(1)).toEqual([
'FAILED',
null,
'PrismaClientKnownRequestError P2002 (Task)',
'run-1',
]);
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Mustermann');
});
// finishedAt of every run - finished, failed or aborted - comes from
// the database clock, like the comparisons of cleanup.
it.each([
['succeeds', () => Promise.resolve({ deleted: 1 })],
['fails', () => Promise.reject(new Error('boom'))],
])(
'sets finishedAt to now() when the run %s',
async (_case, work: () => Promise<Record<string, number>>) => {
await service.run('cleanup', work);
const [finish] = prisma.$queryRaw.mock.calls;
expect(sqlText(finish)).toContain('"finishedAt" = now()');
expect(sqlText(finish)).toContain('RETURNING *');
expect(finish.slice(1).some((value) => value instanceof Date)).toBe(
false,
);
},
);
it('notifies holders of the job permission when a run fails', async () => {
await service.run('daily-evaluation', () =>
Promise.reject(new Error('boom')),
);
expect(notifications.recordForPermission).toHaveBeenCalledWith(
'automation:update',
{
priority: 'WARNUNG',
message: 'Job „Tägliche Auswertung“ fehlgeschlagen',
relatedType: 'JobRun',
relatedId: 'run-1',
},
tx,
);
});
// The hourly app-backup check would otherwise report one outage every
// hour. The check and the write share a transaction behind a lock per
// job, so two failing runs at once cannot both pass the check.
it('notifies at most once per job and UTC day, decided by the database clock', async () => {
tx.$queryRaw.mockResolvedValue([{ notified: true }]);
await service.run('app-backup', () => Promise.reject(new Error('boom')));
expect(notifications.recordForPermission).not.toHaveBeenCalled();
const lock = tx.$executeRaw.mock.calls[0];
expect(sqlText(lock)).toContain('pg_advisory_xact_lock');
expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']);
const check = tx.$queryRaw.mock.calls[0];
expect(sqlText(check)).toContain(`date_trunc('day', now())`);
expect(check.slice(1)).toEqual(['JobRun', 'app-backup']);
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.$queryRaw.mock.invocationCallOrder[0],
);
});
it('still returns the FAILED run when the notification cannot be written', async () => {
prisma.$transaction.mockRejectedValue(new Error('deadlock detected'));
const run = await service.run('cleanup', () =>
Promise.reject(new Error('boom')),
);
expect(run.status).toBe('FAILED');
expect(JSON.stringify(errorSpy.mock.calls)).toContain(
'deadlock detected',
);
});
});
describe('recent', () => {
it('returns the newest runs, optionally of one job', async () => {
await service.recent(20, 'cleanup');
await service.recent(5);
expect(jobRun.findMany).toHaveBeenNthCalledWith(1, {
where: { job: 'cleanup' },
orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
take: 20,
});
expect(jobRun.findMany).toHaveBeenNthCalledWith(2, {
where: {},
orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
take: 5,
});
});
it.each([0, -1, 1.5])('refuses the limit %p', async (limit) => {
await expect(service.recent(limit)).rejects.toThrow(RangeError);
});
});
describe('latestPerJob', () => {
it('returns the newest run of every job, null for a job that never ran', async () => {
const cleanupRun = runRow({ job: 'cleanup', status: 'SUCCEEDED' });
jobRun.findFirst.mockImplementation(
({ where }: { where: { job: string } }) =>
Promise.resolve(where.job === 'cleanup' ? cleanupRun : null),
);
const latest = await service.latestPerJob();
expect(Object.keys(latest)).toEqual([...JOB_NAMES]);
expect(latest.cleanup).toBe(cleanupRun);
expect(latest.rekey).toBeNull();
expect(jobRun.findFirst).toHaveBeenCalledWith({
where: { job: 'cleanup' },
orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
});
});
});
describe('abortStale', () => {
it('marks runs RUNNING longer than the window as FAILED, by the database clock', async () => {
prisma.$queryRaw.mockResolvedValue([]);
await expect(service.abortStale(6)).resolves.toBe(0);
const [call] = prisma.$queryRaw.mock.calls;
const sql = sqlText(call);
expect(sql).toContain(`"status" = 'RUNNING'`);
expect(sql).toContain('"startedAt" < now()');
expect(call.slice(1)).toEqual([JOB_ABORTED_ERROR, 6]);
expect(call.slice(1).some((value) => value instanceof Date)).toBe(false);
});
it('sends the failure notification once per job of the aborted runs', async () => {
prisma.$queryRaw.mockResolvedValue([
{ id: 'a', job: 'app-backup' },
{ id: 'b', job: 'app-backup' },
{ id: 'c', job: 'task-templates' },
]);
await expect(service.abortStale(6)).resolves.toBe(3);
expect(notifications.recordForPermission).toHaveBeenCalledTimes(2);
expect(notifications.recordForPermission).toHaveBeenCalledWith(
'backup:configure',
expect.objectContaining({
message: 'Job „App-Backup“ fehlgeschlagen',
relatedId: 'a',
}),
tx,
);
expect(notifications.recordForPermission).toHaveBeenCalledWith(
'automation:update',
expect.objectContaining({ relatedId: 'c' }),
tx,
);
});
it('aborts but does not notify for a job name that no longer exists', async () => {
prisma.$queryRaw.mockResolvedValue([{ id: 'x', job: 'retired-job' }]);
await expect(service.abortStale(6)).resolves.toBe(1);
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,187 @@
import { Injectable, Logger } from '@nestjs/common';
import { JobRun } from '../../generated/prisma';
import { loggableError } from '../common/loggable-error';
import { JobResult } from '../jobs/job';
import {
isJobName,
JOB_FAILURE_PERMISSION,
JOB_LABELS_DE,
JOB_NAMES,
JobName,
} from '../jobs/job-names';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
/** JobRun.error of a run whose pod ended before it wrote a result. */
export const JOB_ABORTED_ERROR = 'aborted (pod ended without result)';
/** Notification.relatedType of a job failure; relatedId is the JobRun id. */
const JOB_RUN_RELATED_TYPE = 'JobRun';
const NEWEST_FIRST = [{ startedAt: 'desc' as const }, { id: 'desc' as const }];
type RunOutcome =
| { status: 'SUCCEEDED'; result: JobResult }
| { status: 'FAILED'; error: string };
/**
* Bookkeeping of the CronJob runs: every run is one JobRun row (RUNNING,
* then SUCCEEDED or FAILED), and a failed run notifies the holders of the
* job's permission (JOB_FAILURE_PERMISSION).
*/
@Injectable()
export class JobRunService {
private readonly logger = new Logger(JobRunService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
/**
* Runs `work` as one run of `job`. Resolves with the finished JobRun - a
* failure of `work` is recorded, not rethrown. Throws only if the run
* itself cannot be recorded.
*/
async run(job: JobName, work: () => Promise<JobResult>): Promise<JobRun> {
const started = await this.prisma.jobRun.create({ data: { job } });
let result: JobResult;
try {
result = await work();
} catch (error: unknown) {
return this.fail(job, started.id, error);
}
this.logger.log(`Job "${job}" succeeded: ${JSON.stringify(result)}`);
return this.finish(started.id, { status: 'SUCCEEDED', result });
}
/** The newest runs, of one job or of all. */
async recent(limit: number, job?: JobName): Promise<JobRun[]> {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError(`Limit must be a positive integer, got ${limit}`);
}
return this.prisma.jobRun.findMany({
where: job ? { job } : {},
orderBy: NEWEST_FIRST,
take: limit,
});
}
/** The newest run of every job, null for a job that never ran. */
async latestPerJob(): Promise<Record<JobName, JobRun | null>> {
const latest = await Promise.all(
JOB_NAMES.map((job) =>
this.prisma.jobRun.findFirst({ where: { job }, orderBy: NEWEST_FIRST }),
),
);
return Object.fromEntries(
JOB_NAMES.map((job, index) => [job, latest[index]]),
) as Record<JobName, JobRun | null>;
}
/**
* Marks runs that are RUNNING for longer than `staleHours` as FAILED:
* their pod ended without a result (activeDeadlineSeconds, OOM, node
* loss). finishedAt is when this was noticed. Each affected job gets the
* usual failure notification - otherwise a job that is killed every time
* would fail without anyone hearing about it. Compared against now(), the
* database clock. Returns how many runs were aborted.
*/
async abortStale(staleHours: number): Promise<number> {
const aborted = await this.prisma.$queryRaw<{ id: string; job: string }[]>`
UPDATE "JobRun"
SET "status" = 'FAILED', "finishedAt" = now(), "error" = ${JOB_ABORTED_ERROR}
WHERE "status" = 'RUNNING'
AND "startedAt" < now() - (${staleHours} * interval '1 hour')
RETURNING "id", "job"`;
const firstRunPerJob = new Map<JobName, string>();
for (const { id, job } of aborted) {
if (isJobName(job) && !firstRunPerJob.has(job)) {
firstRunPerJob.set(job, id);
}
}
for (const [job, runId] of firstRunPerJob) {
await this.notifyFailure(job, runId);
}
return aborted.length;
}
private async fail(
job: JobName,
runId: string,
error: unknown,
): Promise<JobRun> {
const { message, stack } = loggableError(error);
this.logger.error(`Job "${job}" failed: ${message}`, stack);
const failed = await this.finish(runId, {
status: 'FAILED',
error: message,
});
await this.notifyFailure(job, runId);
return failed;
}
/**
* Ends a run. finishedAt is now(), the database clock - the same clock
* abortStale and every cleanup comparison use.
*/
private async finish(runId: string, outcome: RunOutcome): Promise<JobRun> {
const result =
outcome.status === 'SUCCEEDED' ? JSON.stringify(outcome.result) : null;
const error = outcome.status === 'FAILED' ? outcome.error : null;
const [finished] = await this.prisma.$queryRaw<JobRun[]>`
UPDATE "JobRun"
SET "status" = CAST(${outcome.status} AS "JobRunStatus"),
"finishedAt" = now(),
"result" = CAST(${result} AS jsonb),
"error" = ${error}
WHERE "id" = ${runId}
RETURNING *`;
if (!finished) {
throw new Error(`JobRun ${runId} no longer exists`);
}
return finished;
}
/**
* At most one notification per job and UTC day (database clock): the
* hourly app-backup check would otherwise report the same outage every
* hour. The advisory lock per job serialises check and write, so two
* runs failing at once cannot both pass the check. A notification that
* cannot be written is logged; the run stays FAILED either way.
*/
private async notifyFailure(job: JobName, runId: string): Promise<void> {
try {
await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`job-failure-notification:${job}`}, 0))`;
const [{ notified }] = await tx.$queryRaw<{ notified: boolean }[]>`
SELECT EXISTS (
SELECT 1 FROM "Notification" n
JOIN "JobRun" r ON r."id" = n."relatedId"
WHERE n."relatedType" = ${JOB_RUN_RELATED_TYPE}
AND r."job" = ${job}
AND n."createdAt" >= date_trunc('day', now())
) AS "notified"`;
if (notified) {
return;
}
await this.notifications.recordForPermission(
JOB_FAILURE_PERMISSION[job],
{
priority: 'WARNUNG',
message: `Job „${JOB_LABELS_DE[job]}“ fehlgeschlagen`,
relatedType: JOB_RUN_RELATED_TYPE,
relatedId: runId,
},
tx,
);
});
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(
`Failure notification for job "${job}" could not be written: ${message}`,
stack,
);
}
}
}
@@ -0,0 +1,207 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from './job-run.service';
import { SystemQueueService } from './system-queue.service';
type RawCall = [readonly string[], ...unknown[]];
describe('SystemQueueService', () => {
let tx: {
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
$executeRaw: jest.Mock<Promise<number>, RawCall>;
};
let prisma: {
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
$transaction: jest.Mock;
domainEvent: { findMany: jest.Mock; findUnique: jest.Mock };
webhookDelivery: { findMany: jest.Mock; findUnique: jest.Mock };
};
let jobRuns: { latestPerJob: jest.Mock };
let audit: { record: jest.Mock };
let service: SystemQueueService;
const sqlText = (call: RawCall): string => call[0].join('?');
beforeEach(() => {
tx = {
$queryRaw: jest.fn<Promise<unknown>, RawCall>().mockResolvedValue([]),
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
};
prisma = {
$queryRaw: jest.fn<Promise<unknown>, RawCall>(),
$transaction: jest.fn((fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
domainEvent: { findMany: jest.fn(), findUnique: jest.fn() },
webhookDelivery: { findMany: jest.fn(), findUnique: jest.fn() },
};
jobRuns = {
latestPerJob: jest.fn().mockResolvedValue({
'daily-evaluation': null,
'task-templates': null,
cleanup: null,
'app-backup': null,
rekey: null,
}),
};
audit = { record: jest.fn().mockResolvedValue(undefined) };
service = new SystemQueueService(
prisma as unknown as PrismaService,
jobRuns as unknown as JobRunService,
audit as unknown as AuditLogService,
);
});
it('reports the oldest pending age from the database clock', async () => {
prisma.$queryRaw
.mockResolvedValueOnce([
{ pending: 3, processing: 1, failed: 2, oldestPendingAgeSeconds: 42.5 },
])
.mockResolvedValueOnce([{ pending: 4, failed: 1 }]);
const overview = await service.overview();
expect(overview.domainEvents).toEqual({
pending: 3,
processing: 1,
failed: 2,
oldestPendingAgeSeconds: 42.5,
});
expect(overview.webhookDeliveries).toEqual({ pending: 4, failed: 1 });
expect(overview.jobs).toHaveLength(5);
const sql = sqlText(prisma.$queryRaw.mock.calls[0]);
expect(sql).toContain('EXTRACT(EPOCH FROM now() - MIN("occurredAt")');
});
it('reports null age when nothing is pending', async () => {
prisma.$queryRaw
.mockResolvedValueOnce([
{ pending: 0, processing: 0, failed: 0, oldestPendingAgeSeconds: null },
])
.mockResolvedValueOnce([{ pending: 0, failed: 0 }]);
const overview = await service.overview();
expect(overview.domainEvents.oldestPendingAgeSeconds).toBeNull();
});
it('lists failed events without payload, with their failed handlers', async () => {
prisma.domainEvent.findMany.mockResolvedValue([
{
id: 'e1',
type: 'x',
aggregateType: 'A',
aggregateId: 'a1',
occurredAt: new Date(),
attempts: 8,
lastError: 'boom',
deliveries: [{ handler: 'h1', lastError: 'bad' }],
},
]);
const [view] = await service.failedDomainEvents(10);
expect(view.failedHandlers).toEqual([{ handler: 'h1', lastError: 'bad' }]);
const args = (
prisma.domainEvent.findMany.mock.calls as unknown[][]
)[0][0] as {
select: Record<string, unknown>;
take: number;
};
expect(args.select).not.toHaveProperty('payload');
expect(args.take).toBe(10);
});
it('retry resets a FAILED event and its failed deliveries, writes an audit entry and wakes the worker', async () => {
tx.$queryRaw.mockResolvedValueOnce([{ attempts: 8 }]);
await service.retryDomainEvent('e1', 'user-1');
const statements = [
...tx.$queryRaw.mock.calls,
...tx.$executeRaw.mock.calls,
].map(sqlText);
expect(statements[0]).toContain('"availableAt" = now()');
expect(statements[0]).toContain('"attempts" = 0');
expect(statements.some((s) => s.includes('"DomainEventDelivery"'))).toBe(
true,
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'DomainEvent',
entityId: 'e1',
action: 'UPDATE',
userId: 'user-1',
}),
tx,
);
expect(
tx.$executeRaw.mock.calls.some((c) =>
c.slice(1).includes('domain_event'),
),
).toBe(true);
});
it('retry of a DONE event is 409', async () => {
tx.$queryRaw.mockResolvedValueOnce([]);
prisma.domainEvent.findUnique.mockResolvedValue({ id: 'e1' });
await expect(service.retryDomainEvent('e1', 'u')).rejects.toBeInstanceOf(
ConflictException,
);
expect(audit.record).not.toHaveBeenCalled();
});
it('retry of an unknown event is 404', async () => {
tx.$queryRaw.mockResolvedValueOnce([]);
prisma.domainEvent.findUnique.mockResolvedValue(null);
await expect(service.retryDomainEvent('nope', 'u')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('shows only the host of a webhook target', async () => {
prisma.webhookDelivery.findMany.mockResolvedValue([
{
id: 'd1',
subscriptionId: 's1',
event: 'harvest.created',
attempts: 8,
lastStatusCode: 500,
lastError: 'HTTP 500',
createdAt: new Date(),
subscription: {
url: 'https://user:[email protected]:8443/hidden/path?key=abc',
},
},
]);
const [view] = await service.failedWebhookDeliveries(5);
expect(view.targetHost).toBe('hooks.example.com:8443');
expect(JSON.stringify(view)).not.toMatch(/hidden|key=|pw/);
expect(view).not.toHaveProperty('body');
expect(view).not.toHaveProperty('subscription');
});
it('retries a FAILED webhook delivery and wakes the delivery worker', async () => {
tx.$queryRaw.mockResolvedValueOnce([{ attempts: 8 }]);
await service.retryWebhookDelivery('d1', 'user-1');
expect(sqlText(tx.$queryRaw.mock.calls[0])).toContain(
'"availableAt" = now()',
);
expect(
tx.$executeRaw.mock.calls.some((c) =>
c.slice(1).includes('webhook_delivery'),
),
).toBe(true);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'WebhookDelivery' }),
tx,
);
});
it('retry of a non-FAILED delivery is 409', async () => {
tx.$queryRaw.mockResolvedValueOnce([]);
prisma.webhookDelivery.findUnique.mockResolvedValue({ id: 'd1' });
await expect(
service.retryWebhookDelivery('d1', 'u'),
).rejects.toBeInstanceOf(ConflictException);
});
});
@@ -0,0 +1,291 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { JobRun } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { JobResult } from '../jobs/job';
import { isJobName, JOB_NAMES, JobName } from '../jobs/job-names';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from './job-run.service';
export interface JobRunView {
id: string;
job: JobName;
status: JobRun['status'];
startedAt: Date;
finishedAt: Date | null;
error: string | null;
result: JobResult | null;
}
export interface SystemOverview {
jobs: { job: JobName; lastRun: JobRunView | null }[];
domainEvents: {
pending: number;
processing: number;
failed: number;
oldestPendingAgeSeconds: number | null;
};
webhookDeliveries: { pending: number; failed: number };
}
export interface FailedDomainEventView {
id: string;
type: string;
aggregateType: string;
aggregateId: string;
occurredAt: Date;
attempts: number;
lastError: string | null;
failedHandlers: { handler: string; lastError: string | null }[];
}
export interface FailedWebhookDeliveryView {
id: string;
subscriptionId: string;
targetHost: string;
event: string;
attempts: number;
lastStatusCode: number | null;
lastError: string | null;
createdAt: Date;
}
/** Shown when a stored webhook URL cannot be parsed. */
const UNKNOWN_HOST = 'unknown';
interface DomainEventCounts {
pending: number;
processing: number;
failed: number;
oldestPendingAgeSeconds: number | null;
}
interface WebhookCounts {
pending: number;
failed: number;
}
/** Host (and port) of a webhook URL - never path, query or credentials. */
function hostOf(rawUrl: string): string {
try {
return new URL(rawUrl).host || UNKNOWN_HOST;
} catch {
return UNKNOWN_HOST;
}
}
/** The row was not retryable: 404 if it does not exist, else 409. */
function retryRejection(
existing: { id: string } | null,
label: string,
): NotFoundException | ConflictException {
return existing
? new ConflictException(`${label} is not FAILED`)
: new NotFoundException(`${label} not found`);
}
/** JobRun for the API: `job` narrowed, `result` as the stored JSON object. */
export function toJobRunView(run: JobRun): JobRunView {
return {
id: run.id,
job: run.job as JobName,
status: run.status,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
error: run.error,
result: (run.result ?? null) as JobResult | null,
};
}
/**
* Read model and retry actions of the System page: the CronJob runs, the
* state of both outboxes and the re-queueing of FAILED entries. Views never
* carry payloads, webhook bodies, secrets or full URLs; `lastError` is
* shown as stored (written via loggableError).
*/
@Injectable()
export class SystemQueueService {
constructor(
private readonly prisma: PrismaService,
private readonly jobRuns: JobRunService,
private readonly audit: AuditLogService,
) {}
async overview(): Promise<SystemOverview> {
const [latest, [events], [webhooks]] = await Promise.all([
this.jobRuns.latestPerJob(),
// The age is computed by the database: the API pod's clock may differ.
this.prisma.$queryRaw<DomainEventCounts[]>`
SELECT
count(*) FILTER (WHERE "status" = 'PENDING')::int AS "pending",
count(*) FILTER (WHERE "status" = 'PROCESSING')::int AS "processing",
count(*) FILTER (WHERE "status" = 'FAILED')::int AS "failed",
EXTRACT(EPOCH FROM now() - MIN("occurredAt") FILTER (WHERE "status" = 'PENDING'))::float8 AS "oldestPendingAgeSeconds"
FROM "DomainEvent"`,
this.prisma.$queryRaw<WebhookCounts[]>`
SELECT
count(*) FILTER (WHERE "status" = 'PENDING')::int AS "pending",
count(*) FILTER (WHERE "status" = 'FAILED')::int AS "failed"
FROM "WebhookDelivery"`,
]);
return {
jobs: JOB_NAMES.map((job) => ({
job,
lastRun: latest[job] ? toJobRunView(latest[job]) : null,
})),
domainEvents: {
pending: events.pending,
processing: events.processing,
failed: events.failed,
oldestPendingAgeSeconds: events.oldestPendingAgeSeconds,
},
webhookDeliveries: { pending: webhooks.pending, failed: webhooks.failed },
};
}
/** The newest runs, of one job or of all (limit validated by the DTO). */
async jobRunViews(limit: number, job?: JobName): Promise<JobRunView[]> {
const runs = await this.jobRuns.recent(limit, job);
return runs.filter((run) => isJobName(run.job)).map(toJobRunView);
}
async failedDomainEvents(limit: number): Promise<FailedDomainEventView[]> {
const events = await this.prisma.domainEvent.findMany({
where: { status: 'FAILED' },
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
take: limit,
// Never the payload: it holds the full record the event was about.
select: {
id: true,
type: true,
aggregateType: true,
aggregateId: true,
occurredAt: true,
attempts: true,
lastError: true,
deliveries: {
where: { status: 'FAILED' },
orderBy: { handler: 'asc' },
select: { handler: true, lastError: true },
},
},
});
return events.map(({ deliveries, ...event }) => ({
...event,
failedHandlers: deliveries,
}));
}
/**
* Re-queues a FAILED event and its FAILED handler deliveries (DONE
* deliveries stay DONE and are skipped, so no effect repeats). Resets
* attempts and availableAt in SQL (database clock), audits the change in
* the same transaction and wakes the worker. 404 unknown, 409 not FAILED.
*/
async retryDomainEvent(id: string, actingUserId: string): Promise<void> {
await this.prisma.$transaction(async (tx) => {
const [before] = await tx.$queryRaw<{ attempts: number }[]>`
WITH old AS (
SELECT "id", "attempts" FROM "DomainEvent"
WHERE "id" = ${id} AND "status" = 'FAILED'
FOR UPDATE
)
UPDATE "DomainEvent" e
SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(),
"claimedAt" = NULL, "processedAt" = NULL, "lastError" = NULL
FROM old
WHERE e."id" = old."id"
RETURNING old."attempts" AS "attempts"`;
if (!before) {
const existing = await this.prisma.domainEvent.findUnique({
where: { id },
select: { id: true },
});
throw retryRejection(existing, 'Domain event');
}
await tx.$executeRaw`
UPDATE "DomainEventDelivery"
SET "status" = 'PENDING', "attempts" = 0, "lastError" = NULL,
"updatedAt" = now()
WHERE "eventId" = ${id} AND "status" = 'FAILED'`;
await this.audit.record(
{
entityType: 'DomainEvent',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: { status: 'FAILED', attempts: before.attempts },
afterData: { status: 'PENDING', attempts: 0 },
},
tx,
);
await notifyWorker(tx, WAKE_CHANNELS.domainEvents);
});
}
async failedWebhookDeliveries(
limit: number,
): Promise<FailedWebhookDeliveryView[]> {
const deliveries = await this.prisma.webhookDelivery.findMany({
where: { status: 'FAILED' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: limit,
// Never the body; of the subscription only the URL, reduced to its host.
select: {
id: true,
subscriptionId: true,
event: true,
attempts: true,
lastStatusCode: true,
lastError: true,
createdAt: true,
subscription: { select: { url: true } },
},
});
return deliveries.map(({ subscription, ...delivery }) => ({
...delivery,
targetHost: hostOf(subscription.url),
}));
}
/** Re-queues a FAILED delivery; same rules as retryDomainEvent. */
async retryWebhookDelivery(id: string, actingUserId: string): Promise<void> {
await this.prisma.$transaction(async (tx) => {
const [before] = await tx.$queryRaw<{ attempts: number }[]>`
WITH old AS (
SELECT "id", "attempts" FROM "WebhookDelivery"
WHERE "id" = ${id} AND "status" = 'FAILED'
FOR UPDATE
)
UPDATE "WebhookDelivery" d
SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(),
"claimedAt" = NULL, "lastStatusCode" = NULL, "lastError" = NULL
FROM old
WHERE d."id" = old."id"
RETURNING old."attempts" AS "attempts"`;
if (!before) {
const existing = await this.prisma.webhookDelivery.findUnique({
where: { id },
select: { id: true },
});
throw retryRejection(existing, 'Webhook delivery');
}
await this.audit.record(
{
entityType: 'WebhookDelivery',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: { status: 'FAILED', attempts: before.attempts },
afterData: { status: 'PENDING', attempts: 0 },
},
tx,
);
await notifyWorker(tx, WAKE_CHANNELS.webhookDeliveries);
});
}
}
@@ -0,0 +1,87 @@
import {
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import {
JobRunListQueryDto,
SYSTEM_LIST_DEFAULT_LIMIT,
SystemListQueryDto,
} from './dto/system-list-query.dto';
import {
FailedDomainEventView,
FailedWebhookDeliveryView,
JobRunView,
SystemOverview,
SystemQueueService,
} from './system-queue.service';
@ApiTags('system')
@ApiBearerAuth()
@Controller('system')
export class SystemController {
constructor(private readonly queue: SystemQueueService) {}
@RequirePermission('system:read')
@Get('overview')
overview(): Promise<SystemOverview> {
return this.queue.overview();
}
@RequirePermission('system:read')
@Get('job-runs')
jobRuns(@Query() query: JobRunListQueryDto): Promise<JobRunView[]> {
return this.queue.jobRunViews(
query.limit ?? SYSTEM_LIST_DEFAULT_LIMIT,
query.job,
);
}
@RequirePermission('system:read')
@Get('domain-events/failed')
failedDomainEvents(
@Query() query: SystemListQueryDto,
): Promise<FailedDomainEventView[]> {
return this.queue.failedDomainEvents(
query.limit ?? SYSTEM_LIST_DEFAULT_LIMIT,
);
}
@RequirePermission('system:manage')
@Post('domain-events/:id/retry')
@HttpCode(HttpStatus.NO_CONTENT)
retryDomainEvent(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<void> {
return this.queue.retryDomainEvent(id, actor.id);
}
@RequirePermission('system:read')
@Get('webhook-deliveries/failed')
failedWebhookDeliveries(
@Query() query: SystemListQueryDto,
): Promise<FailedWebhookDeliveryView[]> {
return this.queue.failedWebhookDeliveries(
query.limit ?? SYSTEM_LIST_DEFAULT_LIMIT,
);
}
@RequirePermission('system:manage')
@Post('webhook-deliveries/:id/retry')
@HttpCode(HttpStatus.NO_CONTENT)
retryWebhookDelivery(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<void> {
return this.queue.retryWebhookDelivery(id, actor.id);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { JobRunService } from './job-run.service';
import { SystemController } from './system.controller';
import { SystemQueueService } from './system-queue.service';
/**
* Operations of the platform itself: the CronJob runs (JobRun). Imported by
* the API (System page) and by JobsModule (dist/cli.js).
*/
@Module({
imports: [NotificationsModule],
controllers: [SystemController],
providers: [JobRunService, SystemQueueService],
exports: [JobRunService],
})
export class SystemModule {}
@@ -2,7 +2,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TaskTemplatesService } from './task-templates.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
describe('TaskTemplatesService', () => {
let service: TaskTemplatesService;
@@ -12,12 +11,9 @@ describe('TaskTemplatesService', () => {
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
};
task: { create: jest.Mock };
};
let auditLog: { record: jest.Mock };
let webhooks: { dispatch: jest.Mock };
const template = {
id: 'template-1',
@@ -40,16 +36,12 @@ describe('TaskTemplatesService', () => {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
},
task: { create: jest.fn() },
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
service = new TaskTemplatesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
);
});
@@ -198,43 +190,4 @@ describe('TaskTemplatesService', () => {
];
expect(updateArg.data.active).toBe(false);
});
describe('generateDueTasks', () => {
it('creates a Task and advances nextDueDate for a due template', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([template]);
prisma.taskTemplate.updateMany.mockResolvedValue({ count: 1 });
prisma.task.create.mockResolvedValue({ id: 'task-1' });
await service.generateDueTasks();
const [[createArg]] = prisma.task.create.mock.calls as [
[{ data: { title: string; sourceTemplateId: string } }],
];
expect(createArg.data.title).toBe(template.title);
expect(createArg.data.sourceTemplateId).toBe(template.id);
expect(webhooks.dispatch).toHaveBeenCalledWith(
'task.created',
expect.objectContaining({ id: 'task-1' }),
);
});
it('skips Task creation when the compare-and-swap claim loses the race', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([template]);
prisma.taskTemplate.updateMany.mockResolvedValue({ count: 0 });
await service.generateDueTasks();
expect(prisma.task.create).not.toHaveBeenCalled();
expect(webhooks.dispatch).not.toHaveBeenCalled();
});
it('does nothing when no template is due', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([]);
await service.generateDueTasks();
expect(prisma.taskTemplate.updateMany).not.toHaveBeenCalled();
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
});
@@ -5,15 +5,10 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { Task, TaskTemplate } from '../../generated/prisma';
import { TaskTemplate } from '../../generated/prisma';
import { CreateTaskTemplateDto } from './dto/create-task-template.dto';
import { UpdateTaskTemplateDto } from './dto/update-task-template.dto';
import {
computeInitialDueDate,
computeNextDueDate,
RecurrenceInput,
} from './recurrence';
import { computeInitialDueDate, RecurrenceInput } from './recurrence';
function valueOrStored(
value: number | null | undefined,
@@ -41,7 +36,6 @@ export class TaskTemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
) {}
async findAll(): Promise<TaskTemplate[]> {
@@ -145,53 +139,6 @@ export class TaskTemplatesService {
return template;
}
/**
* Lazily catches up any template whose nextDueDate has passed: creates ONE
* task for the most recent missed occurrence (due on that date) and moves
* nextDueDate past today. A weekly template nobody looked at for two months
* yields one reminder, not eight stale duplicates.
*
* The update's WHERE clause matches the previous nextDueDate
* (compare-and-swap), so concurrent calls from multiple API replicas cannot
* double-fire the same occurrence.
*/
async generateDueTasks(): Promise<void> {
const now = new Date();
const dueTemplates = await this.prisma.taskTemplate.findMany({
where: { active: true, nextDueDate: { lte: now } },
});
for (const template of dueTemplates) {
let occurrence = template.nextDueDate;
let nextDueDate = computeNextDueDate(template, occurrence);
// Bounded: even a daily template several years behind stays cheap.
for (let step = 0; step < 5000 && nextDueDate <= now; step++) {
occurrence = nextDueDate;
nextDueDate = computeNextDueDate(template, occurrence);
}
const claim = await this.prisma.taskTemplate.updateMany({
where: { id: template.id, nextDueDate: template.nextDueDate },
data: { nextDueDate },
});
if (claim.count === 0) {
continue;
}
const task: Task = await this.prisma.task.create({
data: {
title: template.title,
description: template.description,
priority: template.priority,
dueDate: occurrence,
assignedUserId: template.assignedUserId,
relatedType: 'GENERAL',
sourceTemplateId: template.id,
},
});
await this.webhooks.dispatch('task.created', task);
}
}
private assertRecurrenceFieldsPresent(input: RecurrenceInput): void {
if (
input.recurrenceType === 'YEARLY' &&
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common';
import { TaskTemplatesModule } from '../task-templates/task-templates.module';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [TaskTemplatesModule],
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService],
+458 -64
View File
@@ -2,47 +2,88 @@ import { NotFoundException } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { TaskTemplatesService } from '../task-templates/task-templates.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
describe('TasksService', () => {
let service: TasksService;
let prisma: {
let tx: {
task: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
findUnique: jest.Mock;
findUniqueOrThrow: jest.Mock;
};
taskTemplate: { updateMany: jest.Mock };
};
let prisma: {
$connect: jest.Mock;
$transaction: jest.Mock;
task: { findMany: jest.Mock; findUnique: jest.Mock };
taskTemplate: { findMany: jest.Mock };
};
let auditLog: { record: jest.Mock };
let taskTemplates: { generateDueTasks: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
let events: { record: jest.Mock };
const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' };
const task = {
id: 'task-1',
title: 'Beute prüfen',
status: 'OPEN',
dueDate: null,
relatedType: 'GENERAL',
relatedId: null,
completedAt: null,
};
const template = {
id: 'template-1',
title: 'Varroa-Kontrolle',
description: 'Windel einlegen',
priority: 'HIGH',
assignedUserId: 'user-2',
recurrenceType: 'MONTHLY',
recurrenceMonth: null,
recurrenceDayOfMonth: 15,
intervalDays: null,
nextDueDate: new Date('2026-03-15T00:00:00.000Z'),
active: true,
};
beforeEach(() => {
prisma = {
tx = {
task: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
// The stored row: defaults filled in like the database does.
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({
id: 'task-new',
status: 'OPEN',
relatedType: 'GENERAL',
relatedId: null,
dueDate: null,
...data,
}),
),
update: jest.fn(),
updateMany: jest.fn(),
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
},
taskTemplate: { updateMany: jest.fn() },
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
taskTemplates = {
generateDueTasks: jest.fn().mockResolvedValue(undefined),
prisma = {
$connect: jest.fn(),
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
task: { findMany: jest.fn(), findUnique: jest.fn() },
taskTemplate: { findMany: jest.fn().mockResolvedValue([]) },
};
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
events = { record: jest.fn().mockResolvedValue(undefined) };
service = new TasksService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
taskTemplates as unknown as TaskTemplatesService,
calendarSync as unknown as CalendarSyncService,
events as unknown as DomainEventsService,
);
});
@@ -51,7 +92,13 @@ describe('TasksService', () => {
const result = await service.findAll({});
expect(taskTemplates.generateDueTasks).toHaveBeenCalled();
expect(prisma.taskTemplate.findMany).toHaveBeenCalledWith({
where: { active: true, nextDueDate: { lte: expect.any(Date) as Date } },
});
// Generated first, so the list already contains the new tasks.
const [generatedAt] = prisma.taskTemplate.findMany.mock.invocationCallOrder;
const [listedAt] = prisma.task.findMany.mock.invocationCallOrder;
expect(generatedAt).toBeLessThan(listedAt);
expect(result).toEqual([task]);
});
@@ -62,69 +109,416 @@ describe('TasksService', () => {
);
});
it('create stores the acting user as creator', async () => {
prisma.task.create.mockResolvedValue(task);
describe('createInTx', () => {
it('createInTx writes task, audit entry and TaskCreated on the same client', async () => {
await service.createInTx(
tx as never,
{ title: 'T', dueDate: new Date('2027-05-01T00:00:00Z') },
'u1',
);
await service.create({ title: 'Beute prüfen' }, 'user-1');
expect(tx.task.create).toHaveBeenCalledWith({
data: {
title: 'T',
dueDate: new Date('2027-05-01T00:00:00Z'),
createdByUserId: 'u1',
},
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Task',
entityId: 'task-new',
action: 'CREATE',
userId: 'u1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'TaskCreated',
aggregateType: 'Task',
aggregateId: 'task-new',
actorUserId: 'u1',
payload: {
relatedType: 'GENERAL',
relatedId: null,
dueDate: '2027-05-01',
},
}),
);
});
const [[createArg]] = prisma.task.create.mock.calls as [
[{ data: { title: string; createdByUserId: string } }],
it('announces a task without a due date with dueDate null', async () => {
await service.createInTx(
tx as never,
{ title: 'T', relatedType: 'COLONY', relatedId: 'colony-1' },
'u1',
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
payload: {
relatedType: 'COLONY',
relatedId: 'colony-1',
dueDate: null,
},
}),
);
});
it('refuses the root client - the task would commit without its event', async () => {
await expect(
service.createInTx(prisma as never, { title: 'T' }, 'u1'),
).rejects.toThrow('TasksService.createInTx needs the transaction client');
expect(tx.task.create).not.toHaveBeenCalled();
});
});
it('create stores the acting user as creator in one transaction with its event', async () => {
await service.create(
{ title: 'Beute prüfen', dueDate: '2026-08-01T00:00:00.000Z' },
'user-1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
const [[createArg]] = tx.task.create.mock.calls as [
[{ data: { title: string; createdByUserId: string; dueDate: Date } }],
];
expect(createArg.data.title).toBe('Beute prüfen');
expect(createArg.data.createdByUserId).toBe('user-1');
expect(createArg.data.dueDate).toEqual(
new Date('2026-08-01T00:00:00.000Z'),
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'TaskCreated',
payload: expect.objectContaining({ dueDate: '2026-08-01' }) as unknown,
}),
);
});
it('complete sets status DONE and completedAt', async () => {
prisma.task.findUnique.mockResolvedValue(task);
prisma.task.update.mockResolvedValue({ ...task, status: 'DONE' });
describe('update', () => {
const dueDate = new Date('2026-08-01T00:00:00.000Z');
await service.complete('task-1', 'user-1');
it('records TaskUpdated with status and due date next to the audit entry', async () => {
tx.task.findUnique.mockResolvedValue({ ...task, dueDate });
const updated = { ...task, dueDate, status: 'IN_PROGRESS' };
tx.task.update.mockResolvedValue(updated);
const [[updateArg]] = prisma.task.update.mock.calls as [
[{ data: { status: string; completedAt: Date } }],
];
expect(updateArg.data.status).toBe('DONE');
expect(updateArg.data.completedAt).toBeInstanceOf(Date);
});
const result = await service.update(
'task-1',
{ status: 'IN_PROGRESS' },
'user-1',
);
describe('update keeps the calendar entry in step', () => {
const dueDate = new Date('2026-08-01');
it('re-enqueues an open task with a due date so a moved date moves the event', async () => {
prisma.task.findUnique.mockResolvedValue({ ...task, dueDate });
prisma.task.update.mockResolvedValue({ ...task, dueDate });
await service.update('task-1', { dueDate: '2026-08-01' }, 'user-1');
expect(calendarSync.enqueue).toHaveBeenCalledWith('TASK', 'task-1');
expect(calendarSync.cancel).not.toHaveBeenCalled();
expect(result).toBe(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Task',
entityId: 'task-1',
action: 'UPDATE',
userId: 'user-1',
afterData: updated,
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'TaskUpdated',
aggregateType: 'Task',
aggregateId: 'task-1',
actorUserId: 'user-1',
payload: { status: 'IN_PROGRESS', dueDate: '2026-08-01' },
});
});
it('clears the due date on null and removes the event', async () => {
prisma.task.findUnique.mockResolvedValue({ ...task, dueDate });
prisma.task.update.mockResolvedValue({ ...task, dueDate: null });
it('clears the due date on null', async () => {
tx.task.findUnique.mockResolvedValue({ ...task, dueDate });
tx.task.update.mockResolvedValue({ ...task, dueDate: null });
await service.update('task-1', { dueDate: null as never }, 'user-1');
const [[updateArg]] = prisma.task.update.mock.calls as [
const [[updateArg]] = tx.task.update.mock.calls as [
[{ data: { dueDate?: Date | null } }],
];
expect(updateArg.data.dueDate).toBeNull();
expect(calendarSync.cancel).toHaveBeenCalledWith('TASK', 'task-1');
expect(calendarSync.enqueue).not.toHaveBeenCalled();
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'TaskUpdated',
payload: { status: 'OPEN', dueDate: null },
}),
);
});
it.each(['DONE', 'CANCELLED'] as const)(
'removes the event when the task is set to %s directly',
async (status) => {
prisma.task.findUnique.mockResolvedValue({ ...task, dueDate });
prisma.task.update.mockResolvedValue({ ...task, dueDate, status });
describe('into DONE', () => {
const done = { ...task, status: 'DONE', completedAt: new Date() };
await service.update('task-1', { status }, 'user-1');
it('completes through the conditional write and records TaskCompleted once, next to TaskUpdated', async () => {
tx.task.findUnique.mockResolvedValue(task);
tx.task.updateMany.mockResolvedValue({ count: 1 });
tx.task.update.mockResolvedValue(done);
expect(calendarSync.cancel).toHaveBeenCalledWith('TASK', 'task-1');
expect(calendarSync.enqueue).not.toHaveBeenCalled();
},
);
await service.update('task-1', { status: 'DONE' }, 'user-1');
// Same rule as complete(): status and completedAt only if not DONE.
const [[markArg]] = tx.task.updateMany.mock.calls as [
[{ where: unknown; data: { status: string; completedAt: Date } }],
];
expect(markArg.where).toEqual({
id: 'task-1',
status: { not: 'DONE' },
});
expect(markArg.data.status).toBe('DONE');
expect(markArg.data.completedAt).toBeInstanceOf(Date);
// The edit itself leaves completedAt to that write.
const [[updateArg]] = tx.task.update.mock.calls as [
[{ data: { completedAt?: Date | null } }],
];
expect(updateArg.data.completedAt).toBeUndefined();
expect(tx.task.updateMany.mock.invocationCallOrder[0]).toBeLessThan(
tx.task.update.mock.invocationCallOrder[0],
);
const recorded = events.record.mock.calls as [unknown, unknown][];
expect(recorded.map(([, event]) => event)).toEqual([
expect.objectContaining({
type: 'TaskUpdated',
payload: { status: 'DONE', dueDate: null },
}),
{
type: 'TaskCompleted',
aggregateType: 'Task',
aggregateId: 'task-1',
actorUserId: 'user-1',
payload: { relatedType: 'GENERAL', relatedId: null },
},
]);
expect(recorded.every(([client]) => client === tx)).toBe(true);
});
it('records no TaskCompleted when a parallel request completed the task first', async () => {
tx.task.findUnique.mockResolvedValue(task);
tx.task.updateMany.mockResolvedValue({ count: 0 });
tx.task.update.mockResolvedValue(done);
await service.update('task-1', { status: 'DONE' }, 'user-1');
expect(events.record).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({ type: 'TaskUpdated' }),
);
});
it('records no TaskCompleted for a task that is DONE already', async () => {
tx.task.findUnique.mockResolvedValue(done);
tx.task.update.mockResolvedValue({ ...done, title: 'Neu' });
await service.update(
'task-1',
{ status: 'DONE', title: 'Neu' },
'user-1',
);
expect(tx.task.updateMany).not.toHaveBeenCalled();
const [[updateArg]] = tx.task.update.mock.calls as [
[{ data: { completedAt?: Date | null } }],
];
expect(updateArg.data.completedAt).toBeUndefined();
expect(events.record).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({ type: 'TaskUpdated' }),
);
});
});
it('clears completedAt when a done task is reopened', async () => {
tx.task.findUnique.mockResolvedValue({
...task,
status: 'DONE',
completedAt: new Date(),
});
tx.task.update.mockResolvedValue(task);
await service.update('task-1', { status: 'OPEN' }, 'user-1');
const [[updateArg]] = tx.task.update.mock.calls as [
[{ data: { completedAt?: Date | null } }],
];
expect(updateArg.data.completedAt).toBeNull();
expect(tx.task.updateMany).not.toHaveBeenCalled();
expect(events.record).toHaveBeenCalledTimes(1);
});
it('throws NotFoundException and records nothing for an unknown task', async () => {
tx.task.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { title: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(events.record).not.toHaveBeenCalled();
});
});
describe('complete', () => {
it('complete records TaskCompleted once and nothing on a repeated complete', async () => {
const done = {
...task,
relatedType: 'COLONY',
relatedId: 'colony-1',
status: 'DONE',
completedAt: new Date(),
};
tx.task.findUnique.mockResolvedValueOnce({
...task,
relatedType: 'COLONY',
relatedId: 'colony-1',
});
tx.task.updateMany.mockResolvedValueOnce({ count: 1 });
tx.task.findUniqueOrThrow.mockResolvedValueOnce(done);
await expect(service.complete('task-1', 'user-1')).resolves.toBe(done);
const [[updateArg]] = tx.task.updateMany.mock.calls as [
[{ where: unknown; data: { status: string; completedAt: Date } }],
];
expect(updateArg.where).toEqual({
id: 'task-1',
status: { not: 'DONE' },
});
expect(updateArg.data.status).toBe('DONE');
expect(updateArg.data.completedAt).toBeInstanceOf(Date);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Task',
action: 'UPDATE',
afterData: done,
}),
tx,
);
expect(events.record).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'TaskCompleted',
aggregateType: 'Task',
aggregateId: 'task-1',
actorUserId: 'user-1',
payload: { relatedType: 'COLONY', relatedId: 'colony-1' },
});
// The second tap: already DONE - no write, no audit entry, no event.
tx.task.findUnique.mockResolvedValueOnce(done);
await expect(service.complete('task-1', 'user-1')).resolves.toBe(done);
expect(tx.task.updateMany).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledTimes(1);
});
it('records nothing when a parallel request completed the task first', async () => {
const done = { ...task, status: 'DONE', completedAt: new Date() };
tx.task.findUnique.mockResolvedValue(task);
tx.task.updateMany.mockResolvedValue({ count: 0 });
tx.task.findUniqueOrThrow.mockResolvedValue(done);
await expect(service.complete('task-1', 'user-1')).resolves.toBe(done);
expect(auditLog.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('throws NotFoundException for an unknown task', async () => {
tx.task.findUnique.mockResolvedValue(null);
await expect(
service.complete('missing', 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.task.updateMany).not.toHaveBeenCalled();
});
});
describe('generateFromTemplates', () => {
const now = new Date('2026-05-20T08:00:00.000Z');
it('generateFromTemplates creates no task when another replica already claimed the occurrence', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([template]);
tx.taskTemplate.updateMany.mockResolvedValue({ count: 0 });
await expect(service.generateFromTemplates(now)).resolves.toBe(0);
expect(tx.task.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('creates one task for the latest missed occurrence and moves nextDueDate past now', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([template]);
tx.taskTemplate.updateMany.mockResolvedValue({ count: 1 });
await expect(service.generateFromTemplates(now)).resolves.toBe(1);
// 2026-03-15 and 2026-04-15 were missed; 2026-05-15 is the latest.
expect(tx.taskTemplate.updateMany).toHaveBeenCalledWith({
where: { id: 'template-1', nextDueDate: template.nextDueDate },
data: { nextDueDate: new Date('2026-06-15T00:00:00.000Z') },
});
expect(tx.task.create).toHaveBeenCalledTimes(1);
expect(tx.task.create).toHaveBeenCalledWith({
data: {
title: 'Varroa-Kontrolle',
description: 'Windel einlegen',
priority: 'HIGH',
dueDate: new Date('2026-05-15T00:00:00.000Z'),
assignedUserId: 'user-2',
relatedType: 'GENERAL',
sourceTemplateId: 'template-1',
createdByUserId: undefined,
},
});
// Claim and task in one transaction: both or neither.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
it('a template task gets an audit entry without a user', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([template]);
tx.taskTemplate.updateMany.mockResolvedValue({ count: 1 });
await service.generateFromTemplates(now);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Task',
action: 'CREATE',
userId: undefined,
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'TaskCreated',
actorUserId: null,
payload: {
relatedType: 'GENERAL',
relatedId: null,
dueDate: '2026-05-15',
},
}),
);
});
it('does nothing when no template is due', async () => {
prisma.taskTemplate.findMany.mockResolvedValue([]);
await expect(service.generateFromTemplates(now)).resolves.toBe(0);
expect(prisma.taskTemplate.findMany).toHaveBeenCalledWith({
where: { active: true, nextDueDate: { lte: now } },
});
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
});
+286 -85
View File
@@ -1,27 +1,39 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { assertTransactionClient, DbClient } from '../prisma/db-client';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { TaskTemplatesService } from '../task-templates/task-templates.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { utcDay } from '../domain-events/domain-event-types';
import {
AutomationTriggerType,
Prisma,
Task,
TaskPriority,
TaskRelatedType,
TaskStatus,
TaskTemplate,
} from '../../generated/prisma';
import { computeNextDueDate } from '../task-templates/recurrence';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { optionalDate } from '../common/patch-values';
/**
* Keeps completedAt in step with status when the status is edited directly:
* set when a task becomes DONE, cleared when it is reopened or cancelled -
* a reopened task must not keep claiming it was finished.
*/
function completedAtForStatusChange(
before: Task,
nextStatus: TaskStatus | undefined,
): Date | null | undefined {
if (nextStatus === undefined || nextStatus === before.status) {
return undefined;
}
return nextStatus === 'DONE' ? new Date() : null;
// Bounds the catch-up of one template: even a daily template several years
// behind stays cheap.
const MAX_CATCH_UP_STEPS = 5000;
/** A new task, wherever it is started: task page, inspection, template, rule. */
export interface NewTaskInput {
title: string;
description?: string | null;
priority?: TaskPriority;
dueDate?: Date | null;
assignedUserId?: string | null;
relatedType?: TaskRelatedType;
relatedId?: string | null;
sourceInspectionId?: string | null;
sourceTemplateId?: string | null;
sourceAutomationTrigger?: AutomationTriggerType | null;
}
export interface TaskFilter {
@@ -29,18 +41,68 @@ export interface TaskFilter {
priority?: TaskPriority;
}
/**
* Keeps completedAt in step with status when the status is edited directly:
* cleared when a task is reopened or cancelled - a reopened task must not
* keep claiming it was finished. Into DONE it is set together with the
* status by TasksService.markDoneIn, so it is left alone here.
*/
function completedAtForStatusChange(
before: Task,
nextStatus: TaskStatus | undefined,
): null | undefined {
if (
nextStatus === undefined ||
nextStatus === before.status ||
nextStatus === 'DONE'
) {
return undefined;
}
return null;
}
/** The due date as event payloads carry it: its UTC day, or null. */
function dueDay(dueDate: Date | null): string | null {
return dueDate === null ? null : utcDay(dueDate);
}
/**
* The most recent occurrence a template missed up to `now`, and the due date
* after it. A weekly template nobody looked at for two months yields one
* reminder, not eight stale duplicates.
*/
function latestMissedOccurrence(
template: TaskTemplate,
now: Date,
): { occurrence: Date; nextDueDate: Date } {
let occurrence = template.nextDueDate;
let nextDueDate = computeNextDueDate(template, occurrence);
for (let step = 0; step < MAX_CATCH_UP_STEPS && nextDueDate <= now; step++) {
occurrence = nextDueDate;
nextDueDate = computeNextDueDate(template, occurrence);
}
return { occurrence, nextDueDate };
}
/**
* Every task the app creates or edits goes through here (automation follows
* with Task 10; backup restore excepted), together with its audit entry and
* domain event in one transaction: TaskCreated, TaskUpdated, TaskCompleted.
* The calendar entry and the webhooks follow from the event (handlers
* calendar-sync.task and webhooks.forward), never from a direct call.
*/
@Injectable()
export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly taskTemplates: TaskTemplatesService,
private readonly calendarSync: CalendarSyncService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: TaskFilter): Promise<Task[]> {
await this.taskTemplates.generateDueTasks();
// Lazily, so a due template shows up without waiting for the
// task-templates job.
await this.generateFromTemplates();
return this.prisma.task.findMany({
where: filter,
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
@@ -48,33 +110,55 @@ export class TasksService {
}
async findById(id: string): Promise<Task> {
const task = await this.prisma.task.findUnique({ where: { id } });
if (!task) {
throw new NotFoundException('Task not found');
}
return this.findIn(this.prisma, id);
}
/**
* Creates a task with its audit entry and TaskCreated on the caller's
* transaction - all or nothing. `actingUserId` is null for a task the
* system creates (template, automation rule).
*/
async createInTx(
tx: Prisma.TransactionClient,
input: NewTaskInput,
actingUserId: string | null,
): Promise<Task> {
assertTransactionClient(tx, 'TasksService.createInTx');
const task = await tx.task.create({
data: { ...input, createdByUserId: actingUserId ?? undefined },
});
await this.auditLog.record(
{
entityType: 'Task',
entityId: task.id,
action: 'CREATE',
userId: actingUserId ?? undefined,
afterData: task,
},
tx,
);
await this.events.record(tx, {
type: 'TaskCreated',
aggregateType: 'Task',
aggregateId: task.id,
payload: {
relatedType: task.relatedType,
relatedId: task.relatedId,
dueDate: dueDay(task.dueDate),
},
actorUserId: actingUserId,
});
return task;
}
async create(dto: CreateTaskDto, actingUserId: string): Promise<Task> {
const task = await this.prisma.task.create({
data: {
...dto,
dueDate: optionalDate(dto.dueDate),
createdByUserId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'Task',
entityId: task.id,
action: 'CREATE',
userId: actingUserId,
afterData: task,
});
await this.webhooks.dispatch('task.created', task);
if (task.dueDate) {
await this.calendarSync.enqueue('TASK', task.id);
}
return task;
return this.prisma.$transaction((tx) =>
this.createInTx(
tx,
{ ...dto, dueDate: optionalDate(dto.dueDate) },
actingUserId,
),
);
}
async update(
@@ -82,54 +166,171 @@ export class TasksService {
dto: UpdateTaskDto,
actingUserId: string,
): Promise<Task> {
const before = await this.findById(id);
const task = await this.prisma.task.update({
where: { id },
data: {
...dto,
dueDate: optionalDate(dto.dueDate),
completedAt: completedAtForStatusChange(before, dto.status),
},
return this.prisma.$transaction(async (tx) => {
const before = await this.findIn(tx, id);
// An edit into DONE completes the task like "Erledigt" does - by the
// same conditional write, so it is announced once, also when both
// arrive at the same moment.
const completed =
dto.status === 'DONE' && before.status !== 'DONE'
? await this.markDoneIn(tx, id)
: false;
const task = await tx.task.update({
where: { id },
data: {
...dto,
dueDate: optionalDate(dto.dueDate),
completedAt: completedAtForStatusChange(before, dto.status),
},
});
await this.auditLog.record(
{
entityType: 'Task',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: task,
},
tx,
);
// The calendar follows: a moved date moves the event, a finished or
// cancelled task leaves it, a reopened one returns to it.
await this.events.record(tx, {
type: 'TaskUpdated',
aggregateType: 'Task',
aggregateId: id,
payload: { status: task.status, dueDate: dueDay(task.dueDate) },
actorUserId: actingUserId,
});
if (completed) {
await this.recordCompleted(tx, task, actingUserId);
}
return task;
});
await this.auditLog.record({
entityType: 'Task',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: task,
});
// Like complete(): a finished or cancelled task leaves the calendar,
// a reopened one returns to it.
if (task.dueDate && task.status !== 'DONE' && task.status !== 'CANCELLED') {
await this.calendarSync.enqueue('TASK', task.id);
} else {
await this.calendarSync.cancel('TASK', task.id);
}
return task;
}
async complete(id: string, actingUserId: string): Promise<Task> {
const before = await this.findById(id);
// A second tap on "Erledigt" (or a retried request) must not move the
// completion time or announce the completion a second time.
if (before.status === 'DONE') {
return before;
}
const task = await this.prisma.task.update({
where: { id },
return this.prisma.$transaction(async (tx) => {
const before = await this.findIn(tx, id);
// A second tap on "Erledigt" (or a retried request) must not move the
// completion time or announce the completion a second time.
if (before.status === 'DONE') {
return before;
}
const completed = await this.markDoneIn(tx, id);
const task = await tx.task.findUniqueOrThrow({ where: { id } });
if (!completed) {
return task;
}
await this.auditLog.record(
{
entityType: 'Task',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: task,
},
tx,
);
await this.recordCompleted(tx, task, actingUserId);
return task;
});
}
/**
* The transition into DONE, as a conditional write: of two requests at
* the same moment (complete, or an edit into DONE) the second one waits
* for the first one's commit and then matches nothing. True if this call
* completed the task.
*/
private async markDoneIn(
tx: Prisma.TransactionClient,
id: string,
): Promise<boolean> {
const { count } = await tx.task.updateMany({
where: { id, status: { not: 'DONE' } },
data: { status: 'DONE', completedAt: new Date() },
});
await this.auditLog.record({
entityType: 'Task',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: task,
return count === 1;
}
private async recordCompleted(
tx: Prisma.TransactionClient,
task: Task,
actingUserId: string,
): Promise<void> {
await this.events.record(tx, {
type: 'TaskCompleted',
aggregateType: 'Task',
aggregateId: task.id,
payload: { relatedType: task.relatedType, relatedId: task.relatedId },
actorUserId: actingUserId,
});
await this.webhooks.dispatch('task.completed', task);
await this.calendarSync.cancel('TASK', task.id);
}
/**
* Catches up every active template whose nextDueDate has passed: ONE task
* for the most recent missed occurrence (due on that date), and
* nextDueDate moved past `now`. Runs lazily before the task list and as
* the task-templates job. Returns how many tasks were created.
*/
async generateFromTemplates(now: Date = new Date()): Promise<number> {
const dueTemplates = await this.prisma.taskTemplate.findMany({
where: { active: true, nextDueDate: { lte: now } },
});
let created = 0;
for (const template of dueTemplates) {
const task = await this.prisma.$transaction((tx) =>
this.generateFromTemplateIn(tx, template, now),
);
if (task !== null) {
created += 1;
}
}
return created;
}
/**
* The claim is a compare-and-swap on the nextDueDate that was read: of
* several replicas generating at once, exactly one gets the occurrence.
* Claim and task share the transaction, so a failed task write gives the
* occurrence back instead of losing it.
*/
private async generateFromTemplateIn(
tx: Prisma.TransactionClient,
template: TaskTemplate,
now: Date,
): Promise<Task | null> {
const { occurrence, nextDueDate } = latestMissedOccurrence(template, now);
const claim = await tx.taskTemplate.updateMany({
where: { id: template.id, nextDueDate: template.nextDueDate },
data: { nextDueDate },
});
if (claim.count === 0) {
return null;
}
return this.createInTx(
tx,
{
title: template.title,
description: template.description,
priority: template.priority,
dueDate: occurrence,
assignedUserId: template.assignedUserId,
relatedType: 'GENERAL',
sourceTemplateId: template.id,
},
null,
);
}
private async findIn(db: DbClient, id: string): Promise<Task> {
const task = await db.task.findUnique({ where: { id } });
if (!task) {
throw new NotFoundException('Task not found');
}
return task;
}
}
@@ -0,0 +1,16 @@
import { OmitType } from '@nestjs/swagger';
import { IsBulkColonyIds } from '../../common/bulk/bulk-validation';
import { CreateTreatmentDto } from './create-treatment.dto';
/**
* One treatment for several colonies: every field of a single treatment,
* entered once and stored on each record. `batchNumber` is the drug lot from
* the package (Bestandsbuch) - entered by the user, never generated; the
* records of one bulk entry are grouped by `bulkId` instead.
*/
export class BulkCreateTreatmentDto extends OmitType(CreateTreatmentDto, [
'colonyId',
] as const) {
@IsBulkColonyIds()
colonyIds!: string[];
}
@@ -12,6 +12,7 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { BulkCreateTreatmentDto } from './dto/bulk-create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
import { WithdrawalStatusQueryDto } from './dto/withdrawal-status-query.dto';
import { LastByProductQueryDto } from './dto/last-by-product-query.dto';
@@ -75,6 +76,17 @@ export class TreatmentsController {
return this.treatmentsService.create(dto, actor.id);
}
/** One treatment for several colonies - all records or none. */
@RequirePermission('treatments:create')
@Idempotent()
@Post('bulk')
createBulk(
@Body() dto: BulkCreateTreatmentDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.treatmentsService.createBulk(dto, actor.id);
}
@RequirePermission('treatments:update')
@Patch(':id')
update(
@@ -1,12 +1,10 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { TreatmentsController } from './treatments.controller';
import { ColonyTreatmentsController } from './colony-treatments.controller';
import { TreatmentPrefillService } from './treatment-prefill.service';
import { TreatmentsService } from './treatments.service';
@Module({
imports: [AutomationModule],
controllers: [TreatmentsController, ColonyTreatmentsController],
providers: [TreatmentsService, TreatmentPrefillService],
exports: [TreatmentsService],
@@ -1,23 +1,27 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service';
import { TreatmentsService } from './treatments.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
describe('TreatmentsService', () => {
let service: TreatmentsService;
let prisma: {
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let tx: {
treatment: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
colony: { findUnique: jest.Mock };
colony: { findUnique: jest.Mock; findMany: jest.Mock };
$queryRaw: jest.Mock;
};
let auditLog: { record: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
let events: { record: jest.Mock };
const treatment = {
id: 'treatment-1',
@@ -26,24 +30,30 @@ describe('TreatmentsService', () => {
};
beforeEach(() => {
prisma = {
tx = {
treatment: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
colony: {
findUnique: jest.fn().mockResolvedValue({ apiaryId: 'apiary-1' }),
findMany: jest.fn().mockResolvedValue([]),
},
$queryRaw: jest.fn().mockResolvedValue([]),
};
prisma = {
treatment: { findMany: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn() };
calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
events = { record: jest.fn() };
service = new TreatmentsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
calendarSync as unknown as CalendarSyncService,
{ evaluateTreatment: jest.fn() } as unknown as AutomationService,
events as unknown as DomainEventsService,
);
});
@@ -55,14 +65,14 @@ describe('TreatmentsService', () => {
});
it('create stores the acting user and product', async () => {
prisma.treatment.create.mockResolvedValue(treatment);
tx.treatment.create.mockResolvedValue(treatment);
await service.create(
{ colonyId: 'colony-1', date: '2026-01-01', product: 'Ameisensäure' },
'user-1',
);
const [[createArg]] = prisma.treatment.create.mock.calls as [
const [[createArg]] = tx.treatment.create.mock.calls as [
[
{
data: {
@@ -82,9 +92,9 @@ describe('TreatmentsService', () => {
});
it('update writes an audit log entry with before and after data', async () => {
prisma.treatment.findUnique.mockResolvedValue(treatment);
tx.treatment.findUnique.mockResolvedValue(treatment);
const updated = { ...treatment, notes: 'geändert' };
prisma.treatment.update.mockResolvedValue(updated);
tx.treatment.update.mockResolvedValue(updated);
const result = await service.update(
'treatment-1',
@@ -101,16 +111,17 @@ describe('TreatmentsService', () => {
beforeData: treatment,
afterData: updated,
}),
tx,
);
});
it('update clears start and end date on null', async () => {
prisma.treatment.findUnique.mockResolvedValue({
tx.treatment.findUnique.mockResolvedValue({
...treatment,
startDate: new Date('2026-07-01'),
endDate: new Date('2026-07-14'),
});
prisma.treatment.update.mockResolvedValue(treatment);
tx.treatment.update.mockResolvedValue(treatment);
await service.update(
'treatment-1',
@@ -118,22 +129,266 @@ describe('TreatmentsService', () => {
'user-1',
);
const [[updateArg]] = prisma.treatment.update.mock.calls as [
const [[updateArg]] = tx.treatment.update.mock.calls as [
[{ data: { startDate?: Date | null; endDate?: Date | null } }],
];
expect(updateArg.data.startDate).toBeNull();
expect(updateArg.data.endDate).toBeNull();
});
it('update re-enqueues the calendar sync so a changed date moves the event', async () => {
prisma.treatment.findUnique.mockResolvedValue(treatment);
prisma.treatment.update.mockResolvedValue(treatment);
it('create writes treatment, audit entry and TreatmentRecorded in one transaction', async () => {
tx.treatment.create.mockResolvedValue(treatment);
await service.create(
{ colonyId: 'colony-1', date: '2026-01-01', product: 'Ameisensäure' },
'user-1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Treatment',
entityId: 'treatment-1',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'TreatmentRecorded',
aggregateType: 'Treatment',
aggregateId: 'treatment-1',
payload: { colonyId: 'colony-1' },
actorUserId: 'user-1',
});
});
it('update takes "before" from the locked row inside the transaction, not from a pre-read', async () => {
const stale = { ...treatment, notes: 'veraltet' };
const locked = { ...treatment, notes: 'gesperrt gelesen' };
prisma.treatment.findUnique.mockResolvedValue(stale);
tx.treatment.findUnique.mockResolvedValue(locked);
tx.treatment.update.mockResolvedValue(treatment);
await service.update('treatment-1', { notes: 'neu' }, 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ beforeData: locked }),
tx,
);
});
it('update reads the new colony apiary inside the transaction when re-filed', async () => {
tx.treatment.findUnique.mockResolvedValue(treatment);
tx.colony.findUnique.mockResolvedValue({ apiaryId: 'apiary-2' });
tx.treatment.update.mockResolvedValue(treatment);
await service.update('treatment-1', { colonyId: 'colony-2' }, 'user-1');
const [[updateArg]] = tx.treatment.update.mock.calls as [
[{ data: { apiaryId?: string } }],
];
expect(updateArg.data.apiaryId).toBe('apiary-2');
});
it('create reads and locks the colony inside the transaction', async () => {
tx.treatment.create.mockResolvedValue(treatment);
await service.create(
{ colonyId: 'colony-1', date: '2026-01-01', product: 'Ameisensäure' },
'user-1',
);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(tx.colony.findUnique).toHaveBeenCalled();
});
describe('createBulk', () => {
const shared = {
date: '2026-08-01',
product: 'Oxuvar',
batchNumber: 'CH-1',
};
type CreateArg = { data: Record<string, unknown> & { colonyId: string } };
beforeEach(() => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'WEAK', apiaryId: 'apiary-2' },
]);
tx.treatment.create.mockImplementation(({ data }: CreateArg) =>
Promise.resolve({ ...data, id: `treatment-of-${data.colonyId}` }),
);
});
it('createBulk writes one treatment per colony with the shared fields and that colony apiary', async () => {
const result = await service.createBulk(
{ ...shared, colonyIds: ['colony-2', 'colony-1'] },
'user-1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: BULK_TRANSACTION_TIMEOUT_MS,
});
expect(result.bulkId).toEqual(expect.any(String));
expect(result.records.map((record) => record.id)).toEqual([
'treatment-of-colony-2',
'treatment-of-colony-1',
]);
const rows = (tx.treatment.create.mock.calls as [CreateArg][]).map(
([arg]) => arg.data,
);
expect(rows).toEqual([
expect.objectContaining({
colonyId: 'colony-2',
apiaryId: 'apiary-2',
product: 'Oxuvar',
batchNumber: 'CH-1',
date: new Date('2026-08-01'),
userId: 'user-1',
}),
expect.objectContaining({
colonyId: 'colony-1',
apiaryId: 'apiary-1',
product: 'Oxuvar',
batchNumber: 'CH-1',
date: new Date('2026-08-01'),
userId: 'user-1',
}),
]);
expect(rows[0]).not.toHaveProperty('colonyIds');
});
it('createBulk writes an audit entry and a TreatmentRecorded with the bulkId per record', async () => {
const { bulkId } = await service.createBulk(
{ ...shared, colonyIds: ['colony-1', 'colony-2'] },
'user-1',
);
expect(auditLog.record).toHaveBeenCalledTimes(2);
for (const colonyId of ['colony-1', 'colony-2']) {
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Treatment',
entityId: `treatment-of-${colonyId}`,
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'TreatmentRecorded',
aggregateType: 'Treatment',
aggregateId: `treatment-of-${colonyId}`,
payload: { colonyId, bulkId },
actorUserId: 'user-1',
});
}
expect(events.record).toHaveBeenCalledTimes(2);
});
it('createBulk stores the same row as a single create', async () => {
await service.create({ ...shared, colonyId: 'colony-1' }, 'user-1');
await service.createBulk(
{ ...shared, colonyIds: ['colony-1'] },
'user-1',
);
const [[single], [bulk]] = tx.treatment.create.mock.calls as [
CreateArg,
][];
expect(bulk).toEqual(single);
});
it('createBulk writes nothing when a colony has ended', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'DISSOLVED', apiaryId: 'apiary-1' },
]);
const error: unknown = await service
.createBulk(
{ ...shared, colonyIds: ['colony-1', 'colony-2'] },
'user-1',
)
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BadRequestException);
expect((error as BadRequestException).getResponse()).toEqual(
expect.objectContaining({ invalidColonyIds: ['colony-2'] }),
);
expect(tx.treatment.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('createBulk rolls back every record when an audit write fails', async () => {
// A transaction that keeps its writes only when the work succeeds.
const committed: unknown[] = [];
prisma.$transaction.mockImplementation(
async (work: (txArg: typeof tx) => Promise<unknown>) => {
const pending: unknown[] = [];
tx.treatment.create.mockImplementation(({ data }: CreateArg) => {
pending.push(data);
return Promise.resolve({
...data,
id: `treatment-of-${data.colonyId}`,
});
});
const result = await work(tx);
committed.push(...pending);
return result;
},
);
auditLog.record
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('audit write failed'));
await expect(
service.createBulk(
{ ...shared, colonyIds: ['colony-1', 'colony-2'] },
'user-1',
),
).rejects.toThrow('audit write failed');
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tx.treatment.create).toHaveBeenCalledTimes(2);
expect(committed).toEqual([]);
// Every write went through the one transaction client.
for (const [, client] of auditLog.record.mock.calls as [
unknown,
unknown,
][]) {
expect(client).toBe(tx);
}
for (const [client] of events.record.mock.calls as [unknown][]) {
expect(client).toBe(tx);
}
});
});
it('update records TreatmentCorrected and no automation', async () => {
tx.treatment.findUnique.mockResolvedValue(treatment);
tx.treatment.update.mockResolvedValue(treatment);
await service.update('treatment-1', { date: '2026-05-02' }, 'user-1');
expect(calendarSync.enqueue).toHaveBeenCalledWith(
'TREATMENT',
'treatment-1',
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'UPDATE' }),
tx,
);
expect(events.record).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'TreatmentCorrected',
aggregateType: 'Treatment',
aggregateId: 'treatment-1',
payload: { colonyId: 'colony-1' },
}),
);
});
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff. Show more