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:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
+96
-24
@@ -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).
|
||||
Generated
+4
-20
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { bootstrapCli } from './cli';
|
||||
import * as runCliModule from './jobs/run-cli';
|
||||
|
||||
const VALID_ENV: NodeJS.ProcessEnv = {
|
||||
DATABASE_URL: 'postgresql://job:secret@db/app',
|
||||
JWT_ACCESS_SECRET: 'a'.repeat(32),
|
||||
};
|
||||
|
||||
describe('bootstrapCli', () => {
|
||||
let runCli: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
runCli = jest.spyOn(runCliModule, 'runCli').mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
// A misconfigured CronJob pod must fail before any module connects.
|
||||
it.each([
|
||||
[
|
||||
'a role of another entry point',
|
||||
{ ...VALID_ENV, APICORE_ROLE: 'worker' },
|
||||
'APICORE_ROLE=worker cannot start dist/cli.js',
|
||||
],
|
||||
[
|
||||
'"all" in production',
|
||||
{ ...VALID_ENV, NODE_ENV: 'production', APICORE_ROLE: 'all' },
|
||||
'APICORE_ROLE=all is for local development and tests only',
|
||||
],
|
||||
[
|
||||
'no role in production',
|
||||
{ ...VALID_ENV, NODE_ENV: 'production' },
|
||||
'APICORE_ROLE is required in production',
|
||||
],
|
||||
[
|
||||
'a missing DATABASE_URL',
|
||||
{ JWT_ACCESS_SECRET: VALID_ENV.JWT_ACCESS_SECRET },
|
||||
'Missing required environment variable(s): DATABASE_URL',
|
||||
],
|
||||
])(
|
||||
'refuses %s before running a job',
|
||||
async (_case, env: NodeJS.ProcessEnv, message: string) => {
|
||||
await expect(bootstrapCli(['cleanup'], env)).rejects.toThrow(message);
|
||||
|
||||
expect(runCli).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('runs the named job with its arguments and returns the exit code', async () => {
|
||||
runCli.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
bootstrapCli(['rekey', '--dry-run'], {
|
||||
...VALID_ENV,
|
||||
APICORE_ROLE: 'job',
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(runCli).toHaveBeenCalledWith(['rekey', '--dry-run']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CliExitCode, runCli } from './jobs/run-cli';
|
||||
import { resolveRole } from './runtime/role';
|
||||
import { assertStartupEnvironment } from './runtime/startup-checks';
|
||||
|
||||
/**
|
||||
* Entry point of the job role: `node dist/cli.js <job> [args]`, one run per
|
||||
* CronJob pod. Role and environment are checked like in main.ts and
|
||||
* worker.ts, before any module connects. Resolves with the exit code:
|
||||
* 0 succeeded, 1 failed, 2 wrong call. `env` is for tests; the modules
|
||||
* themselves read process.env.
|
||||
*/
|
||||
export async function bootstrapCli(
|
||||
argv: readonly string[] = process.argv.slice(2),
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<CliExitCode> {
|
||||
const role = resolveRole('cli', env);
|
||||
assertStartupEnvironment(role, env);
|
||||
return runCli(argv);
|
||||
}
|
||||
|
||||
// Started as `node dist/cli.js`; importing this file starts nothing.
|
||||
if (require.main === module) {
|
||||
void bootstrapCli().then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'] : [],
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user