Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export

# Conflicts:
#	ARCHITECTURE.md
This commit is contained in:
Nick Szaunig committed 2026-10-13 05:58:30 +02:00
commit 9c332d4716
310 files changed
+12177 -3746

No files matched your search

+9 -7
View File
@@ -98,11 +98,11 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. 7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. 8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. 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 (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.
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 – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden. 10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden.
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. 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. 12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. 14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). 15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
@@ -110,7 +110,9 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung. 17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen. 18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen.
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel `BACKUP_ENCRYPTION_KEY` (sonst per HKDF aus `JWT_ACCESS_SECRET`). Nur versiegelte Dateien stellen Benutzer, Rollen, Webhooks und Audit-Log wieder her; Import verlangt Passwort (+ TOTP). Webhook-Secrets liegen verschlüsselt in der DB (Schlüssel aus `JWT_ACCESS_SECRET`) – für eine Wiederherstellung in einem anderen Deployment mit anderem `JWT_ACCESS_SECRET` müssen Webhook-Secrets und MFA neu eingerichtet werden. 19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel `BACKUP_ENCRYPTION_KEY` (sonst per HKDF aus `JWT_ACCESS_SECRET`). Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). Webhook-Secrets liegen verschlüsselt in der DB (Schlüssel aus `JWT_ACCESS_SECRET`) – für eine Wiederherstellung in einem anderen Deployment mit anderem `JWT_ACCESS_SECRET` müssen Webhook-Secrets und MFA neu eingerichtet werden.
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`.
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
## 7. Kubernetes ## 7. Kubernetes
@@ -126,9 +128,9 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie: **Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert wirklich alle Modelle (Betriebsdaten, Benutzer/Rollen/Rechte, Audit-Log, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei; Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`. - **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14) und IdempotencyRecord (Annahme 11); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
- **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer. - **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.
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. - **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 nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
+6
View File
@@ -6,6 +6,12 @@ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN npx prisma generate RUN npx prisma generate
# Qualitaets-Gate: ein Image entsteht nur, wenn Lint, Typecheck und
# Unit-Tests gruen sind. eslint laeuft hier ohne --fix (anders als
# `npm run lint`), damit ein Verstoss den Build abbricht statt still
# korrigiert zu werden. Die e2e-Tests brauchen eine Datenbank und laufen
# deshalb nicht im Image-Build.
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
RUN npm run build RUN npm run build
FROM node:22-alpine AS runtime FROM node:22-alpine AS runtime
@@ -0,0 +1,5 @@
-- Outbox claims: one worker at a time writes an entry to M365, and a
-- revision counter lets it notice an enqueue that arrived during the sync.
-- AlterTable
ALTER TABLE "CalendarSyncOutboxEntry" ADD COLUMN "claimedAt" TIMESTAMP(3),
ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
+11 -1
View File
@@ -925,7 +925,9 @@ model HoneyBatchContainerAllocation {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([honeyBatchId, storageContainerId]) // The name the migration created (an older Prisma shortened it). Mapped
// explicitly so schema and database agree and no rename migration is due.
@@unique([honeyBatchId, storageContainerId], map: "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key")
@@index([storageContainerId]) @@index([storageContainerId])
} }
@@ -1505,6 +1507,14 @@ model CalendarSyncOutboxEntry {
graphEventId String? graphEventId String?
attempts Int @default(0) attempts Int @default(0)
lastError String? lastError String?
// Set while one worker (pod, cron tick, "Jetzt synchronisieren") writes
// this entry to M365, so no second worker creates the same event. A claim
// older than the stale timeout belongs to a crashed worker and is retaken.
claimedAt DateTime?
// Counts every enqueue: a worker only marks the entry SYNCED if nothing
// was enqueued since it claimed it - an edit made during the sync is not
// lost but synced on the next pass.
revision Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -78,6 +78,20 @@ describe('ApiariesService', () => {
); );
}); });
it('deactivate counts every living colony, weak and queenless ones included', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue(existingApiary);
await service.deactivate('apiary-1', 'user-1');
expect(prisma.colony.count).toHaveBeenCalledWith({
where: {
apiaryId: 'apiary-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
});
});
it('deactivate throws ConflictException when active colonies remain', async () => { it('deactivate throws ConflictException when active colonies remain', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary); prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.colony.count.mockResolvedValue(2); prisma.colony.count.mockResolvedValue(2);
@@ -5,6 +5,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { Apiary, ApiaryStatus } from '../../generated/prisma'; import { Apiary, ApiaryStatus } from '../../generated/prisma';
import { CreateApiaryDto } from './dto/create-apiary.dto'; import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto'; import { UpdateApiaryDto } from './dto/update-apiary.dto';
@@ -82,7 +83,7 @@ export class ApiariesService {
// "location" has vanished from the active apiary list). Move the // "location" has vanished from the active apiary list). Move the
// colonies elsewhere or dissolve them first instead. // colonies elsewhere or dissolve them first instead.
const activeColonyCount = await this.prisma.colony.count({ const activeColonyCount = await this.prisma.colony.count({
where: { apiaryId: id, status: 'ACTIVE' }, where: { apiaryId: id, ...LIVING_COLONY_WHERE },
}); });
if (activeColonyCount > 0) { if (activeColonyCount > 0) {
throw new ConflictException( throw new ConflictException(
@@ -1,4 +1,6 @@
import { PartialType } from '@nestjs/swagger'; import { PartialType } from '@nestjs/swagger';
import { CreateApiaryDto } from './create-apiary.dto'; import { CreateApiaryDto } from './create-apiary.dto';
export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {} export class UpdateApiaryDto extends PartialType(CreateApiaryDto, {
skipNullProperties: false,
}) {}
@@ -1,10 +1,10 @@
import { NotFoundException } from '@nestjs/common'; import { Logger, NotFoundException } from '@nestjs/common';
import { AutomationService } from './automation.service'; import { AutomationService } from './automation.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationTriggerType } from '../../generated/prisma'; import { AutomationTriggerType, Prisma } from '../../generated/prisma';
describe('AutomationService', () => { describe('AutomationService', () => {
let service: AutomationService; let service: AutomationService;
@@ -17,6 +17,8 @@ describe('AutomationService', () => {
}; };
automationSequence: { upsert: jest.Mock }; automationSequence: { upsert: jest.Mock };
task: { create: jest.Mock; findFirst: jest.Mock }; task: { create: jest.Mock; findFirst: jest.Mock };
$transaction: jest.Mock;
$queryRaw: jest.Mock;
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let notifications: { record: jest.Mock }; let notifications: { record: jest.Mock };
@@ -57,6 +59,9 @@ describe('AutomationService', () => {
create: jest.fn().mockResolvedValue({ id: 'task-1' }), create: jest.fn().mockResolvedValue({ id: 'task-1' }),
findFirst: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(null),
}, },
// Interactive transactions run against the same mocks.
$transaction: jest.fn((work: (tx: unknown) => unknown) => work(prisma)),
$queryRaw: jest.fn().mockResolvedValue([]),
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
notifications = { record: jest.fn() }; notifications = { record: jest.fn() };
@@ -91,6 +96,122 @@ describe('AutomationService', () => {
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
}); });
// Rule evaluation runs after the triggering record is committed. A failure
// here must not turn that request into an error - the client would retry
// and store the inspection or treatment a second time.
describe('post-commit evaluation', () => {
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.automationRule.upsert.mockRejectedValue(new Error('db down'));
});
afterEach(() => {
errorSpy.mockRestore();
});
const colony = { id: 'colony-1', healthStatus: 'GOOD' };
const evaluations: [string, string, (s: AutomationService) => unknown][] = [
[
'evaluateInspection',
'Inspection inspection-1',
(s) =>
s.evaluateInspection({
id: 'inspection-1',
colonyId: 'colony-1',
} as never),
],
[
'evaluateVarroaMeasurement',
'VarroaMeasurement measurement-1',
(s) =>
s.evaluateVarroaMeasurement({
id: 'measurement-1',
colonyId: 'colony-1',
} as never),
],
[
'evaluateSwarmControlCheck',
'SwarmControlCheck check-1',
(s) =>
s.evaluateSwarmControlCheck({
id: 'check-1',
colonyId: 'colony-1',
} as never),
],
[
'evaluateColonyHealth',
'Colony colony-1',
(s) =>
s.evaluateColonyHealth(
colony as never,
{ ...colony, healthStatus: 'CRITICAL' } as never,
),
],
[
'evaluateStockItem',
'StockItem item-1',
(s) =>
s.evaluateStockItem({
id: 'item-1',
quantity: 1,
minThreshold: 5,
} as never),
],
[
'evaluateRawMaterialLow',
'RawMaterialKind BEESWAX',
(s) => s.evaluateRawMaterialLow('BEESWAX', 100),
],
[
'evaluateTreatment',
'Treatment treatment-1',
(s) =>
s.evaluateTreatment({
id: 'treatment-1',
colonyId: 'colony-1',
date: new Date('2026-08-01'),
endDate: null,
product: 'Ameisensäure',
} as never),
],
[
'evaluateFeeding',
'Feeding feeding-1',
(s) =>
s.evaluateFeeding({
id: 'feeding-1',
colonyId: 'colony-1',
date: new Date('2026-09-01'),
amount: 5,
unit: 'l',
} as never),
],
];
it.each(evaluations)(
'%s never throws and logs the failure with the record',
async (_name, subject, evaluate) => {
await expect(evaluate(service)).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain(subject);
expect(message).toContain('db down');
},
);
it('still throws from generateNumber, which runs before the write', async () => {
await expect(
service.generateNumber('HONEY_BATCH_NUMBER_FORMAT'),
).rejects.toThrow('db down');
expect(errorSpy).not.toHaveBeenCalled();
});
});
describe('evaluateInspection', () => { describe('evaluateInspection', () => {
it('creates a task and notification when queen not seen and no eggs present', async () => { it('creates a task and notification when queen not seen and no eggs present', async () => {
prisma.automationRule.findMany.mockResolvedValue([queenlessRule]); prisma.automationRule.findMany.mockResolvedValue([queenlessRule]);
@@ -238,6 +359,37 @@ describe('AutomationService', () => {
select: { id: true }, select: { id: true },
}); });
}); });
it('checks for an open task and creates one under a lock on the rule row', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
await service.evaluateVarroaMeasurement({
id: 'measurement-6',
colonyId: 'colony-1',
method: 'NATURAL_MITE_FALL',
calculatedValue: 25,
miteCount: 25,
} as never);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
const [sql, ...values] = prisma.$queryRaw.mock.calls[0] as [
TemplateStringsArray,
...unknown[],
];
expect(sql.join('?')).toMatch(
/SELECT id FROM "AutomationRule" WHERE id = \? FOR UPDATE/,
);
expect(values).toEqual(['rule-3']);
// Lock first, then check, then create - all in that one transaction.
const [lockOrder] = prisma.$queryRaw.mock.invocationCallOrder;
const [checkOrder] = prisma.task.findFirst.mock.invocationCallOrder;
const [createOrder] = prisma.task.create.mock.invocationCallOrder;
expect(lockOrder).toBeLessThan(checkOrder);
expect(checkOrder).toBeLessThan(createOrder);
// Webhook and warning follow the commit, outside the lock.
const [dispatchOrder] = webhooks.dispatch.mock.invocationCallOrder;
expect(dispatchOrder).toBeGreaterThan(createOrder);
});
}); });
describe('evaluateSwarmControlCheck', () => { describe('evaluateSwarmControlCheck', () => {
@@ -412,6 +564,34 @@ describe('AutomationService', () => {
}), }),
); );
}); });
it('clears the config on null so the rule falls back to its defaults', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue({
...varroaRule,
config: null,
});
await service.update('rule-3', { config: null as never }, 'user-1');
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
// A Json column is cleared with DbNull - a plain null fails the write.
expect(updateArg.data.config).toBe(Prisma.DbNull);
});
it('leaves the config untouched when it is omitted', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue(varroaRule);
await service.update('rule-3', { active: true }, 'user-1');
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
expect(updateArg.data.config).toBeUndefined();
});
}); });
describe('generateNumber', () => { describe('generateNumber', () => {
@@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { runAfterCommit } from '../common/after-commit';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
@@ -164,6 +165,8 @@ function dueDateFromConfig(
@Injectable() @Injectable()
export class AutomationService { export class AutomationService {
private readonly logger = new Logger(AutomationService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
@@ -222,7 +225,12 @@ export class AutomationService {
where: { id }, where: { id },
data: { data: {
...dto, ...dto,
config: dto.config as Prisma.InputJsonValue | undefined, // null resets the rule to its defaults; a Json column is cleared
// with DbNull, a plain null would fail the write.
config:
dto.config === null
? Prisma.DbNull
: (dto.config as Prisma.InputJsonValue | undefined),
}, },
}); });
await this.auditLog.record({ await this.auditLog.record({
@@ -236,109 +244,141 @@ export class AutomationService {
return rule; 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,
);
}
async evaluateInspection(inspection: Inspection): Promise<void> { async evaluateInspection(inspection: Inspection): Promise<void> {
await this.ensureDefaults(); await this.evaluateAfterCommit('Inspection', inspection.id, async () => {
const rules = await this.prisma.automationRule.findMany({ await this.ensureDefaults();
where: { const rules = await this.prisma.automationRule.findMany({
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] }, where: {
active: true, 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,
);
}
}); });
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 evaluateVarroaMeasurement( async evaluateVarroaMeasurement(
measurement: VarroaMeasurement, measurement: VarroaMeasurement,
): Promise<void> { ): Promise<void> {
await this.ensureDefaults(); await this.evaluateAfterCommit(
const rule = await this.prisma.automationRule.findUnique({ 'VarroaMeasurement',
where: { triggerType: 'VARROA_THRESHOLD' }, measurement.id,
}); async () => {
if (!rule || !rule.active) { await this.ensureDefaults();
return; const rule = await this.prisma.automationRule.findUnique({
} where: { triggerType: 'VARROA_THRESHOLD' },
// 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 if (!rule || !rule.active) {
// wash is per bee sample and would be compared against the wrong scale, return;
// as would a raw board count without the number of days it covered. }
if (measurement.method !== 'NATURAL_MITE_FALL') { // The threshold is a natural mite fall rate (mites per day). Only
return; // 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
const config = rule.config as { thresholdMitesPerDay?: number } | null; // the wrong scale, as would a raw board count without the number of
const threshold = config?.thresholdMitesPerDay ?? 10; // days it covered.
const value = measurement.calculatedValue; if (measurement.method !== 'NATURAL_MITE_FALL') {
if (value !== null && value >= threshold) { return;
await this.triggerAction(rule, 'COLONY', measurement.colonyId); }
} 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);
}
},
);
} }
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> { async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
await this.ensureDefaults(); await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => {
const rule = await this.prisma.automationRule.findUnique({ await this.ensureDefaults();
where: { triggerType: 'SWARM_MOOD_HIGH' }, 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);
}
}); });
if (!rule || !rule.active) {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerAction(rule, 'COLONY', check.colonyId);
}
} }
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */ /** Fires only on the transition into CRITICAL, not on every edit while already critical. */
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> { async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
await this.ensureDefaults(); await this.evaluateAfterCommit('Colony', after.id, async () => {
if ( await this.ensureDefaults();
after.healthStatus !== 'CRITICAL' || if (
before.healthStatus === 'CRITICAL' after.healthStatus !== 'CRITICAL' ||
) { before.healthStatus === 'CRITICAL'
return; ) {
} return;
const rule = await this.prisma.automationRule.findUnique({ }
where: { triggerType: 'COLONY_HEALTH_CRITICAL' }, const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', after.id);
}); });
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', after.id);
} }
async evaluateStockItem(item: StockItem): Promise<void> { async evaluateStockItem(item: StockItem): Promise<void> {
await this.ensureDefaults(); await this.evaluateAfterCommit('StockItem', item.id, async () => {
if (item.minThreshold === null || item.quantity >= item.minThreshold) { await this.ensureDefaults();
return; if (item.minThreshold === null || item.quantity >= item.minThreshold) {
} return;
const rule = await this.prisma.automationRule.findUnique({ }
where: { triggerType: 'STOCK_ITEM_LOW' }, const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'STORAGE', item.id);
}); });
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'STORAGE', item.id);
} }
/** /**
@@ -352,40 +392,46 @@ export class AutomationService {
kind: RawMaterialKind, kind: RawMaterialKind,
remainingGrams: number, remainingGrams: number,
): Promise<void> { ): Promise<void> {
await this.ensureDefaults(); await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
const rule = await this.prisma.automationRule.findUnique({ await this.ensureDefaults();
where: { triggerType: 'RAW_MATERIAL_LOW' }, const rule = await this.prisma.automationRule.findUnique({
}); where: { triggerType: 'RAW_MATERIAL_LOW' },
if (!rule || !rule.active) { });
return; if (!rule || !rule.active) {
} return;
const config = rule.config as { }
minGrams?: Partial<Record<RawMaterialKind, unknown>>; const config = rule.config as {
} | null; minGrams?: Partial<Record<RawMaterialKind, unknown>>;
const minGrams = config?.minGrams?.[kind]; } | null;
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) { const minGrams = config?.minGrams?.[kind];
return; 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.`, await this.triggerAction(rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
});
}); });
} }
async evaluateTreatment(treatment: Treatment): Promise<void> { async evaluateTreatment(treatment: Treatment): Promise<void> {
await this.evaluateFollowUp( await this.evaluateAfterCommit('Treatment', treatment.id, () =>
'TREATMENT_FOLLOW_UP', this.evaluateFollowUp(
treatment.colonyId, 'TREATMENT_FOLLOW_UP',
treatment.endDate ?? treatment.date, treatment.colonyId,
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`, treatment.endDate ?? treatment.date,
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
),
); );
} }
async evaluateFeeding(feeding: Feeding): Promise<void> { async evaluateFeeding(feeding: Feeding): Promise<void> {
await this.evaluateFollowUp( await this.evaluateAfterCommit('Feeding', feeding.id, () =>
'FEEDING_FOLLOW_UP', this.evaluateFollowUp(
feeding.colonyId, 'FEEDING_FOLLOW_UP',
feeding.date, feeding.colonyId,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`, feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
),
); );
} }
@@ -507,6 +553,12 @@ export class AutomationService {
* stock item or the check task for a colony is still open, further triggers * stock item or the check task for a colony is still open, further triggers
* (the next removal below minimum, a second high varroa count) add nothing. * (the next removal below minimum, a second high varroa count) add nothing.
* Repeating the same alarm would only bury the one task that matters. * Repeating the same alarm would only bury the one task that matters.
*
* Check and create run in one transaction under a lock on the rule's row:
* 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.
*/ */
private async triggerAction( private async triggerAction(
rule: AutomationRule, rule: AutomationRule,
@@ -515,33 +567,38 @@ export class AutomationService {
sourceInspectionId?: string, sourceInspectionId?: string,
options: { baseDate?: Date; description?: string } = {}, options: { baseDate?: Date; description?: string } = {},
): Promise<void> { ): Promise<void> {
const alreadyOpen = await this.prisma.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return;
}
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null; const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.prisma.task.create({ const task = await this.prisma.$transaction(async (tx) => {
data: { await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
title, const alreadyOpen = await tx.task.findFirst({
priority: 'HIGH', where: {
relatedType, sourceAutomationTrigger: rule.triggerType,
relatedId, relatedType,
sourceInspectionId, relatedId,
sourceAutomationTrigger: rule.triggerType, status: { in: ['OPEN', 'IN_PROGRESS'] },
description: options.description, },
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate), 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.webhooks.dispatch('task.created', task);
await this.notifications.record({ await this.notifications.record({
priority: 'WARNUNG', priority: 'WARNUNG',
@@ -1,9 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class UpdateAutomationRuleDto { export class UpdateAutomationRuleDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
active?: boolean; active?: boolean;
@@ -0,0 +1,75 @@
import { Logger } from '@nestjs/common';
import {
DEFAULT_BACKUP_MAX_IMPORT_BYTES,
backupMaxImportBytes,
exceedsBackupImportLimit,
} from './backup-import-limit';
describe('backup-import-limit', () => {
const original = process.env.BACKUP_MAX_IMPORT_BYTES;
let warn: jest.SpyInstance;
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
if (original === undefined) {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
} else {
process.env.BACKUP_MAX_IMPORT_BYTES = original;
}
});
it('allows 50 MB when BACKUP_MAX_IMPORT_BYTES is not set', () => {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
expect(DEFAULT_BACKUP_MAX_IMPORT_BYTES).toBe(50 * 1024 * 1024);
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
expect(warn).not.toHaveBeenCalled();
});
it('takes a positive byte count from BACKUP_MAX_IMPORT_BYTES', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '104857600';
expect(backupMaxImportBytes()).toBe(104857600);
expect(warn).not.toHaveBeenCalled();
});
it.each(['abc', '0', '-5', '1.5', '50MB'])(
'falls back to the default with a logged warning for "%s"',
(value) => {
process.env.BACKUP_MAX_IMPORT_BYTES = value;
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('BACKUP_MAX_IMPORT_BYTES'),
);
},
);
it('caps a value beyond 256 MB, since the whole upload is held in memory', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = String(8 * 1024 * 1024 * 1024);
expect(backupMaxImportBytes()).toBe(256 * 1024 * 1024);
expect(warn).toHaveBeenCalledTimes(1);
});
it('warns only once for the same invalid value', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = 'not-a-number';
backupMaxImportBytes();
backupMaxImportBytes();
expect(warn).toHaveBeenCalledTimes(1);
});
it('flags only a file larger than the limit', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
expect(exceedsBackupImportLimit(1000)).toBe(false);
expect(exceedsBackupImportLimit(1001)).toBe(true);
});
});
@@ -0,0 +1,59 @@
import { Logger } from '@nestjs/common';
/**
* Largest backup file POST /backup/import accepts - and the size every
* export is checked against, so a backup that could no longer be restored
* is noticed when it is written, not on the day it is needed. A full bundle
* is far larger than the 5mb CSV import cap elsewhere (dozens of tables,
* years of records, document contents as base64), hence its own limit.
*
* BACKUP_MAX_IMPORT_BYTES overrides the default. Like the other BACKUP_*
* variables, an unusable value falls back instead of stopping the server,
* here with a logged warning.
*/
export const DEFAULT_BACKUP_MAX_IMPORT_BYTES = 50 * 1024 * 1024;
// The whole upload is held in memory, and parsed and decoded from there
// (a string near V8's ~512 MB limit, a 512Mi pod) - no setting goes beyond.
const BACKUP_MAX_IMPORT_CEILING_BYTES = 256 * 1024 * 1024;
const logger = new Logger('BackupImportLimit');
// Parsed once per distinct env value, so an invalid one warns only once.
let resolved: { raw: string | undefined; bytes: number } | undefined;
function parseLimit(raw: string | undefined): number {
if (raw === undefined || raw.trim() === '') {
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
}
const bytes = Number(raw);
if (!Number.isInteger(bytes) || bytes <= 0) {
logger.warn(
`BACKUP_MAX_IMPORT_BYTES="${raw}" ist keine positive ganze Zahl (Bytes) - es gilt der Standard von ${DEFAULT_BACKUP_MAX_IMPORT_BYTES} Bytes`,
);
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
}
if (bytes > BACKUP_MAX_IMPORT_CEILING_BYTES) {
logger.warn(
`BACKUP_MAX_IMPORT_BYTES=${bytes} liegt über der Obergrenze - es gelten ${BACKUP_MAX_IMPORT_CEILING_BYTES} Bytes`,
);
return BACKUP_MAX_IMPORT_CEILING_BYTES;
}
return bytes;
}
export function backupMaxImportBytes(): number {
const raw = process.env.BACKUP_MAX_IMPORT_BYTES;
if (!resolved || resolved.raw !== raw) {
resolved = { raw, bytes: parseLimit(raw) };
}
return resolved.bytes;
}
/** True when a backup file of this size would be refused on import. */
export function exceedsBackupImportLimit(sizeBytes: number): boolean {
return sizeBytes > backupMaxImportBytes();
}
/** A byte count as "52.4 MB" for log lines. */
export function formatMegabytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -0,0 +1,198 @@
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 { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service';
jest.mock('node:fs/promises', () => ({
mkdir: jest.fn(),
writeFile: jest.fn(),
readdir: jest.fn(),
stat: jest.fn(),
unlink: jest.fn(),
}));
import * as fs from 'node:fs/promises';
describe('BackupSchedulerService', () => {
const originalEnv = { ...process.env };
const directory = path.resolve('/data/backups');
let service: BackupSchedulerService;
let backupService: { exportSealed: jest.Mock };
let error: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
process.env.BACKUP_STORAGE_PATH = directory;
delete process.env.BACKUP_MAX_IMPORT_BYTES;
delete process.env.BACKUP_RETENTION_COUNT;
delete process.env.BACKUP_CRON_SCHEDULE;
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
backupService = {
exportSealed: jest.fn().mockResolvedValue({
format: 'apicore-backup',
exportedAt: '2026-10-12T03:00:00.000Z',
ciphertext: 'x'.repeat(2000),
}),
};
const settings = {
get: jest.fn().mockResolvedValue({
storagePath: null,
cronSchedule: null,
retentionCount: null,
}),
};
service = new BackupSchedulerService(
backupService as unknown as BackupService,
settings as unknown as BackupSettingsService,
{} as SchedulerRegistry,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.readdir as jest.Mock).mockResolvedValue([]);
});
afterEach(() => {
error.mockRestore();
process.env = { ...originalEnv };
});
function onDisk(files: { name: string; size: number; mtime: string }[]) {
(fs.readdir as jest.Mock).mockResolvedValue(files.map((f) => f.name));
(fs.stat as jest.Mock).mockImplementation((filePath: string) => {
const file = files.find((f) => filePath.endsWith(f.name));
return Promise.resolve({
size: file?.size,
mtime: new Date(file!.mtime),
});
});
}
describe('getStatus', () => {
it('reports the import limit and the size of the newest backup', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
onDisk([
{
name: 'apicore-backup-old.json',
size: 5000,
mtime: '2026-10-10T03:00:00.000Z',
},
{
name: 'apicore-backup-new.json',
size: 900,
mtime: '2026-10-11T03:00:00.000Z',
},
]);
const status = await service.getStatus();
expect(status).toEqual(
expect.objectContaining({
importLimitBytes: 1000,
lastBackupSizeBytes: 900,
lastBackupExceedsImportLimit: false,
}),
);
});
it('warns when the newest backup could no longer be imported', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
onDisk([
{
name: 'apicore-backup-new.json',
size: 1001,
mtime: '2026-10-11T03:00:00.000Z',
},
]);
const status = await service.getStatus();
expect(status.lastBackupSizeBytes).toBe(1001);
expect(status.lastBackupExceedsImportLimit).toBe(true);
});
it('still reports the import limit while no schedule is configured', async () => {
delete process.env.BACKUP_STORAGE_PATH;
const status = await service.getStatus();
expect(status).toEqual(
expect.objectContaining({
enabled: false,
importLimitBytes: 50 * 1024 * 1024,
lastBackupSizeBytes: null,
lastBackupExceedsImportLimit: false,
}),
);
});
});
describe('runNow', () => {
it('keeps a backup larger than the import limit but logs an error', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
const result = await service.runNow();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(result.fileName).toMatch(/^apicore-backup-.*\.json$/);
expect(error).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledWith(
expect.stringContaining('Import-Limit'),
);
});
it('logs nothing for a backup within the import limit', async () => {
await service.runNow();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(error).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,
{
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();
await job.fireOnTick();
await new Promise((resolve) => setImmediate(resolve));
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();
});
});
});
@@ -5,10 +5,16 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import { BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service'; import { BackupSettingsService } from './backup-settings.service';
import { loggableError } from '../common/loggable-error';
import { import {
assertBackupCron, assertBackupCron,
resolveGuiBackupPath, resolveGuiBackupPath,
} from './backup-schedule-rules'; } from './backup-schedule-rules';
import {
backupMaxImportBytes,
exceedsBackupImportLimit,
formatMegabytes,
} from './backup-import-limit';
const SCHEDULED_JOB_NAME = 'automatic-backup'; const SCHEDULED_JOB_NAME = 'automatic-backup';
const DEFAULT_CRON_SCHEDULE = '0 3 * * *'; const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
@@ -28,6 +34,13 @@ export interface BackupScheduleStatus {
cronSchedule: string | null; cronSchedule: string | null;
retentionCount: number | null; retentionCount: number | null;
lastRunAt: string | null; lastRunAt: string | null;
// Largest file POST /backup/import accepts (BACKUP_MAX_IMPORT_BYTES).
importLimitBytes: number;
// Size of the newest backup file - taken from the file itself, so the
// warning survives restarts and is the same on every replica.
lastBackupSizeBytes: number | null;
// True when that file could not be restored without a raised limit.
lastBackupExceedsImportLimit: boolean;
// Which fields are currently pinned by an env var (and therefore not // Which fields are currently pinned by an env var (and therefore not
// editable via the GUI form) - env always wins over the GUI-stored value. // editable via the GUI form) - env always wins over the GUI-stored value.
envOverrides: { envOverrides: {
@@ -125,9 +138,11 @@ export class BackupSchedulerService implements OnModuleInit {
const job = CronJob.from({ const job = CronJob.from({
cronTime: settings.cronSchedule, cronTime: settings.cronSchedule,
onTick: () => { onTick: () => {
this.runScheduled(directory).catch((error) => // Prisma errors by name/code/model only (see loggableError).
this.logger.error('Scheduled backup failed', error), this.runScheduled(directory).catch((error: unknown) => {
); const { message, stack } = loggableError(error);
this.logger.error(`Scheduled backup failed: ${message}`, stack);
});
}, },
start: true, start: true,
}); });
@@ -139,6 +154,7 @@ export class BackupSchedulerService implements OnModuleInit {
async getStatus(): Promise<BackupScheduleStatus> { async getStatus(): Promise<BackupScheduleStatus> {
const settings = await this.effectiveSettings(); const settings = await this.effectiveSettings();
const importLimitBytes = backupMaxImportBytes();
if (!settings.path) { if (!settings.path) {
return { return {
enabled: false, enabled: false,
@@ -146,12 +162,16 @@ export class BackupSchedulerService implements OnModuleInit {
cronSchedule: null, cronSchedule: null,
retentionCount: null, retentionCount: null,
lastRunAt: null, lastRunAt: null,
importLimitBytes,
lastBackupSizeBytes: null,
lastBackupExceedsImportLimit: false,
envOverrides: settings.envOverrides, envOverrides: settings.envOverrides,
}; };
} }
const files = await this.listBackupFiles(settings.path); const files = await this.listBackupFiles(settings.path);
const lastRunAt = files[0]?.mtime.toISOString() ?? null; const lastRunAt = files[0]?.mtime.toISOString() ?? null;
const lastBackupSizeBytes = files[0]?.size ?? null;
return { return {
enabled: true, enabled: true,
@@ -159,6 +179,11 @@ export class BackupSchedulerService implements OnModuleInit {
cronSchedule: settings.cronSchedule, cronSchedule: settings.cronSchedule,
retentionCount: settings.retentionCount, retentionCount: settings.retentionCount,
lastRunAt, lastRunAt,
importLimitBytes,
lastBackupSizeBytes,
lastBackupExceedsImportLimit:
lastBackupSizeBytes !== null &&
exceedsBackupImportLimit(lastBackupSizeBytes),
envOverrides: settings.envOverrides, envOverrides: settings.envOverrides,
}; };
} }
@@ -205,8 +230,17 @@ export class BackupSchedulerService implements OnModuleInit {
directory, directory,
`${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`, `${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`,
); );
const content = JSON.stringify(sealed);
// Owner-only: the file holds every account's credentials (encrypted). // Owner-only: the file holds every account's credentials (encrypted).
await fs.writeFile(filePath, JSON.stringify(sealed), { mode: 0o600 }); await fs.writeFile(filePath, content, { mode: 0o600 });
const sizeBytes = Buffer.byteLength(content);
if (exceedsBackupImportLimit(sizeBytes)) {
// Kept anyway (a raised BACKUP_MAX_IMPORT_BYTES restores it); the
// status reads the size from the file and warns in the UI.
this.logger.error(
`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 filePath;
} }
@@ -231,7 +265,7 @@ export class BackupSchedulerService implements OnModuleInit {
private async listBackupFiles( private async listBackupFiles(
directory: string, directory: string,
): Promise<{ filePath: string; mtime: Date }[]> { ): Promise<{ filePath: string; mtime: Date; size: number }[]> {
let entries: string[]; let entries: string[];
try { try {
entries = await fs.readdir(directory); entries = await fs.readdir(directory);
@@ -249,7 +283,7 @@ export class BackupSchedulerService implements OnModuleInit {
backupFiles.map(async (name) => { backupFiles.map(async (name) => {
const filePath = path.join(directory, name); const filePath = path.join(directory, name);
const stat = await fs.stat(filePath); const stat = await fs.stat(filePath);
return { filePath, mtime: stat.mtime }; return { filePath, mtime: stat.mtime, size: stat.size };
}), }),
); );
@@ -0,0 +1,100 @@
import { Logger } from '@nestjs/common';
import type { Response } from 'express';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service';
import { ReauthService } from '../auth/reauth/reauth.service';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
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 reauth: { verify: jest.Mock };
const actor = { id: 'admin-1' } as AuthenticatedUser;
const file = {
buffer: Buffer.from(JSON.stringify({ format: 'apicore-backup' })),
} as Express.Multer.File;
beforeEach(() => {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
backupService = {
importFile: jest.fn().mockResolvedValue({ imported: {}, skipped: [] }),
exportSealed: jest.fn().mockResolvedValue({
format: 'apicore-backup',
exportedAt: '2026-10-12T03:00:00.000Z',
ciphertext: 'x'.repeat(2000),
}),
};
scheduler = { reload: jest.fn().mockResolvedValue(undefined) };
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
controller = new BackupController(
backupService as unknown as BackupService,
scheduler as unknown as BackupSchedulerService,
{} as BackupSettingsService,
reauth as unknown as ReauthService,
);
});
afterEach(() => {
if (originalLimit === undefined) {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
} else {
process.env.BACKUP_MAX_IMPORT_BYTES = originalLimit;
}
});
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'));
await expect(
controller.import(file, { password: 'secret' }, actor),
).rejects.toThrow('broken file');
expect(scheduler.reload).not.toHaveBeenCalled();
});
describe('export', () => {
let warn: jest.SpyInstance;
let res: { setHeader: jest.Mock; send: jest.Mock };
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
res = { setHeader: jest.fn(), send: jest.fn() };
});
afterEach(() => {
warn.mockRestore();
});
it('still delivers a backup larger than the import limit, but logs a warning', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
await controller.export(res as unknown as Response);
expect(res.send).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Import-Limit'),
);
});
it('logs nothing for a backup within the import limit', async () => {
await controller.export(res as unknown as Response);
expect(res.send).toHaveBeenCalledTimes(1);
expect(warn).not.toHaveBeenCalled();
});
});
});
@@ -3,6 +3,7 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
Logger,
Patch, Patch,
Post, Post,
Res, Res,
@@ -25,17 +26,18 @@ import {
} from './backup-scheduler.service'; } from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service'; import { BackupSettingsService } from './backup-settings.service';
import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto'; import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto';
import {
// A full backup bundle can be much larger than the 5mb CSV import cap backupMaxImportBytes,
// elsewhere (dozens of tables, potentially years of records, plus embedded exceedsBackupImportLimit,
// label-template images) - 50mb comfortably covers a real dataset without formatMegabytes,
// raising the app-wide JSON body limit for every other route. } from './backup-import-limit';
const MAX_BACKUP_FILE_SIZE_BYTES = 50 * 1024 * 1024;
@ApiTags('backup') @ApiTags('backup')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('backup') @Controller('backup')
export class BackupController { export class BackupController {
private readonly logger = new Logger(BackupController.name);
constructor( constructor(
private readonly backupService: BackupService, private readonly backupService: BackupService,
private readonly backupSchedulerService: BackupSchedulerService, private readonly backupSchedulerService: BackupSchedulerService,
@@ -76,19 +78,29 @@ export class BackupController {
@Get('export') @Get('export')
async export(@Res() res: Response): Promise<void> { async export(@Res() res: Response): Promise<void> {
const sealed = await this.backupService.exportSealed(); const sealed = await this.backupService.exportSealed();
const body = JSON.stringify(sealed);
const sizeBytes = Buffer.byteLength(body);
if (exceedsBackupImportLimit(sizeBytes)) {
// Still delivered: losing the data is worse than needing a raised
// BACKUP_MAX_IMPORT_BYTES to restore it. The UI compares the file
// against the limit from GET /backup/schedule and warns there.
this.logger.warn(
`Exportiertes Backup ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern`,
);
}
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`; const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(sealed)); res.send(body);
} }
@RequirePermission('backup:import') @RequirePermission('backup:import')
@Post('import') @Post('import')
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@UseInterceptors( @UseInterceptors(
// Size limit from MulterModule in BackupModule (BACKUP_MAX_IMPORT_BYTES).
FileInterceptor('file', { FileInterceptor('file', {
limits: { fileSize: MAX_BACKUP_FILE_SIZE_BYTES },
fileFilter: (_req, file, callback) => { fileFilter: (_req, file, callback) => {
if (!file.originalname.toLowerCase().endsWith('.json')) { if (!file.originalname.toLowerCase().endsWith('.json')) {
callback( callback(
@@ -117,6 +129,9 @@ export class BackupController {
} catch { } catch {
throw new BadRequestException('Datei ist kein gültiges JSON'); throw new BadRequestException('Datei ist kein gültiges JSON');
} }
return this.backupService.importFile(parsed, actor.id); 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;
} }
} }
+11 -1
View File
@@ -1,12 +1,22 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { ReauthModule } from '../auth/reauth/reauth.module'; import { ReauthModule } from '../auth/reauth/reauth.module';
import { BackupController } from './backup.controller'; import { BackupController } from './backup.controller';
import { BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service'; import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service'; import { BackupSettingsService } from './backup-settings.service';
import { backupMaxImportBytes } from './backup-import-limit';
@Module({ @Module({
imports: [ReauthModule], imports: [
ReauthModule,
// 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.
MulterModule.registerAsync({
useFactory: () => ({ limits: { fileSize: backupMaxImportBytes() } }),
}),
],
controllers: [BackupController], controllers: [BackupController],
providers: [BackupService, BackupSchedulerService, BackupSettingsService], providers: [BackupService, BackupSchedulerService, BackupSettingsService],
}) })
+602 -25
View File
@@ -1,5 +1,11 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException, Logger } from '@nestjs/common';
import { BackupService } from './backup.service'; import * as path from 'node:path';
import { Prisma } from '../../generated/prisma';
import {
BackupService,
LEGACY_UNSEALED_TABLES,
MODELS_EXCLUDED_FROM_BACKUP,
} from './backup.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
@@ -8,6 +14,7 @@ jest.mock('node:fs/promises', () => ({
readFile: jest.fn(), readFile: jest.fn(),
writeFile: jest.fn(), writeFile: jest.fn(),
mkdir: jest.fn(), mkdir: jest.fn(),
unlink: jest.fn(),
})); }));
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
@@ -36,15 +43,25 @@ const BUSINESS_ENTITY_KEYS = [
'honeyBatch', 'honeyBatch',
'honeyMeasurement', 'honeyMeasurement',
'honeyBatchRelocation', 'honeyBatchRelocation',
'honeyBatchContainerAllocation',
'stockItem', 'stockItem',
'stockMovement', 'stockMovement',
'product', 'product',
'labelTemplate', 'labelTemplate',
'bottlingBatch', 'bottlingBatch',
'rawMaterialLot',
'productionBatch',
'productionInput',
'shopStockRequest',
'batchMovement',
'equipment', 'equipment',
'webhookSubscription', 'webhookSubscription',
'automationRule', 'automationRule',
'automationSequence',
'siteSettings', 'siteSettings',
'backupSettings',
'calendarSyncSettings',
'calendarSyncOutboxEntry',
] as const; ] as const;
const ALL_ENTITY_KEYS = [ const ALL_ENTITY_KEYS = [
@@ -57,17 +74,30 @@ const ALL_ENTITY_KEYS = [
'auditLog', 'auditLog',
] as const; ] as const;
// Prisma delegate name of a model: `productionBatch` for ProductionBatch.
function modelKey(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
}
// Every model the schema has - so a table added later is mocked too and the
// coverage guard below reports it instead of crashing on a missing mock.
const MODEL_KEYS = Object.values(Prisma.ModelName).map(modelKey);
type Row = Record<string, unknown>;
describe('BackupService', () => { describe('BackupService', () => {
let service: BackupService; let service: BackupService;
let prisma: Record<string, { findMany: jest.Mock }> & { let prisma: Record<string, { findMany: jest.Mock }> & {
user: { findMany: jest.Mock }; user: { findMany: jest.Mock };
rolePermission: { findMany: jest.Mock }; rolePermission: { findMany: jest.Mock };
document: { findMany: jest.Mock }; document: { findMany: jest.Mock; count: jest.Mock };
$transaction: jest.Mock; $transaction: jest.Mock;
}; };
let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & { let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & {
permission: { findUnique: jest.Mock }; permission: { findUnique: jest.Mock };
user: { upsert: jest.Mock; findMany: jest.Mock }; user: { upsert: jest.Mock; findMany: jest.Mock };
queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock };
document: { upsert: jest.Mock; findUnique: jest.Mock };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let permissionResolver: { invalidateAll: jest.Mock }; let permissionResolver: { invalidateAll: jest.Mock };
@@ -77,20 +107,14 @@ describe('BackupService', () => {
jest.clearAllMocks(); jest.clearAllMocks();
tx = {} as never; tx = {} as never;
for (const key of [ for (const key of MODEL_KEYS) {
...BUSINESS_ENTITY_KEYS,
'role',
'user',
'rolePermission',
'document',
'notification',
'auditLog',
]) {
tx[key] = { tx[key] = {
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }), upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
}; };
} }
tx.queen.update = jest.fn().mockResolvedValue({}); tx.queen.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null);
tx.permission = { tx.permission = {
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }), findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
}; };
@@ -100,23 +124,14 @@ describe('BackupService', () => {
.mockResolvedValue([]); .mockResolvedValue([]);
prisma = { prisma = {
user: {
findMany: jest.fn().mockResolvedValue([]),
},
rolePermission: { findMany: jest.fn().mockResolvedValue([]) },
document: { findMany: jest.fn().mockResolvedValue([]) },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx), callback(tx),
), ),
} as never; } as never;
for (const key of [ for (const key of MODEL_KEYS) {
'role',
...BUSINESS_ENTITY_KEYS,
'notification',
'auditLog',
]) {
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) }; prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
} }
prisma.document.count = jest.fn().mockResolvedValue(0);
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
@@ -129,10 +144,10 @@ describe('BackupService', () => {
}); });
describe('exportAll', () => { describe('exportAll', () => {
it('returns a version 2 bundle with every entity key, including role/user/document', async () => { it('returns a version 3 bundle with every entity key, including role/user/document', async () => {
const bundle = await service.exportAll(); const bundle = await service.exportAll();
expect(bundle.version).toBe(2); expect(bundle.version).toBe(3);
expect(typeof bundle.exportedAt).toBe('string'); expect(typeof bundle.exportedAt).toBe('string');
for (const key of ALL_ENTITY_KEYS) { for (const key of ALL_ENTITY_KEYS) {
expect(bundle.data).toHaveProperty(key); expect(bundle.data).toHaveProperty(key);
@@ -589,5 +604,567 @@ describe('BackupService', () => {
expect.arrayContaining(['role', 'user', 'webhookSubscription']), expect.arrayContaining(['role', 'user', 'webhookSubscription']),
); );
}); });
it('restores production, raw material and stock ledger rows by id, remapping their optional userId', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
rawMaterialLot: [{ id: 'lot-1', userId: 'ghost-user' }],
productionBatch: [
{ id: 'batch-1', productId: 'product-1', userId: 'known-user' },
],
productionInput: [
{
id: 'input-1',
productionBatchId: 'batch-1',
rawMaterialLotId: 'lot-1',
},
],
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
batchMovement: [
{
id: 'movement-1',
productionBatchId: 'batch-1',
shopRequestId: 'request-1',
userId: null,
},
],
honeyBatchContainerAllocation: [
{
id: 'allocation-1',
honeyBatchId: 'honey-1',
storageContainerId: 'container-1',
},
],
},
},
'admin-1',
TRUSTED,
);
const firstCall = (key: string) =>
(
tx[key].upsert.mock.calls as [
{
where: Record<string, unknown>;
create: Record<string, unknown>;
},
][]
)[0][0];
expect(firstCall('rawMaterialLot').where).toEqual({ id: 'lot-1' });
expect(firstCall('rawMaterialLot').create.userId).toBe('admin-1');
expect(firstCall('productionBatch').create.userId).toBe('known-user');
expect(firstCall('batchMovement').create.userId).toBeNull();
expect(firstCall('productionInput').where).toEqual({ id: 'input-1' });
expect(firstCall('shopStockRequest').where).toEqual({ id: 'request-1' });
expect(firstCall('honeyBatchContainerAllocation').where).toEqual({
id: 'allocation-1',
});
});
it('writes every parent row before the rows that reference it', async () => {
const one = (id: string) => [{ id }];
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: one('apiary-1'),
harvest: one('harvest-1'),
storageContainer: one('container-1'),
honeyBatch: one('honey-1'),
honeyBatchContainerAllocation: one('allocation-1'),
product: one('product-1'),
bottlingBatch: one('bottling-1'),
rawMaterialLot: one('lot-1'),
productionBatch: one('batch-1'),
productionInput: one('input-1'),
shopStockRequest: one('request-1'),
batchMovement: one('movement-1'),
automationRule: [{ triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT' }],
automationSequence: [
{
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
value: 7,
},
],
},
},
'admin-1',
TRUSTED,
);
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
const dependencies: [parent: string, child: string][] = [
['storageContainer', 'honeyBatchContainerAllocation'],
['honeyBatch', 'honeyBatchContainerAllocation'],
['apiary', 'rawMaterialLot'],
['harvest', 'rawMaterialLot'],
['product', 'productionBatch'],
['productionBatch', 'productionInput'],
['rawMaterialLot', 'productionInput'],
['bottlingBatch', 'batchMovement'],
['productionBatch', 'batchMovement'],
['rawMaterialLot', 'batchMovement'],
['shopStockRequest', 'batchMovement'],
['automationRule', 'automationSequence'],
];
for (const [parent, child] of dependencies) {
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
{ parent, child, before: true },
);
}
});
it('upserts automationSequence by triggerType and scope', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
automationSequence: [
{
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
value: 7,
},
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.automationSequence.upsert.mock.calls as [
[{ where: unknown; create: Record<string, unknown> }],
];
expect(call.where).toEqual({
triggerType_scope: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
},
});
expect(call.create.value).toBe(7);
});
it('upserts a calendar outbox entry by its source and keeps the target row id', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
calendarSyncOutboxEntry: [
{
id: 'entry-source-id',
sourceType: 'TASK',
sourceId: 'task-1',
graphEventId: 'graph-event-1',
},
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.calendarSyncOutboxEntry.upsert.mock.calls as [
[{ where: unknown; update: Record<string, unknown> }],
];
expect(call.where).toEqual({
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
});
expect(call.update).not.toHaveProperty('id');
expect(call.update.graphEventId).toBe('graph-event-1');
});
it('upserts the backup and calendar settings singletons by id without rewriting it', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
backupSettings: [{ id: 'singleton', retentionCount: 30 }],
calendarSyncSettings: [{ id: 'singleton', enabled: true }],
},
},
'admin-1',
TRUSTED,
);
for (const key of ['backupSettings', 'calendarSyncSettings']) {
const [[call]] = tx[key].upsert.mock.calls as [
[{ where: unknown; update: Record<string, unknown> }],
];
expect(call.where).toEqual({ id: 'singleton' });
expect(call.update).not.toHaveProperty('id');
}
});
it('takes no calendar sync or backup schedule from an unsealed file', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
backupSettings: [{ id: 'singleton', retentionCount: 1 }],
calendarSyncSettings: [{ id: 'singleton', groupId: 'evil' }],
calendarSyncOutboxEntry: [
{ id: 'entry-1', sourceType: 'TASK', sourceId: 'task-1' },
],
},
},
'admin-1',
{ trusted: false },
);
expect(tx.backupSettings.upsert).not.toHaveBeenCalled();
expect(tx.calendarSyncSettings.upsert).not.toHaveBeenCalled();
expect(tx.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
expect(summary.skipped).toEqual(
expect.arrayContaining([
'backupSettings',
'calendarSyncSettings',
'calendarSyncOutboxEntry',
]),
);
});
it('restores an older bundle that lacks the newer tables as if they were empty', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
},
'admin-1',
TRUSTED,
);
expect(summary.imported).toEqual(
expect.objectContaining({
apiary: 1,
productionBatch: 0,
rawMaterialLot: 0,
batchMovement: 0,
automationSequence: 0,
}),
);
});
});
// A file without the seal cannot be proven to come from ApiCore: it may
// only add plain master and field records, nothing else.
describe('unsealed file', () => {
const UNSEALED = { trusted: false };
const SEALED_ONLY_SAMPLE: Record<string, Row[]> = {
automationRule: [{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT' }],
automationSequence: [
{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT', scope: 'x', value: 0 },
],
stockItem: [{ id: 'stock-1' }],
stockMovement: [{ id: 'movement-1' }],
batchMovement: [{ id: 'ledger-1' }],
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
rawMaterialLot: [{ id: 'lot-1' }],
productionBatch: [{ id: 'batch-1' }],
productionInput: [{ id: 'input-1' }],
honeyBatchContainerAllocation: [{ id: 'allocation-1' }],
siteSettings: [{ id: 'singleton', holderName: 'Fake' }],
document: [
{
id: 'doc-1',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
},
],
notification: [{ id: 'note-1', message: 'Bitte hier anmelden' }],
};
it('takes no counters, ledgers, shop requests, settings, documents or notifications from it', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
...SEALED_ONLY_SAMPLE,
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
},
},
'admin-1',
UNSEALED,
);
for (const key of Object.keys(SEALED_ONLY_SAMPLE)) {
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
key,
calls: 0,
});
}
expect(fs.writeFile).not.toHaveBeenCalled();
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
expect([...summary.skipped].sort()).toEqual(
Object.keys(SEALED_ONLY_SAMPLE).sort(),
);
});
it('allows only plain master and field records - no table added later', () => {
expect([...LEGACY_UNSEALED_TABLES].sort()).toEqual(
[
'apiary',
'queen',
'colony',
'colonyApiaryHistory',
'colonyLineage',
'queenEvaluation',
'queenColonyHistory',
'inspection',
'taskTemplate',
'task',
'varroaMeasurement',
'swarmControlCheck',
'winterCheck',
'treatment',
'feeding',
'harvest',
'harvestColony',
'extraction',
'extractionHarvest',
'storageContainer',
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'product',
'labelTemplate',
'bottlingBatch',
'equipment',
].sort(),
);
});
it('adds the records it carries but never rewrites ones that already exist', async () => {
const data = Object.fromEntries(
LEGACY_UNSEALED_TABLES.map((key) => [
key,
[{ id: `${key}-1`, notes: 'aus der Datei' }],
]),
);
await service.importAll(
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
'admin-1',
UNSEALED,
);
for (const key of LEGACY_UNSEALED_TABLES) {
const [[call]] = tx[key].upsert.mock.calls as [
[{ create: Row; update: Row }],
];
expect({ key, update: call.update }).toEqual({ key, update: {} });
expect(call.create.notes).toBe('aus der Datei');
}
});
it('does not re-link the mother line of a queen that was already there', async () => {
tx.queen.findMany.mockResolvedValue([{ id: 'queen-existing' }]);
await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
queen: [
{ id: 'queen-existing', motherQueenId: 'queen-mother' },
{ id: 'queen-new', motherQueenId: 'queen-mother' },
{ id: 'queen-mother', motherQueenId: null },
],
},
},
'admin-1',
UNSEALED,
);
expect(tx.queen.update).toHaveBeenCalledTimes(1);
expect(tx.queen.update).toHaveBeenCalledWith({
where: { id: 'queen-new' },
data: { motherQueenId: 'queen-mother' },
});
});
it('reports the import as unsealed and lists no unknown sections as skipped', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { notATable: [{ id: 'x' }], apiary: [{ id: 'apiary-1' }] },
},
'admin-1',
UNSEALED,
);
expect(summary.sealed).toBe(false);
expect(summary.skipped).toEqual([]);
});
it('still restores everything, overwriting existing rows, from a sealed file', async () => {
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
stockMovement: [{ id: 'movement-1', userId: null }],
},
},
'admin-1',
TRUSTED,
);
expect(summary.sealed).toBe(true);
expect(summary.skipped).toEqual([]);
const [[call]] = tx.apiary.upsert.mock.calls as [[{ update: Row }]];
expect(call.update).toEqual({ id: 'apiary-1', name: 'Stand Wald' });
expect(tx.stockMovement.upsert).toHaveBeenCalledTimes(1);
});
});
// Restoring a document writes its content to a new file; the file the row
// pointed at before must not be left behind on disk.
describe('replaced document files', () => {
const storageRoot = path.resolve(
process.env.DOCUMENTS_STORAGE_PATH ?? './uploads',
);
const oldFile = path.join(storageRoot, 'colony', 'old.pdf');
const bundle = {
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
document: [
{
id: 'doc-1',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
},
],
},
};
let warn: jest.SpyInstance;
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
});
afterEach(() => {
warn.mockRestore();
});
it('deletes the file a restored row replaced, only after the import committed', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).toHaveBeenCalledWith(oldFile);
expect(
(fs.unlink as jest.Mock).mock.invocationCallOrder[0],
).toBeGreaterThan(tx.document.upsert.mock.invocationCallOrder[0]);
expect(prisma.document.count).toHaveBeenCalledWith({
where: { storagePath: oldFile },
});
});
it('deletes nothing when the import fails', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
tx.notification.upsert.mockRejectedValue(new Error('db down'));
await expect(
service.importAll(
{
...bundle,
data: { ...bundle.data, notification: [{ id: 'note-1' }] },
},
'admin-1',
TRUSTED,
),
).rejects.toThrow('db down');
expect(fs.unlink).not.toHaveBeenCalled();
});
it('keeps a replaced file outside the documents root and logs it', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: '/etc/passwd' });
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
});
it('keeps a replaced file that another document row still uses', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
prisma.document.count.mockResolvedValue(1);
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
});
it('completes the import when a replaced file cannot be deleted, and logs it', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
(fs.unlink as jest.Mock).mockRejectedValue(new Error('EBUSY'));
await expect(
service.importAll(bundle, 'admin-1', TRUSTED),
).resolves.toEqual(expect.objectContaining({ sealed: true }));
expect(warn).toHaveBeenCalledTimes(1);
});
it('deletes nothing for a document that is new to this database', async () => {
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {
it('backs up every Prisma model or names why it is left out', async () => {
const bundle = await service.exportAll();
const uncovered = Object.values(Prisma.ModelName).filter(
(model) =>
!(modelKey(model) in bundle.data) &&
!(model in MODELS_EXCLUDED_FROM_BACKUP),
);
expect(uncovered).toEqual([]);
});
it('gives every excluded model a reason and never exports it anyway', async () => {
const bundle = await service.exportAll();
for (const [model, reason] of Object.entries(
MODELS_EXCLUDED_FROM_BACKUP,
)) {
expect(bundle.data).not.toHaveProperty(modelKey(model));
expect(reason?.trim().length).toBeGreaterThan(0);
}
});
it('restores every table it exports', async () => {
const bundle = await service.exportAll();
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
expect(Object.keys(summary.imported).sort()).toEqual(
Object.keys(bundle.data).sort(),
);
});
}); });
}); });
+361 -70
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
@@ -10,7 +10,7 @@ import {
} from '../documents/document-mime-types'; } from '../documents/document-mime-types';
import { matchesFileSignature } from '../common/file-signature'; import { matchesFileSignature } from '../common/file-signature';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType } from '../../generated/prisma'; import { DocumentEntityType, Prisma } from '../../generated/prisma';
import { import {
SealedBackup, SealedBackup,
isSealedBackup, isSealedBackup,
@@ -18,7 +18,10 @@ import {
sealBackup, sealBackup,
} from './backup-crypto'; } from './backup-crypto';
const BACKUP_VERSION = 2; // 3: production, raw material, stock ledger, number sequence, calendar sync
// and backup schedule tables. Older bundles simply lack those sections and
// restore them as empty.
const BACKUP_VERSION = 3;
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000; const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
export interface BackupBundle { export interface BackupBundle {
@@ -30,34 +33,107 @@ export interface BackupBundle {
export interface BackupImportSummary { export interface BackupImportSummary {
imported: Record<string, number>; imported: Record<string, number>;
/** /**
* Tables left out because the file was an old, unsealed (v2) backup: its * Tables the file carried but an unsealed backup may not write - every
* contents cannot be proven to come from ApiCore, so nothing that grants * table outside LEGACY_UNSEALED_TABLES.
* access (users, roles, webhooks) or rewrites history (audit log) is
* taken from it.
*/ */
skipped: string[]; skipped: string[];
/** False for an old, unsealed file: restricted and add-only. */
sealed: boolean;
} }
// What an unsealed file may never write - see BackupImportSummary.skipped. /**
const TRUSTED_ONLY_TABLES = [ * All an unsealed file may write. Its contents cannot be proven to come from
'role', * ApiCore (it may be hand-built), so it is limited to the plain beekeeping
'user', * master and field records of the old unsealed (v2) format - records an
'rolePermission', * admin could just as well type in again - and it may only add missing
'webhookSubscription', * rows, never rewrite existing ones.
'auditLog', *
* Sealed-only, and why (any table not listed here is sealed-only too, so
* tables added later are safe by default):
* - role, user, rolePermission: grant access.
* - webhookSubscription, calendarSyncSettings, calendarSyncOutboxEntry:
* send data out, or update/delete events in an M365 calendar.
* - backupSettings: retention 1 would prune every older backup.
* - auditLog: history must not be rewritten.
* - automationRule, automationSequence: hold the lot-number counters (a
* reset hands out duplicate batch numbers) and the rule thresholds.
* - stockItem, stockMovement, batchMovement: running stock and the stock
* ledgers - never forged, only booked.
* - shopStockRequest: the shop's idempotency keys - a planted one would
* swallow a real sale.
* - rawMaterialLot, productionBatch, productionInput,
* honeyBatchContainerAllocation: newer than the unsealed format, so a
* genuine old file never carries them.
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
* public links.
* - document: writes files to the server's disk.
* - notification: messages shown to users as coming from ApiCore.
*/
export const LEGACY_UNSEALED_TABLES = [
'apiary',
'queen',
'colony',
'colonyApiaryHistory',
'colonyLineage',
'queenEvaluation',
'queenColonyHistory',
'inspection',
'taskTemplate',
'task',
'varroaMeasurement',
'swarmControlCheck',
'winterCheck',
'treatment',
'feeding',
'harvest',
'harvestColony',
'extraction',
'extractionHarvest',
'storageContainer',
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'product',
'labelTemplate',
'bottlingBatch',
'equipment',
] as const; ] as const;
// Bundle sections that name a real table - a file's other keys are ignored
// for every file and so never reported as skipped.
const MODEL_SECTIONS = new Set<string>(
Object.values(Prisma.ModelName).map(
(model) => model.charAt(0).toLowerCase() + model.slice(1),
),
);
/**
* Models deliberately left out of the backup, each with its reason. Every
* other Prisma model is a section of the bundle, named like its client
* delegate (`productionBatch`). The model coverage test in
* backup.service.spec.ts fails for a model that is neither, so a new table
* cannot silently drop out of the backup.
*/
export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
Partial<Record<Prisma.ModelName, string>>
> = {
Permission:
'Code-derived catalog, re-synced from the code on every boot. RolePermission rows reference permissions by resource+action instead, resolved against the target database.',
RefreshToken:
'Live session tokens - a session must never be revivable from a backup file.',
ConsumedToken:
'Replay guard for one-time tokens that expire within minutes, so always stale by the time a backup is restored.',
ApiKey:
'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.',
IdempotencyRecord:
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
};
const DOCUMENT_ENTITY_TYPES = new Set<string>( const DOCUMENT_ENTITY_TYPES = new Set<string>(
Object.values(DocumentEntityType), Object.values(DocumentEntityType),
); );
// Excluded on purpose: // Excluded on purpose: see MODELS_EXCLUDED_FROM_BACKUP.
// - Permission: code-derived, re-synced from the catalog on every boot -
// including it would just fight that sync. RolePermission references
// permissions by resource+action (not id) instead, resolved against
// whatever the target database's own sync already produced.
// - RefreshToken: live session tokens, meaningless once restored (expired,
// or a stale session that should never be revivable from a backup file).
// //
// Role/User/RolePermission ARE included (v2) - Role is matched by its `key` // Role/User/RolePermission ARE included (v2) - Role is matched by its `key`
// (not id, since a fresh deployment's seeded system roles get a new // (not id, since a fresh deployment's seeded system roles get a new
@@ -84,6 +160,8 @@ function documentsStorageRoot(): string {
@Injectable() @Injectable()
export class BackupService { export class BackupService {
private readonly logger = new Logger(BackupService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
@@ -98,7 +176,7 @@ export class BackupService {
/** /**
* Restores an uploaded file. A sealed backup is restored completely; an * Restores an uploaded file. A sealed backup is restored completely; an
* old unsealed one only with its business data (see TRUSTED_ONLY_TABLES). * old unsealed one only add-only and limited to LEGACY_UNSEALED_TABLES.
*/ */
async importFile( async importFile(
parsed: unknown, parsed: unknown,
@@ -162,15 +240,28 @@ export class BackupService {
data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany(); data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany();
data.honeyBatchRelocation = data.honeyBatchRelocation =
await this.prisma.honeyBatchRelocation.findMany(); await this.prisma.honeyBatchRelocation.findMany();
data.honeyBatchContainerAllocation =
await this.prisma.honeyBatchContainerAllocation.findMany();
data.stockItem = await this.prisma.stockItem.findMany(); data.stockItem = await this.prisma.stockItem.findMany();
data.stockMovement = await this.prisma.stockMovement.findMany(); data.stockMovement = await this.prisma.stockMovement.findMany();
data.product = await this.prisma.product.findMany(); data.product = await this.prisma.product.findMany();
data.labelTemplate = await this.prisma.labelTemplate.findMany(); data.labelTemplate = await this.prisma.labelTemplate.findMany();
data.bottlingBatch = await this.prisma.bottlingBatch.findMany(); data.bottlingBatch = await this.prisma.bottlingBatch.findMany();
data.rawMaterialLot = await this.prisma.rawMaterialLot.findMany();
data.productionBatch = await this.prisma.productionBatch.findMany();
data.productionInput = await this.prisma.productionInput.findMany();
data.shopStockRequest = await this.prisma.shopStockRequest.findMany();
data.batchMovement = await this.prisma.batchMovement.findMany();
data.equipment = await this.prisma.equipment.findMany(); data.equipment = await this.prisma.equipment.findMany();
data.webhookSubscription = await this.prisma.webhookSubscription.findMany(); data.webhookSubscription = await this.prisma.webhookSubscription.findMany();
data.automationRule = await this.prisma.automationRule.findMany(); data.automationRule = await this.prisma.automationRule.findMany();
data.automationSequence = await this.prisma.automationSequence.findMany();
data.siteSettings = await this.prisma.siteSettings.findMany(); data.siteSettings = await this.prisma.siteSettings.findMany();
data.backupSettings = await this.prisma.backupSettings.findMany();
data.calendarSyncSettings =
await this.prisma.calendarSyncSettings.findMany();
data.calendarSyncOutboxEntry =
await this.prisma.calendarSyncOutboxEntry.findMany();
const documents = await this.prisma.document.findMany(); const documents = await this.prisma.document.findMany();
data.document = await Promise.all( data.document = await Promise.all(
@@ -213,21 +304,32 @@ export class BackupService {
throw new BadRequestException('Invalid backup file'); throw new BadRequestException('Invalid backup file');
} }
const isTrustedOnly = (key: string): boolean => const isAllowedUnsealed = (key: string): boolean =>
(TRUSTED_ONLY_TABLES as readonly string[]).includes(key); (LEGACY_UNSEALED_TABLES as readonly string[]).includes(key);
const skipped = trusted const skipped = trusted
? [] ? []
: TRUSTED_ONLY_TABLES.filter((key) => { : Object.keys(bundle.data).filter((key) => {
const value = bundle.data[key]; const value = bundle.data[key];
return Array.isArray(value) && value.length > 0; return (
MODEL_SECTIONS.has(key) &&
!isAllowedUnsealed(key) &&
Array.isArray(value) &&
value.length > 0
);
}); });
const rowsOf = (key: string): Row[] => { const rowsOf = (key: string): Row[] => {
if (!trusted && isTrustedOnly(key)) { if (!trusted && !isAllowedUnsealed(key)) {
return []; return [];
} }
const value = bundle.data[key]; const value = bundle.data[key];
return Array.isArray(value) ? (value as Row[]) : []; return Array.isArray(value) ? (value as Row[]) : [];
}; };
// An unsealed file may add the rows it carries, never rewrite or roll
// back ones that already exist here.
const updateWith = (data: Row): Row => (trusted ? data : {});
// Document files the restored rows no longer point at - deleted only
// once the transaction has committed.
const replacedDocumentFiles: string[] = [];
const imported: Record<string, number> = {}; const imported: Record<string, number> = {};
@@ -324,11 +426,26 @@ export class BackupService {
await tx.apiary.upsert({ await tx.apiary.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.apiary = rowsOf('apiary').length; imported.apiary = rowsOf('apiary').length;
// Queens already here before this import keep their mother line when
// the file is unsealed (add-only, see updateWith).
const preexistingQueenIds = new Set<string>(
trusted
? []
: (
await tx.queen.findMany({
where: {
id: { in: rowsOf('queen').map((row) => row.id as string) },
},
select: { id: true },
})
).map((queen) => queen.id),
);
// Inserted without motherQueenId first (a daughter queen can appear // Inserted without motherQueenId first (a daughter queen can appear
// before her mother in export order) - patched in a second pass // before her mother in export order) - patched in a second pass
// below once every queen row exists. // below once every queen row exists.
@@ -337,7 +454,7 @@ export class BackupService {
await tx.queen.upsert({ await tx.queen.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: withoutMother as never, create: withoutMother as never,
update: withoutMother as never, update: updateWith(withoutMother) as never,
}); });
} }
imported.queen = rowsOf('queen').length; imported.queen = rowsOf('queen').length;
@@ -346,13 +463,13 @@ export class BackupService {
await tx.colony.upsert({ await tx.colony.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.colony = rowsOf('colony').length; imported.colony = rowsOf('colony').length;
for (const row of rowsOf('queen')) { for (const row of rowsOf('queen')) {
if (row.motherQueenId) { if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
await tx.queen.update({ await tx.queen.update({
where: { id: row.id as string }, where: { id: row.id as string },
data: { motherQueenId: row.motherQueenId as string }, data: { motherQueenId: row.motherQueenId as string },
@@ -369,12 +486,12 @@ export class BackupService {
asString(row, 'movedByUserId'), asString(row, 'movedByUserId'),
), ),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
movedByUserId: resolveOptionalUserId( movedByUserId: resolveOptionalUserId(
asString(row, 'movedByUserId'), asString(row, 'movedByUserId'),
), ),
} as never, }) as never,
}); });
} }
imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length; imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length;
@@ -383,7 +500,7 @@ export class BackupService {
await tx.colonyLineage.upsert({ await tx.colonyLineage.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.colonyLineage = rowsOf('colonyLineage').length; imported.colonyLineage = rowsOf('colonyLineage').length;
@@ -395,10 +512,10 @@ export class BackupService {
...row, ...row,
userId: resolveOptionalUserId(asString(row, 'userId')), userId: resolveOptionalUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveOptionalUserId(asString(row, 'userId')), userId: resolveOptionalUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.queenEvaluation = rowsOf('queenEvaluation').length; imported.queenEvaluation = rowsOf('queenEvaluation').length;
@@ -407,7 +524,7 @@ export class BackupService {
await tx.queenColonyHistory.upsert({ await tx.queenColonyHistory.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.queenColonyHistory = rowsOf('queenColonyHistory').length; imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
@@ -419,10 +536,10 @@ export class BackupService {
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.inspection = rowsOf('inspection').length; imported.inspection = rowsOf('inspection').length;
@@ -439,7 +556,7 @@ export class BackupService {
asString(row, 'createdByUserId'), asString(row, 'createdByUserId'),
), ),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
assignedUserId: resolveOptionalUserId( assignedUserId: resolveOptionalUserId(
asString(row, 'assignedUserId'), asString(row, 'assignedUserId'),
@@ -447,7 +564,7 @@ export class BackupService {
createdByUserId: resolveOptionalUserId( createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'), asString(row, 'createdByUserId'),
), ),
} as never, }) as never,
}); });
} }
imported.taskTemplate = rowsOf('taskTemplate').length; imported.taskTemplate = rowsOf('taskTemplate').length;
@@ -464,7 +581,7 @@ export class BackupService {
asString(row, 'createdByUserId'), asString(row, 'createdByUserId'),
), ),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
assignedUserId: resolveOptionalUserId( assignedUserId: resolveOptionalUserId(
asString(row, 'assignedUserId'), asString(row, 'assignedUserId'),
@@ -472,7 +589,7 @@ export class BackupService {
createdByUserId: resolveOptionalUserId( createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'), asString(row, 'createdByUserId'),
), ),
} as never, }) as never,
}); });
} }
imported.task = rowsOf('task').length; imported.task = rowsOf('task').length;
@@ -484,10 +601,10 @@ export class BackupService {
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.varroaMeasurement = rowsOf('varroaMeasurement').length; imported.varroaMeasurement = rowsOf('varroaMeasurement').length;
@@ -499,10 +616,10 @@ export class BackupService {
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.swarmControlCheck = rowsOf('swarmControlCheck').length; imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
@@ -514,10 +631,10 @@ export class BackupService {
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.winterCheck = rowsOf('winterCheck').length; imported.winterCheck = rowsOf('winterCheck').length;
@@ -529,10 +646,10 @@ export class BackupService {
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.treatment = rowsOf('treatment').length; imported.treatment = rowsOf('treatment').length;
@@ -544,10 +661,10 @@ export class BackupService {
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.feeding = rowsOf('feeding').length; imported.feeding = rowsOf('feeding').length;
@@ -559,10 +676,10 @@ export class BackupService {
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.harvest = rowsOf('harvest').length; imported.harvest = rowsOf('harvest').length;
@@ -571,7 +688,7 @@ export class BackupService {
await tx.harvestColony.upsert({ await tx.harvestColony.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.harvestColony = rowsOf('harvestColony').length; imported.harvestColony = rowsOf('harvestColony').length;
@@ -583,10 +700,10 @@ export class BackupService {
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.extraction = rowsOf('extraction').length; imported.extraction = rowsOf('extraction').length;
@@ -595,7 +712,7 @@ export class BackupService {
await tx.extractionHarvest.upsert({ await tx.extractionHarvest.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.extractionHarvest = rowsOf('extractionHarvest').length; imported.extractionHarvest = rowsOf('extractionHarvest').length;
@@ -604,7 +721,7 @@ export class BackupService {
await tx.storageContainer.upsert({ await tx.storageContainer.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.storageContainer = rowsOf('storageContainer').length; imported.storageContainer = rowsOf('storageContainer').length;
@@ -613,7 +730,7 @@ export class BackupService {
await tx.honeyBatch.upsert({ await tx.honeyBatch.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.honeyBatch = rowsOf('honeyBatch').length; imported.honeyBatch = rowsOf('honeyBatch').length;
@@ -625,10 +742,10 @@ export class BackupService {
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveUserId(asString(row, 'userId')), userId: resolveUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.honeyMeasurement = rowsOf('honeyMeasurement').length; imported.honeyMeasurement = rowsOf('honeyMeasurement').length;
@@ -640,14 +757,25 @@ export class BackupService {
...row, ...row,
userId: resolveOptionalUserId(asString(row, 'userId')), userId: resolveOptionalUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: updateWith({
...row, ...row,
userId: resolveOptionalUserId(asString(row, 'userId')), userId: resolveOptionalUserId(asString(row, 'userId')),
} as never, }) as never,
}); });
} }
imported.honeyBatchRelocation = rowsOf('honeyBatchRelocation').length; imported.honeyBatchRelocation = rowsOf('honeyBatchRelocation').length;
for (const row of rowsOf('honeyBatchContainerAllocation')) {
await tx.honeyBatchContainerAllocation.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.honeyBatchContainerAllocation = rowsOf(
'honeyBatchContainerAllocation',
).length;
for (const row of rowsOf('stockItem')) { for (const row of rowsOf('stockItem')) {
await tx.stockItem.upsert({ await tx.stockItem.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -676,7 +804,7 @@ export class BackupService {
await tx.product.upsert({ await tx.product.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.product = rowsOf('product').length; imported.product = rowsOf('product').length;
@@ -685,13 +813,28 @@ export class BackupService {
await tx.labelTemplate.upsert({ await tx.labelTemplate.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.labelTemplate = rowsOf('labelTemplate').length; imported.labelTemplate = rowsOf('labelTemplate').length;
for (const row of rowsOf('bottlingBatch')) { for (const row of rowsOf('bottlingBatch')) {
await tx.bottlingBatch.upsert({ await tx.bottlingBatch.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: updateWith({
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
}) as never,
});
}
imported.bottlingBatch = rowsOf('bottlingBatch').length;
for (const row of rowsOf('rawMaterialLot')) {
await tx.rawMaterialLot.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: { create: {
...row, ...row,
@@ -703,13 +846,62 @@ export class BackupService {
} as never, } as never,
}); });
} }
imported.bottlingBatch = rowsOf('bottlingBatch').length; imported.rawMaterialLot = rowsOf('rawMaterialLot').length;
for (const row of rowsOf('productionBatch')) {
await tx.productionBatch.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
});
}
imported.productionBatch = rowsOf('productionBatch').length;
for (const row of rowsOf('productionInput')) {
await tx.productionInput.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.productionInput = rowsOf('productionInput').length;
for (const row of rowsOf('shopStockRequest')) {
await tx.shopStockRequest.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.shopStockRequest = rowsOf('shopStockRequest').length;
// After every kind of batch and the shop requests it may reference.
for (const row of rowsOf('batchMovement')) {
await tx.batchMovement.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
});
}
imported.batchMovement = rowsOf('batchMovement').length;
for (const row of rowsOf('equipment')) { for (const row of rowsOf('equipment')) {
await tx.equipment.upsert({ await tx.equipment.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: row as never,
update: row as never, update: updateWith(row) as never,
}); });
} }
imported.equipment = rowsOf('equipment').length; imported.equipment = rowsOf('equipment').length;
@@ -748,6 +940,23 @@ export class BackupService {
} }
imported.automationRule = rowsOf('automationRule').length; imported.automationRule = rowsOf('automationRule').length;
// Keyed by triggerType+scope, its primary key (the table has no id).
// Restoring the counters keeps the next lot number from colliding
// with a restored one.
for (const row of rowsOf('automationSequence')) {
await tx.automationSequence.upsert({
where: {
triggerType_scope: {
triggerType: row.triggerType as never,
scope: row.scope as string,
},
},
create: row as never,
update: row as never,
});
}
imported.automationSequence = rowsOf('automationSequence').length;
for (const row of rowsOf('siteSettings')) { for (const row of rowsOf('siteSettings')) {
const { id, ...withoutId } = row; const { id, ...withoutId } = row;
await tx.siteSettings.upsert({ await tx.siteSettings.upsert({
@@ -758,6 +967,48 @@ export class BackupService {
} }
imported.siteSettings = rowsOf('siteSettings').length; imported.siteSettings = rowsOf('siteSettings').length;
for (const row of rowsOf('backupSettings')) {
const { id, ...withoutId } = row;
await tx.backupSettings.upsert({
where: { id: (id as string) ?? 'singleton' },
create: row as never,
update: withoutId as never,
});
}
imported.backupSettings = rowsOf('backupSettings').length;
for (const row of rowsOf('calendarSyncSettings')) {
const { id, ...withoutId } = row;
await tx.calendarSyncSettings.upsert({
where: { id: (id as string) ?? 'singleton' },
create: row as never,
update: withoutId as never,
});
}
imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length;
// Keyed by sourceType+sourceId, like CalendarSyncService.enqueue -
// the target database may already track the same source under
// another id. graphEventId comes along, so a restored entry keeps
// updating its existing M365 event instead of creating a duplicate.
for (const row of rowsOf('calendarSyncOutboxEntry')) {
const withoutId: Row = { ...row };
delete withoutId.id;
await tx.calendarSyncOutboxEntry.upsert({
where: {
sourceType_sourceId: {
sourceType: row.sourceType as never,
sourceId: row.sourceId as string,
},
},
create: row as never,
update: withoutId as never,
});
}
imported.calendarSyncOutboxEntry = rowsOf(
'calendarSyncOutboxEntry',
).length;
// Writes the file to disk as part of this loop, before the // Writes the file to disk as part of this loop, before the
// surrounding transaction is known to commit - if a later entity in // surrounding transaction is known to commit - if a later entity in
// this same import fails and the transaction rolls back, a file // this same import fails and the transaction rolls back, a file
@@ -799,6 +1050,13 @@ export class BackupService {
`${crypto.randomUUID()}${extension}`, `${crypto.randomUUID()}${extension}`,
); );
await fs.writeFile(storagePath, buffer); await fs.writeFile(storagePath, buffer);
const previous = await tx.document.findUnique({
where: { id: row.id as string },
select: { storagePath: true },
});
if (previous) {
replacedDocumentFiles.push(previous.storagePath);
}
const withoutContent: Row = { ...row }; const withoutContent: Row = { ...row };
delete withoutContent.contentBase64; delete withoutContent.contentBase64;
const data = { const data = {
@@ -859,6 +1117,39 @@ export class BackupService {
afterData: { imported, skipped, sealed: trusted }, afterData: { imported, skipped, sealed: trusted },
}); });
return { imported, skipped }; await this.removeReplacedDocumentFiles(replacedDocumentFiles);
return { imported, skipped, sealed: trusted };
}
/**
* Deletes the files restored document rows no longer point at - only now
* that the import has committed, only inside the documents root and only
* while no row still uses them. Best effort: a file left behind wastes
* space, it never breaks a document.
*/
private async removeReplacedDocumentFiles(
storagePaths: string[],
): Promise<void> {
const storageRoot = path.resolve(documentsStorageRoot());
for (const storagePath of storagePaths) {
const resolved = path.resolve(storagePath);
if (!resolved.startsWith(storageRoot + path.sep)) {
this.logger.warn(
`Ersetzte Dokumentdatei liegt außerhalb von ${storageRoot} und bleibt erhalten: ${resolved}`,
);
continue;
}
if ((await this.prisma.document.count({ where: { storagePath } })) > 0) {
continue;
}
try {
await fs.unlink(resolved);
} catch (error: unknown) {
this.logger.warn(
`Ersetzte Dokumentdatei ${resolved} konnte nicht gelöscht werden: ${String(error)}`,
);
}
}
} }
} }
@@ -8,6 +8,7 @@ import {
IsString, IsString,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
/** /**
* Every field of a bottling batch stays correctable after the fact - a * Every field of a bottling batch stays correctable after the fact - a
@@ -21,7 +22,7 @@ import {
*/ */
export class UpdateBottlingBatchDto { export class UpdateBottlingBatchDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
honeyBatchId?: string; honeyBatchId?: string;
@@ -35,29 +36,29 @@ export class UpdateBottlingBatchDto {
productId?: string | null; productId?: string | null;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsDateString() @IsDateString()
date?: string; date?: string;
@ApiPropertyOptional({ description: 'Glasgröße in Gramm' }) @ApiPropertyOptional({ description: 'Glasgröße in Gramm' })
@IsOptional() @IsOptionalNotNull()
@IsNumber() @IsNumber()
@IsPositive() @IsPositive()
jarSize?: number; jarSize?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsInt() @IsInt()
@IsPositive() @IsPositive()
jarCount?: number; jarCount?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsDateString() @IsDateString()
bestBeforeDate?: string; bestBeforeDate?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
lotNumber?: string; lotNumber?: string;
} }
@@ -115,6 +115,23 @@ describe('CalendarSyncSettingsService', () => {
); );
}); });
// PATCH null rule: null on the nullable groupId clears it, like "".
it('clears groupId when given null', async () => {
prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing);
prisma.calendarSyncSettings.update.mockResolvedValue({
...existing,
groupId: null,
});
await service.update({ groupId: null }, 'user-1');
expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ groupId: null }) as unknown,
}),
);
});
it('leaves fields untouched when omitted from the dto', async () => { it('leaves fields untouched when omitted from the dto', async () => {
prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing); prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing);
prisma.calendarSyncSettings.update.mockResolvedValue(existing); prisma.calendarSyncSettings.update.mockResolvedValue(existing);
@@ -6,7 +6,8 @@ import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settin
const SINGLETON_ID = 'singleton'; const SINGLETON_ID = 'singleton';
function emptyToNull(value: string | undefined): string | null { // "" (the GUI emptying the field) and null both clear the stored value.
function emptyToNull(value: string | null | undefined): string | null {
return value === '' ? null : (value ?? null); return value === '' ? null : (value ?? null);
} }
@@ -1,4 +1,12 @@
import { Body, Controller, Get, Patch, Post } from '@nestjs/common'; import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Patch,
Post,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
@@ -37,10 +45,13 @@ export class CalendarSyncController {
return this.calendarSyncService.getStatus(); return this.calendarSyncService.getStatus();
} }
// Starts a pass and answers at once (202) - the pass itself can take
// minutes. The returned status says running: true; GET status follows it.
@RequirePermission('calendar:configure') @RequirePermission('calendar:configure')
@Post('run-now') @Post('run-now')
async runNow() { @HttpCode(HttpStatus.ACCEPTED)
await this.calendarSyncService.runNow(); runNow() {
this.calendarSyncService.startRun();
return this.calendarSyncService.getStatus(); return this.calendarSyncService.getStatus();
} }
} }
@@ -1,7 +1,9 @@
import { Logger } from '@nestjs/common';
import { CalendarSyncService } from './calendar-sync.service'; import { CalendarSyncService } from './calendar-sync.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { GraphClientService } from './graph-client.service'; import { GraphClientService, GraphRequestError } from './graph-client.service';
import { Prisma } from '../../generated/prisma';
describe('CalendarSyncService', () => { describe('CalendarSyncService', () => {
let service: CalendarSyncService; let service: CalendarSyncService;
@@ -10,6 +12,7 @@ describe('CalendarSyncService', () => {
upsert: jest.Mock; upsert: jest.Mock;
findUnique: jest.Mock; findUnique: jest.Mock;
update: jest.Mock; update: jest.Mock;
updateMany: jest.Mock;
findMany: jest.Mock; findMany: jest.Mock;
count: jest.Mock; count: jest.Mock;
}; };
@@ -41,13 +44,47 @@ describe('CalendarSyncService', () => {
updatedAt: new Date(), updatedAt: new Date(),
}; };
const pendingEntry = (overrides: Record<string, unknown> = {}) => ({
id: 'entry-1',
sourceType: 'TASK',
sourceId: 'task-1',
status: 'PENDING',
graphEventId: null,
attempts: 0,
revision: 3,
claimedAt: null,
...overrides,
});
/** Derived from the outbox entry, shaped like a GUID. */
const TRANSACTION_ID = expect.stringMatching(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
) as string;
/** Free, or held by a worker that gave up long ago (cut-off time). */
const CLAIMABLE = {
OR: [{ claimedAt: null }, { claimedAt: { lt: expect.any(Date) as Date } }],
};
type UpdateManyArgs = {
where: Record<string, unknown>;
data: Record<string, unknown>;
};
const updateManyCalls = () =>
prisma.calendarSyncOutboxEntry.updateMany.mock.calls.map(
([args]) => args as UpdateManyArgs,
);
/** The claim taken by this pass - every later write is conditional on it. */
const claimedAt = () => updateManyCalls()[0].data.claimedAt as Date;
beforeEach(() => { beforeEach(() => {
prisma = { prisma = {
calendarSyncOutboxEntry: { calendarSyncOutboxEntry: {
upsert: jest.fn(), upsert: jest.fn(),
findUnique: jest.fn(), findUnique: jest.fn(),
update: jest.fn(), update: jest.fn(),
findMany: jest.fn(), updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0), count: jest.fn().mockResolvedValue(0),
}, },
task: { findUnique: jest.fn() }, task: { findUnique: jest.fn() },
@@ -94,7 +131,7 @@ describe('CalendarSyncService', () => {
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
}); });
it('upserts a PENDING row, resetting attempts and lastError', async () => { it('upserts a PENDING row, resetting attempts and lastError and counting the revision up', async () => {
await service.enqueue('TASK', 'task-1'); await service.enqueue('TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({ expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({
@@ -102,11 +139,58 @@ describe('CalendarSyncService', () => {
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' }, sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
}, },
create: { sourceType: 'TASK', sourceId: 'task-1' }, create: { sourceType: 'TASK', sourceId: 'task-1' },
update: { status: 'PENDING', attempts: 0, lastError: null }, update: {
status: 'PENDING',
attempts: 0,
lastError: null,
revision: { increment: 1 },
},
}); });
}); });
}); });
// enqueue()/cancel() run 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;
beforeEach(() => {
errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
});
afterEach(() => {
errorSpy.mockRestore();
});
it('enqueue never throws and logs the failure with the record', async () => {
settingsService.get.mockRejectedValue(new Error('db down'));
await expect(
service.enqueue('INSPECTION', 'inspection-1'),
).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
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', () => { describe('cancel', () => {
it('does nothing when no outbox row exists', async () => { it('does nothing when no outbox row exists', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null); prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null);
@@ -151,6 +235,25 @@ describe('CalendarSyncService', () => {
}); });
describe('processOutbox', () => { describe('processOutbox', () => {
const dueTask = {
id: 'task-1',
title: 'Beute prüfen',
dueDate: new Date('2026-03-07'),
};
/** The outcome write: only lands if the row is unchanged since the claim. */
const expectSettled = (data: Record<string, unknown>) => {
expect(updateManyCalls()[1]).toEqual({
where: {
id: 'entry-1',
status: 'PENDING',
revision: 3,
claimedAt: claimedAt(),
},
data: { ...data, claimedAt: null },
});
};
it('does nothing when sync is disabled, unconfigured, or no groupId', async () => { it('does nothing when sync is disabled, unconfigured, or no groupId', async () => {
settingsService.get.mockResolvedValue({ settingsService.get.mockResolvedValue({
...enabledSettings, ...enabledSettings,
@@ -160,76 +263,170 @@ describe('CalendarSyncService', () => {
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled(); expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
}); });
it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => { it('only picks PENDING rows that nobody holds, or whose holder gave up long ago', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ await service.processOutbox();
{ const after = Date.now();
id: 'entry-1',
sourceType: 'TASK', const [[query]] = prisma.calendarSyncOutboxEntry.findMany.mock.calls as [
sourceId: 'task-1', [{ where: Record<string, unknown> }],
graphEventId: null, ];
attempts: 0, expect(query.where).toEqual({
}, status: 'PENDING',
]); ...CLAIMABLE,
prisma.task.findUnique.mockResolvedValue({
id: 'task-1',
title: 'Beute prüfen',
dueDate: new Date('2026-03-07'),
}); });
const staleBefore = (
query.where.OR as [unknown, { claimedAt: { lt: Date } }]
)[1].claimedAt.lt;
// A claim counts as abandoned only after several minutes - far longer
// than one entry can take with bounded Graph requests.
expect(after - staleBefore.getTime()).toBeGreaterThanOrEqual(
5 * 60 * 1000,
);
});
it('claims a row with a conditional update before working on it', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
]);
prisma.task.findUnique.mockResolvedValue(dueTask);
graphClient.createEvent.mockResolvedValue('graph-event-1'); graphClient.createEvent.mockResolvedValue('graph-event-1');
await service.processOutbox(); await service.processOutbox();
expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', { expect(updateManyCalls()[0]).toEqual({
title: 'Beute prüfen', where: {
date: new Date('2026-03-07'), id: 'entry-1',
}); status: 'PENDING',
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ revision: 3,
where: { id: 'entry-1' }, ...CLAIMABLE,
data: {
status: 'SYNCED',
graphEventId: 'graph-event-1',
lastError: null,
}, },
data: { claimedAt: expect.any(Date) as Date },
}); });
const [claimOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [createOrder] = graphClient.createEvent.mock.invocationCallOrder;
expect(claimOrder).toBeLessThan(createOrder);
}); });
it('marks the entry SKIPPED when the Task no longer has a dueDate', async () => { it('leaves a row alone when another pod or run claimed it first', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
{ pendingEntry(),
id: 'entry-1',
sourceType: 'TASK',
sourceId: 'task-1',
graphEventId: null,
attempts: 0,
},
]); ]);
prisma.task.findUnique.mockResolvedValue({ prisma.calendarSyncOutboxEntry.updateMany.mockResolvedValueOnce({
id: 'task-1', count: 0,
title: 'Beute prüfen',
dueDate: null,
}); });
await service.processOutbox(); await service.processOutbox();
expect(prisma.task.findUnique).not.toHaveBeenCalled();
expect(graphClient.createEvent).not.toHaveBeenCalled(); expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledTimes(
where: { id: 'entry-1' }, 1,
data: { status: 'SKIPPED' }, );
});
it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
]);
prisma.task.findUnique.mockResolvedValue(dueTask);
graphClient.createEvent.mockResolvedValue('graph-event-1');
await service.processOutbox();
expect(graphClient.createEvent).toHaveBeenCalledWith(
'group-1',
{ title: 'Beute prüfen', date: new Date('2026-03-07') },
TRANSACTION_ID,
);
expectSettled({
status: 'SYNCED',
graphEventId: 'graph-event-1',
lastError: null,
});
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
});
it('keeps the new event id but stays PENDING when the source changed during the sync', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
]);
prisma.task.findUnique.mockResolvedValue(dueTask);
graphClient.createEvent.mockResolvedValue('graph-event-1');
prisma.calendarSyncOutboxEntry.updateMany
.mockResolvedValueOnce({ count: 1 }) // claim
.mockResolvedValueOnce({ count: 0 }); // re-enqueued meanwhile
await service.processOutbox();
// Not SYNCED: the newer state still has to reach M365, and the next
// pass must update this event rather than create a second one.
expect(updateManyCalls()[2]).toEqual({
where: { id: 'entry-1', status: 'PENDING', claimedAt: claimedAt() },
data: { graphEventId: 'graph-event-1', claimedAt: null },
});
expect(graphClient.deleteEvent).not.toHaveBeenCalled();
});
it('removes the event again when the source left the calendar during the sync', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
]);
prisma.task.findUnique.mockResolvedValue(dueTask);
graphClient.createEvent.mockResolvedValue('graph-event-1');
prisma.calendarSyncOutboxEntry.updateMany
.mockResolvedValueOnce({ count: 1 }) // claim
.mockResolvedValueOnce({ count: 0 }) // not unchanged
.mockResolvedValueOnce({ count: 0 }) // not PENDING: cancelled
.mockResolvedValueOnce({ count: 1 }); // claim released
await service.processOutbox();
expect(updateManyCalls()[3]).toEqual({
where: { id: 'entry-1', claimedAt: claimedAt() },
data: { claimedAt: null },
});
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
'group-1',
'graph-event-1',
);
});
it('marks the entry SKIPPED when the Task no longer has a dueDate', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
]);
prisma.task.findUnique.mockResolvedValue({ ...dueTask, dueDate: null });
await service.processOutbox();
expect(graphClient.createEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED' });
});
it('only lets go of the claim when the row changed before SKIPPED could be written', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
]);
prisma.task.findUnique.mockResolvedValue({ ...dueTask, dueDate: null });
prisma.calendarSyncOutboxEntry.updateMany
.mockResolvedValueOnce({ count: 1 })
.mockResolvedValueOnce({ count: 0 });
await service.processOutbox();
expect(updateManyCalls()[2]).toEqual({
where: { id: 'entry-1', claimedAt: claimedAt() },
data: { claimedAt: null },
}); });
}); });
it('updates an existing Graph event when graphEventId is already set', async () => { it('updates an existing Graph event when graphEventId is already set', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
{ pendingEntry({ graphEventId: 'graph-event-1' }),
id: 'entry-1',
sourceType: 'TASK',
sourceId: 'task-1',
graphEventId: 'graph-event-1',
attempts: 0,
},
]); ]);
prisma.task.findUnique.mockResolvedValue({ prisma.task.findUnique.mockResolvedValue({
id: 'task-1', ...dueTask,
title: 'Beute prüfen (geändert)', title: 'Beute prüfen (geändert)',
dueDate: new Date('2026-03-10'), dueDate: new Date('2026-03-10'),
}); });
@@ -243,96 +440,108 @@ describe('CalendarSyncService', () => {
{ title: 'Beute prüfen (geändert)', date: new Date('2026-03-10') }, { title: 'Beute prüfen (geändert)', date: new Date('2026-03-10') },
); );
expect(graphClient.createEvent).not.toHaveBeenCalled(); expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ expectSettled({
where: { id: 'entry-1' }, status: 'SYNCED',
data: { graphEventId: 'graph-event-1',
status: 'SYNCED', lastError: null,
graphEventId: 'graph-event-1',
lastError: null,
},
}); });
}); });
it('falls back to creating a fresh event when updating a stale graphEventId 404s', async () => { it.each([404, 410])(
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ 'falls back to creating a fresh event when the stored event is gone (%i)',
{ async (status) => {
id: 'entry-1', prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
sourceType: 'TASK', pendingEntry({ graphEventId: 'deleted-on-m365' }),
sourceId: 'task-1', ]);
graphEventId: 'deleted-on-m365', prisma.task.findUnique.mockResolvedValue(dueTask);
attempts: 0, graphClient.updateEvent.mockRejectedValue(
}, new GraphRequestError('updateEvent', status, 'gone'),
]); );
prisma.task.findUnique.mockResolvedValue({ graphClient.createEvent.mockResolvedValue('graph-event-new');
id: 'task-1',
title: 'Beute prüfen',
dueDate: new Date('2026-03-07'),
});
graphClient.updateEvent.mockRejectedValue(new Error('404 Not Found'));
graphClient.createEvent.mockResolvedValue('graph-event-new');
await service.processOutbox(); await service.processOutbox();
expect(graphClient.createEvent).toHaveBeenCalled(); expect(graphClient.createEvent).toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ expectSettled({
where: { id: 'entry-1' },
data: {
status: 'SYNCED', status: 'SYNCED',
graphEventId: 'graph-event-new', graphEventId: 'graph-event-new',
lastError: null, lastError: null,
}, });
}); },
}); );
it.each([
['a Graph server error', new GraphRequestError('updateEvent', 503, '')],
['a throttled request', new GraphRequestError('updateEvent', 429, '')],
['a network failure', new Error('fetch failed')],
])(
'does not create a second event after %s, but retries the update later',
async (_case, failure) => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1', attempts: 1 }),
]);
prisma.task.findUnique.mockResolvedValue(dueTask);
graphClient.updateEvent.mockRejectedValue(failure);
await service.processOutbox();
expect(graphClient.createEvent).not.toHaveBeenCalled();
expectSettled({
status: 'PENDING',
attempts: 2,
lastError: failure.message,
});
},
);
it('retries (stays PENDING) on failure below the max attempt count', async () => { it('retries (stays PENDING) on failure below the max attempt count', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
{ pendingEntry({ attempts: 1 }),
id: 'entry-1',
sourceType: 'TASK',
sourceId: 'task-1',
graphEventId: null,
attempts: 1,
},
]); ]);
prisma.task.findUnique.mockRejectedValue(new Error('db error')); prisma.task.findUnique.mockRejectedValue(new Error('db error'));
await service.processOutbox(); await service.processOutbox();
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ expectSettled({ status: 'PENDING', attempts: 2, lastError: 'db error' });
where: { id: 'entry-1' }, });
data: { status: 'PENDING', attempts: 2, lastError: 'db error' },
// lastError is stored and shown to admins - a Prisma message would carry
// query values (titles, names) into it.
it('stores a Prisma failure in lastError by name, code and model only', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ attempts: 1 }),
]);
prisma.task.findUnique.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2024',
clientVersion: '7.9.1',
meta: { modelName: 'Task' },
}),
);
await service.processOutbox();
expectSettled({
status: 'PENDING',
attempts: 2,
lastError: 'PrismaClientKnownRequestError P2024 (Task)',
}); });
}); });
it('marks FAILED once the max attempt count is reached', async () => { it('marks FAILED once the max attempt count is reached', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
{ pendingEntry({ attempts: 4 }),
id: 'entry-1',
sourceType: 'TASK',
sourceId: 'task-1',
graphEventId: null,
attempts: 4,
},
]); ]);
prisma.task.findUnique.mockRejectedValue(new Error('db error')); prisma.task.findUnique.mockRejectedValue(new Error('db error'));
await service.processOutbox(); await service.processOutbox();
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
where: { id: 'entry-1' },
data: { status: 'FAILED', attempts: 5, lastError: 'db error' },
});
}); });
it('builds titles per source type matching the read-only calendar view', async () => { it('builds titles per source type matching the read-only calendar view', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
{ pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
id: 'e1',
sourceType: 'INSPECTION',
sourceId: 'i1',
graphEventId: null,
attempts: 0,
},
]); ]);
prisma.inspection.findUnique.mockResolvedValue({ prisma.inspection.findUnique.mockResolvedValue({
id: 'i1', id: 'i1',
@@ -343,10 +552,224 @@ describe('CalendarSyncService', () => {
await service.processOutbox(); await service.processOutbox();
expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', { expect(graphClient.createEvent).toHaveBeenCalledWith(
title: 'Durchsicht V-001', 'group-1',
date: new Date('2026-03-07'), { title: 'Durchsicht V-001', date: new Date('2026-03-07') },
TRANSACTION_ID,
);
});
});
// A create that timed out may still have reached Graph. The retry - by
// this or another pod - sends the same transactionId, so Graph can drop
// it instead of adding a second event.
describe('transactionId on create', () => {
/** Runs one pass over the entry and returns the transactionId sent. */
const transactionIdFor = async (entry: Record<string, unknown>) => {
graphClient.createEvent.mockClear();
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(entry),
]);
await service.processOutbox();
const [[, , transactionId]] = graphClient.createEvent.mock.calls as [
[string, unknown, string],
];
return transactionId;
};
beforeEach(() => {
prisma.task.findUnique.mockResolvedValue({
id: 'task-1',
title: 'Beute prüfen',
dueDate: new Date('2026-03-07'),
}); });
graphClient.createEvent.mockResolvedValue('graph-event-1');
});
it('is the same for every retry of the same create', async () => {
graphClient.createEvent.mockRejectedValueOnce(new Error('timeout'));
const first = await transactionIdFor({ attempts: 0 });
const retry = await transactionIdFor({ attempts: 1 });
expect(first).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(retry).toBe(first);
});
it('differs per outbox entry', async () => {
const one = await transactionIdFor({ id: 'entry-1' });
const other = await transactionIdFor({ id: 'entry-2' });
expect(other).not.toBe(one);
});
// A cancelled source deleted its event; enqueued again it needs a new
// one, which an old transactionId might be matched against.
it('is new once the entry was enqueued again', async () => {
const before = await transactionIdFor({ revision: 3 });
const after = await transactionIdFor({ revision: 4 });
expect(after).not.toBe(before);
});
it('is new when replacing an event that was deleted in M365', async () => {
const initial = await transactionIdFor({ graphEventId: null });
graphClient.updateEvent.mockRejectedValue(
new GraphRequestError('updateEvent', 404, 'gone'),
);
const replacement = await transactionIdFor({
graphEventId: 'deleted-on-m365',
});
expect(replacement).not.toBe(initial);
});
});
describe('runNow', () => {
it('gives FAILED entries a fresh set of attempts before processing', async () => {
await service.runNow();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [readOrder] =
prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(readOrder);
});
it('leaves FAILED entries alone on a regular cron tick', async () => {
await service.processOutbox();
expect(
prisma.calendarSyncOutboxEntry.updateMany,
).not.toHaveBeenCalledWith(
expect.objectContaining({ where: { status: 'FAILED' } }),
);
});
});
// "Jetzt synchronisieren" must not hold the HTTP request for a whole pass
// (up to 20 entries with several Graph calls each): it only starts it.
describe('startRun', () => {
const flush = async () => {
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
};
/** Lets the pass hang on reading the outbox until released. */
const holdOutbox = () => {
let release: () => void = () => undefined;
prisma.calendarSyncOutboxEntry.findMany.mockReturnValue(
new Promise((resolve) => {
release = () => resolve([]);
}),
);
return () => release();
};
it('returns while the pass is still running and reports it as running', async () => {
const release = holdOutbox();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
expect((await service.getStatus()).running).toBe(true);
release();
await flush();
expect((await service.getStatus()).running).toBe(false);
});
it('starts only one pass per pod at a time', async () => {
const release = holdOutbox();
service.startRun();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
release();
await flush();
});
it('logs a failed pass without query values and clears the indicator', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
service.startRun();
await flush();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect((await service.getStatus()).running).toBe(false);
errorSpy.mockRestore();
});
it('lets shutdown wait for a running pass', async () => {
const release = holdOutbox();
service.startRun();
let destroyed = false;
const shutdown = service.onModuleDestroy().then(() => {
destroyed = true;
});
await flush();
expect(destroyed).toBe(false);
release();
await shutdown;
expect(destroyed).toBe(true);
});
});
// The cron tick's own errors would otherwise reach the scheduler's logger
// with the full Prisma message and stack.
describe('scheduledRun (cron tick)', () => {
it('runs the regular pass', async () => {
await service.scheduledRun();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
});
it('never throws and logs a failed tick without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
await expect(service.scheduledRun()).resolves.toBeUndefined();
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
errorSpy.mockRestore();
}); });
}); });
@@ -355,7 +778,8 @@ describe('CalendarSyncService', () => {
prisma.calendarSyncOutboxEntry.count prisma.calendarSyncOutboxEntry.count
.mockResolvedValueOnce(2) // PENDING .mockResolvedValueOnce(2) // PENDING
.mockResolvedValueOnce(1) // FAILED .mockResolvedValueOnce(1) // FAILED
.mockResolvedValueOnce(5); // SYNCED .mockResolvedValueOnce(5) // SYNCED
.mockResolvedValueOnce(0); // held by a worker right now
const status = await service.getStatus(); const status = await service.getStatus();
@@ -366,6 +790,24 @@ describe('CalendarSyncService', () => {
pendingCount: 2, pendingCount: 2,
failedCount: 1, failedCount: 1,
syncedCount: 5, syncedCount: 5,
running: false,
});
});
// Another pod (its cron tick or its "Jetzt synchronisieren") may be the
// one working - its live claims show that across replicas.
it('reports running while any worker holds an entry', async () => {
prisma.calendarSyncOutboxEntry.count
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(1);
const status = await service.getStatus();
expect(status.running).toBe(true);
expect(prisma.calendarSyncOutboxEntry.count).toHaveBeenLastCalledWith({
where: { claimedAt: { gte: expect.any(Date) as Date } },
}); });
}); });
}); });
@@ -1,15 +1,20 @@
import { Injectable, Logger } from '@nestjs/common'; import { createHash } from 'node:crypto';
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { runAfterCommit } from '../common/after-commit';
import { loggableError } from '../common/loggable-error';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { import {
GraphClientService, GraphClientService,
GraphCalendarEventInput, GraphCalendarEventInput,
GraphRequestError,
} from './graph-client.service'; } from './graph-client.service';
import { import {
CalendarSyncOutboxEntry, CalendarSyncOutboxEntry,
CalendarSyncSettings, CalendarSyncSettings,
CalendarSyncSourceType, CalendarSyncSourceType,
Prisma,
} from '../../generated/prisma'; } from '../../generated/prisma';
// Bounded per tick so a large backlog can't turn one cron run into a // Bounded per tick so a large backlog can't turn one cron run into a
@@ -19,6 +24,47 @@ const BATCH_SIZE = 20;
// (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue // (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue
// (e.g. editing the source record) resets it back to PENDING. // (e.g. editing the source record) resets it back to PENDING.
const MAX_ATTEMPTS = 5; const MAX_ATTEMPTS = 5;
// A claim this old belongs to a worker that died mid-entry (pod restart);
// the entry is taken over. Far above the longest a live worker can hold one
// entry (three Graph requests, each bounded by GRAPH_REQUEST_TIMEOUT_MS).
const STALE_CLAIM_MS = 5 * 60 * 1000;
// Graph answers these when the stored event no longer exists (deleted on
// the M365 side) - only then is a fresh event the right fix.
const EVENT_GONE_STATUSES = [404, 410];
/** An outbox row this worker holds; every write back is conditional on it. */
type ClaimedEntry = CalendarSyncOutboxEntry & { claimedAt: Date };
/**
* Graph's transactionId for creating this entry's event, GUID-shaped.
* The same for every retry of one create - after a timeout, or by another
* pod after a stale claim - so Graph can drop the repeat. A new one for
* every deliberate create: per entry, per enqueue (revision) and per
* replaced event, so a fresh create is never matched against an event that
* was cancelled or deleted in M365 before.
*/
function eventTransactionId(entry: CalendarSyncOutboxEntry): string {
const hex = createHash('sha256')
.update(`${entry.id}:${entry.revision}:${entry.graphEventId ?? ''}`)
.digest('hex');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-');
}
/** Free, or held by a worker that gave up long ago. */
function claimableNow(): Prisma.CalendarSyncOutboxEntryWhereInput {
return {
OR: [
{ claimedAt: null },
{ claimedAt: { lt: new Date(Date.now() - STALE_CLAIM_MS) } },
],
};
}
export interface CalendarSyncStatusReport { export interface CalendarSyncStatusReport {
configured: boolean; configured: boolean;
@@ -27,6 +73,8 @@ export interface CalendarSyncStatusReport {
pendingCount: number; pendingCount: number;
failedCount: number; failedCount: number;
syncedCount: number; syncedCount: number;
/** A pass is under way - on this pod, or any pod holding an entry. */
running: boolean;
} }
function getErrorMessage(error: unknown): string { function getErrorMessage(error: unknown): string {
@@ -34,8 +82,10 @@ function getErrorMessage(error: unknown): string {
} }
@Injectable() @Injectable()
export class CalendarSyncService { export class CalendarSyncService implements OnModuleDestroy {
private readonly logger = new Logger(CalendarSyncService.name); private readonly logger = new Logger(CalendarSyncService.name);
// The pass started by "Jetzt synchronisieren" on this pod, if one runs.
private activeRun: Promise<void> | null = null;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@@ -58,27 +108,56 @@ export class CalendarSyncService {
return flagBySourceType[sourceType]; 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).
private afterCommit(
action: string,
sourceType: CalendarSyncSourceType,
sourceId: string,
work: () => Promise<void>,
): Promise<void> {
return runAfterCommit(
this.logger,
{ action, entityType: sourceType, entityId: sourceId },
work,
);
}
// Called from the ~6 domain services right after a create/update that // Called from the ~6 domain services right after a create/update that
// should appear on the M365 calendar. Upserts rather than always // should appear on the M365 calendar. Upserts rather than always
// inserting: re-enqueuing an already-synced row (e.g. a Task's due date // 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 // changed) resets it to PENDING while keeping its graphEventId, so the
// worker updates the existing Graph event instead of creating a duplicate. // 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.
async enqueue( async enqueue(
sourceType: CalendarSyncSourceType, sourceType: CalendarSyncSourceType,
sourceId: string, sourceId: string,
): Promise<void> { ): Promise<void> {
const settings = await this.settingsService.get(); await this.afterCommit(
if (!settings.enabled) { 'Calendar sync enqueue',
return; sourceType,
} sourceId,
if (!this.isSourceTypeEnabled(settings, sourceType)) { async () => {
return; const settings = await this.settingsService.get();
} if (!settings.enabled) {
await this.prisma.calendarSyncOutboxEntry.upsert({ return;
where: { sourceType_sourceId: { sourceType, sourceId } }, }
create: { sourceType, sourceId }, if (!this.isSourceTypeEnabled(settings, sourceType)) {
update: { status: 'PENDING', attempts: 0, lastError: null }, 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 // Called when a source record no longer belongs on the calendar (e.g. a
@@ -89,38 +168,60 @@ export class CalendarSyncService {
sourceType: CalendarSyncSourceType, sourceType: CalendarSyncSourceType,
sourceId: string, sourceId: string,
): Promise<void> { ): Promise<void> {
const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({ await this.afterCommit(
where: { sourceType_sourceId: { sourceType, sourceId } }, 'Calendar sync cancel',
}); sourceType,
if (!entry) { sourceId,
return; async () => {
} const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({
if (entry.graphEventId) { where: { sourceType_sourceId: { sourceType, sourceId } },
const settings = await this.settingsService.get(); });
if ( if (!entry) {
settings.enabled && return;
settings.groupId &&
this.graphClient.isConfigured()
) {
try {
await this.graphClient.deleteEvent(
settings.groupId,
entry.graphEventId,
);
} catch (error: unknown) {
this.logger.warn(
`Could not delete Graph event ${entry.graphEventId}: ${getErrorMessage(error)}`,
);
} }
} if (entry.graphEventId) {
} await this.deleteGraphEvent(entry.graphEventId);
await this.prisma.calendarSyncOutboxEntry.update({ }
where: { id: entry.id }, await this.prisma.calendarSyncOutboxEntry.update({
data: { status: 'SKIPPED', graphEventId: null }, where: { id: entry.id },
}); data: { status: 'SKIPPED', graphEventId: null },
});
},
);
} }
// Best-effort removal from M365: logged, never thrown.
private async deleteGraphEvent(graphEventId: string): Promise<void> {
const settings = await this.settingsService.get();
if (
!settings.enabled ||
!settings.groupId ||
!this.graphClient.isConfigured()
) {
return;
}
try {
await this.graphClient.deleteEvent(settings.groupId, graphEventId);
} catch (error: unknown) {
this.logger.warn(
`Could not delete Graph event ${graphEventId}: ${getErrorMessage(error)}`,
);
}
}
// The cron tick. An error is logged here, Prisma errors by name/code/model
// only (see loggableError) - left uncaught, the scheduler would log the
// full Prisma message and stack.
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_MINUTE)
async scheduledRun(): Promise<void> {
try {
await this.processOutbox();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Calendar sync tick failed: ${message}`, stack);
}
}
async processOutbox(): Promise<void> { async processOutbox(): Promise<void> {
const settings = await this.settingsService.get(); const settings = await this.settingsService.get();
if ( if (
@@ -131,22 +232,80 @@ export class CalendarSyncService {
return; return;
} }
const entries = await this.prisma.calendarSyncOutboxEntry.findMany({ // Several workers may run at once - one cron tick per pod, plus "Jetzt
where: { status: 'PENDING' }, // synchronisieren". Each entry is claimed right before it is written,
// so exactly one of them talks to Graph about it.
const candidates = await this.prisma.calendarSyncOutboxEntry.findMany({
where: { status: 'PENDING', ...claimableNow() },
orderBy: { updatedAt: 'asc' }, orderBy: { updatedAt: 'asc' },
take: BATCH_SIZE, take: BATCH_SIZE,
}); });
for (const entry of entries) { for (const candidate of candidates) {
await this.processEntry(entry, settings.groupId); const claimed = await this.claim(candidate);
if (claimed) {
await this.processEntry(claimed, settings.groupId);
}
} }
} }
// "Jetzt synchronisieren" also gives FAILED entries a fresh set of
// attempts - besides editing the source record the documented way out of
// FAILED. A FAILED row is never claimed (settle() clears the claim with
// it), so this cannot pull a row from under a running worker. The regular
// cron tick leaves FAILED alone.
async runNow(): Promise<void> { async runNow(): Promise<void> {
await this.prisma.calendarSyncOutboxEntry.updateMany({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
await this.processOutbox(); await this.processOutbox();
} }
private async processEntry( // What the API's "Jetzt synchronisieren" calls: starts runNow() in the
// background and returns at once - a pass over up to BATCH_SIZE entries
// with several Graph calls each must not hold the HTTP request. One such
// pass per pod at a time; passes on other pods (and the cron tick) stay
// safe through the per-entry claims. getStatus().running follows it.
startRun(): void {
if (!this.activeRun) {
this.activeRun = this.runNow()
.catch((error: unknown) => {
const { message, stack } = loggableError(error);
this.logger.error(`Calendar sync run failed: ${message}`, stack);
})
.finally(() => {
this.activeRun = null;
});
}
}
// A pass cut off mid-entry would only be retaken after STALE_CLAIM_MS -
// shutdown lets it finish (each Graph request is bounded by its timeout).
async onModuleDestroy(): Promise<void> {
await this.activeRun;
}
// Compare-and-set on the row as it was read: the update only matches while
// the row is still PENDING, unchanged and unclaimed, so of two workers
// reading the same candidate exactly one gets it (the other gets count 0).
private async claim(
entry: CalendarSyncOutboxEntry, entry: CalendarSyncOutboxEntry,
): Promise<ClaimedEntry | null> {
const claimedAt = new Date();
const claim = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: {
id: entry.id,
status: 'PENDING',
revision: entry.revision,
...claimableNow(),
},
data: { claimedAt },
});
return claim.count === 1 ? { ...entry, claimedAt } : null;
}
private async processEntry(
entry: ClaimedEntry,
groupId: string, groupId: string,
): Promise<void> { ): Promise<void> {
try { try {
@@ -155,47 +314,132 @@ export class CalendarSyncService {
entry.sourceId, entry.sourceId,
); );
if (!input) { if (!input) {
await this.prisma.calendarSyncOutboxEntry.update({ await this.settle(entry, { status: 'SKIPPED' });
where: { id: entry.id },
data: { status: 'SKIPPED' },
});
return; return;
} }
const graphEventId = entry.graphEventId const graphEventId = entry.graphEventId
? await this.updateExisting(groupId, entry.graphEventId, input) ? await this.updateExisting(groupId, entry, entry.graphEventId, input)
: await this.graphClient.createEvent(groupId, input); : await this.graphClient.createEvent(
groupId,
input,
eventTransactionId(entry),
);
await this.prisma.calendarSyncOutboxEntry.update({ await this.recordSynced(entry, graphEventId);
where: { id: entry.id },
data: { status: 'SYNCED', graphEventId, lastError: null },
});
} catch (error: unknown) { } catch (error: unknown) {
const attempts = entry.attempts + 1; const attempts = entry.attempts + 1;
await this.prisma.calendarSyncOutboxEntry.update({ await this.settle(entry, {
where: { id: entry.id }, status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING',
data: { attempts,
status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING', // Stored and shown to admins: Prisma errors by name/code/model only.
attempts, lastError: loggableError(error).message,
lastError: getErrorMessage(error),
},
}); });
} }
} }
/** The row exactly as claimed: still ours, PENDING, nothing enqueued since. */
private unchangedSinceClaim(
entry: ClaimedEntry,
): Prisma.CalendarSyncOutboxEntryWhereInput {
return {
id: entry.id,
status: 'PENDING',
revision: entry.revision,
claimedAt: entry.claimedAt,
};
}
// Writes this pass's outcome only if the row is still as claimed. If it
// was enqueued or cancelled meanwhile, the outcome is stale: the claim is
// just released, and the next pass works from the row's current state.
private async settle(
entry: ClaimedEntry,
outcome: Prisma.CalendarSyncOutboxEntryUpdateManyMutationInput,
): Promise<void> {
const settled = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: this.unchangedSinceClaim(entry),
data: { ...outcome, claimedAt: null },
});
if (settled.count === 0) {
await this.release(entry);
}
}
private async release(entry: ClaimedEntry): Promise<number> {
const released = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: { id: entry.id, claimedAt: entry.claimedAt },
data: { claimedAt: null },
});
return released.count;
}
// The event now exists in M365. Unless the row changed meanwhile it is
// SYNCED. Otherwise the event id must still be kept - a lost id means a
// duplicate event on the next pass.
private async recordSynced(
entry: ClaimedEntry,
graphEventId: string,
): Promise<void> {
const synced = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: this.unchangedSinceClaim(entry),
data: {
status: 'SYNCED',
graphEventId,
lastError: null,
claimedAt: null,
},
});
if (synced.count === 1) {
return;
}
// Re-enqueued during the sync (the source was edited): stays PENDING,
// and the next pass updates this event with the newer state.
const kept = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: { id: entry.id, status: 'PENDING', claimedAt: entry.claimedAt },
data: { graphEventId, claimedAt: null },
});
if (kept.count === 1) {
return;
}
// Cancelled during the sync (the source left the calendar): the event
// just written must not stay behind.
if ((await this.release(entry)) === 1) {
await this.deleteGraphEvent(graphEventId);
return;
}
this.logger.warn(
`Calendar sync claim on ${entry.sourceType} ${entry.sourceId} expired before Graph event ${graphEventId} was recorded`,
);
}
// PATCH on an existing event id that was deleted on the M365 side (e.g. an // PATCH on an existing event id that was deleted on the M365 side (e.g. an
// admin removed it manually) would 404 forever - fall back to creating a // admin removed it manually) would 404 forever - fall back to creating a
// fresh event instead of leaving the row permanently FAILED. // fresh event instead of leaving the row permanently FAILED. Only then:
// after any other failure (5xx, throttling, timeout) the event may well
// still exist, and a second one would be a duplicate - that attempt fails
// and is retried as an update.
private async updateExisting( private async updateExisting(
groupId: string, groupId: string,
entry: ClaimedEntry,
graphEventId: string, graphEventId: string,
input: GraphCalendarEventInput, input: GraphCalendarEventInput,
): Promise<string> { ): Promise<string> {
try { try {
await this.graphClient.updateEvent(groupId, graphEventId, input); await this.graphClient.updateEvent(groupId, graphEventId, input);
return graphEventId; return graphEventId;
} catch { } catch (error: unknown) {
return this.graphClient.createEvent(groupId, input); const eventGone =
error instanceof GraphRequestError &&
EVENT_GONE_STATUSES.includes(error.status);
if (!eventGone) {
throw error;
}
return this.graphClient.createEvent(
groupId,
input,
eventTransactionId(entry),
);
} }
} }
@@ -279,17 +523,22 @@ export class CalendarSyncService {
async getStatus(): Promise<CalendarSyncStatusReport> { async getStatus(): Promise<CalendarSyncStatusReport> {
const settings = await this.settingsService.get(); const settings = await this.settingsService.get();
const [pendingCount, failedCount, syncedCount] = await Promise.all([ const [pendingCount, failedCount, syncedCount, heldCount] =
this.prisma.calendarSyncOutboxEntry.count({ await Promise.all([
where: { status: 'PENDING' }, this.prisma.calendarSyncOutboxEntry.count({
}), where: { status: 'PENDING' },
this.prisma.calendarSyncOutboxEntry.count({ }),
where: { status: 'FAILED' }, this.prisma.calendarSyncOutboxEntry.count({
}), where: { status: 'FAILED' },
this.prisma.calendarSyncOutboxEntry.count({ }),
where: { status: 'SYNCED' }, this.prisma.calendarSyncOutboxEntry.count({
}), where: { status: 'SYNCED' },
]); }),
// Live claims: some worker is writing an entry right now.
this.prisma.calendarSyncOutboxEntry.count({
where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } },
}),
]);
return { return {
configured: this.graphClient.isConfigured(), configured: this.graphClient.isConfigured(),
enabled: settings.enabled, enabled: settings.enabled,
@@ -297,6 +546,7 @@ export class CalendarSyncService {
pendingCount, pendingCount,
failedCount, failedCount,
syncedCount, syncedCount,
running: this.activeRun !== null || heldCount > 0,
}; };
} }
} }
@@ -0,0 +1,33 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateCalendarSyncSettingsDto } from './update-calendar-sync-settings.dto';
async function errorsFor(groupId: unknown): Promise<string[]> {
const dto = plainToInstance(UpdateCalendarSyncSettingsDto, { groupId });
const errors = await validate(dto);
return errors.flatMap((error) => Object.values(error.constraints ?? {}));
}
// The group id goes into the Graph URL path - only an Entra object id
// (a GUID) is accepted, or an empty string to clear the value.
describe('UpdateCalendarSyncSettingsDto groupId', () => {
it.each([
'3fa85f64-5717-4562-b3fc-2c963f66afa6',
'3FA85F64-5717-4562-B3FC-2C963F66AFA6',
'',
])('accepts %p', async (groupId) => {
expect(await errorsFor(groupId)).toEqual([]);
});
it.each([
'meine-gruppe',
'3fa85f64-5717-4562-b3fc-2c963f66afa6/events',
'../users/[email protected]',
'3fa85f64571745 62b3fc2c963f66afa6',
' 3fa85f64-5717-4562-b3fc-2c963f66afa6',
])('rejects %p with a German message', async (groupId) => {
expect(await errorsFor(groupId)).toEqual([
'Gruppen-ID muss die Objekt-ID der Microsoft-365-Gruppe sein (Format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)',
]);
});
});
@@ -1,48 +1,69 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; import {
IsBoolean,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
const GROUP_ID_PATTERN =
/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$/i;
// PATCH null rule: an omitted field stays unchanged. The switches are NOT
// NULL columns, so null on them is a 400. groupId is nullable: null clears
// it, and so does "" - the GUI's way of emptying the field.
export class UpdateCalendarSyncSettingsDto { export class UpdateCalendarSyncSettingsDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
type: String,
nullable: true,
description: description:
'Objekt-ID der Microsoft-365-Gruppe. Leerer String löscht den GUI-Wert.', 'Objekt-ID der Microsoft-365-Gruppe. Leerer String oder null löscht den GUI-Wert.',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(100) @MaxLength(100)
groupId?: string; // An Entra object id (GUID) or "" - the value goes into the Graph URL
// path, so nothing else may get through.
@Matches(GROUP_ID_PATTERN, {
message:
'Gruppen-ID muss die Objekt-ID der Microsoft-365-Gruppe sein (Format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)',
})
groupId?: string | null;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
syncTask?: boolean; syncTask?: boolean;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
syncInspection?: boolean; syncInspection?: boolean;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
syncTreatment?: boolean; syncTreatment?: boolean;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
syncFeeding?: boolean; syncFeeding?: boolean;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
syncHarvest?: boolean; syncHarvest?: boolean;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
syncQueenAssignment?: boolean; syncQueenAssignment?: boolean;
} }
@@ -1,4 +1,4 @@
import { GraphClientService } from './graph-client.service'; import { GraphClientService, GraphRequestError } from './graph-client.service';
function jsonResponse(status: number, body: unknown): Response { function jsonResponse(status: number, body: unknown): Response {
return { return {
@@ -53,10 +53,11 @@ describe('GraphClientService', () => {
) )
.mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' })); .mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' }));
const result = await service.createEvent('group-1', { const result = await service.createEvent(
title: 'Durchsicht V-001', 'group-1',
date: new Date(Date.UTC(2026, 2, 7)), { title: 'Durchsicht V-001', date: new Date(Date.UTC(2026, 2, 7)) },
}); 'tx-durchsicht',
);
expect(result).toBe('graph-event-1'); expect(result).toBe('graph-event-1');
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
@@ -82,8 +83,11 @@ describe('GraphClientService', () => {
isAllDay: boolean; isAllDay: boolean;
start: { dateTime: string }; start: { dateTime: string };
end: { dateTime: string }; end: { dateTime: string };
transactionId: string;
}; };
expect(body.subject).toBe('Durchsicht V-001'); expect(body.subject).toBe('Durchsicht V-001');
// Lets Graph drop a repeated create after a timed-out first attempt.
expect(body.transactionId).toBe('tx-durchsicht');
expect(body.isAllDay).toBe(true); expect(body.isAllDay).toBe(true);
expect(body.start.dateTime).toBe('2026-03-07T00:00:00.0000000'); expect(body.start.dateTime).toBe('2026-03-07T00:00:00.0000000');
expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000'); expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000');
@@ -97,14 +101,16 @@ describe('GraphClientService', () => {
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-a' })) .mockResolvedValueOnce(jsonResponse(201, { id: 'event-a' }))
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-b' })); .mockResolvedValueOnce(jsonResponse(201, { id: 'event-b' }));
await service.createEvent('group-1', { await service.createEvent(
title: 'A', 'group-1',
date: new Date(Date.UTC(2026, 2, 7)), { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
}); 'tx-a',
await service.createEvent('group-1', { );
title: 'B', await service.createEvent(
date: new Date(Date.UTC(2026, 2, 8)), 'group-1',
}); { title: 'B', date: new Date(Date.UTC(2026, 2, 8)) },
'tx-b',
);
expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledTimes(3);
}); });
@@ -117,14 +123,99 @@ describe('GraphClientService', () => {
.mockResolvedValueOnce(jsonResponse(403, { error: 'Forbidden' })); .mockResolvedValueOnce(jsonResponse(403, { error: 'Forbidden' }));
await expect( await expect(
service.createEvent('group-1', { service.createEvent(
title: 'A', 'group-1',
date: new Date(Date.UTC(2026, 2, 7)), { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) },
}), 'tx-a',
),
).rejects.toThrow(/createEvent fehlgeschlagen \(403\)/); ).rejects.toThrow(/createEvent fehlgeschlagen \(403\)/);
}); });
}); });
describe('updateEvent', () => {
it('never sends a transactionId, which Graph does not let an update change', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(200, {}));
await service.updateEvent('group-1', 'event-1', {
title: 'A',
date: new Date(Date.UTC(2026, 2, 7)),
});
const [, init] = fetchMock.mock.calls[1] as [string, { body: string }];
expect(JSON.parse(init.body)).not.toHaveProperty('transactionId');
});
it('rejects with a GraphRequestError carrying the HTTP status', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(404, { error: 'NotFound' }));
const update = service.updateEvent('group-1', 'event-1', {
title: 'A',
date: new Date(Date.UTC(2026, 2, 7)),
});
await expect(update).rejects.toBeInstanceOf(GraphRequestError);
await expect(update).rejects.toMatchObject({ status: 404 });
});
});
// The outbox claim expires after a fixed time; a Graph call hanging longer
// would let a second pod take over the same row while this one still works.
it('bounds every request to Microsoft with a timeout', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
.mockResolvedValueOnce(jsonResponse(200, {}))
.mockResolvedValueOnce(jsonResponse(204, {}));
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
await service.createEvent('group-1', input, 'tx-1');
await service.updateEvent('group-1', 'event-1', input);
await service.deleteEvent('group-1', 'event-1');
expect(fetchMock).toHaveBeenCalledTimes(4);
for (const [, init] of fetchMock.mock.calls as [string, RequestInit][]) {
expect(init.signal).toBeInstanceOf(AbortSignal);
}
});
// Ids end up in the URL path: a value with "/", "?" or "#" must not be
// able to point the request at another Graph resource.
it('encodes group and event ids in every request path', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse(201, { id: 'event-1' }))
.mockResolvedValueOnce(jsonResponse(200, {}))
.mockResolvedValueOnce(jsonResponse(204, {}));
const input = { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) };
const groupId = '../users/x?$top=1#';
const eventId = 'AAMk/abc+def=';
await service.createEvent(groupId, input, 'tx-1');
await service.updateEvent(groupId, eventId, input);
await service.deleteEvent(groupId, eventId);
const urls = (fetchMock.mock.calls as [string][]).map(([url]) => url);
const group = '..%2Fusers%2Fx%3F%24top%3D1%23';
const event = 'AAMk%2Fabc%2Bdef%3D';
expect(urls.slice(1)).toEqual([
`https://graph.microsoft.com/v1.0/groups/${group}/events`,
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
`https://graph.microsoft.com/v1.0/groups/${group}/events/${event}`,
]);
});
describe('deleteEvent', () => { describe('deleteEvent', () => {
it('treats a 404 as success (idempotent delete)', async () => { it('treats a 404 as success (idempotent delete)', async () => {
fetchMock fetchMock
@@ -11,6 +11,16 @@ interface CachedToken {
} }
const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
// Both ids are encoded: a group id from the settings or an event id from
// Graph must never be able to point the request at another resource
// ("/", "?", "#" or "../" in the value).
function eventsUrl(groupId: string, eventId?: string): string {
const events = `${GRAPH_BASE_URL}/groups/${encodeURIComponent(groupId)}/events`;
return eventId === undefined
? events
: `${events}/${encodeURIComponent(eventId)}`;
}
// All-day events render on the correct date regardless of viewer timezone, // All-day events render on the correct date regardless of viewer timezone,
// but Graph still requires a timeZone on the start/end payload - fixed to // but Graph still requires a timeZone on the start/end payload - fixed to
// the org's own zone since this app has no per-user timezone setting. // the org's own zone since this app has no per-user timezone setting.
@@ -18,6 +28,27 @@ const EVENT_TIME_ZONE = 'Europe/Berlin';
// Refresh a little before actual expiry so a request never races an // Refresh a little before actual expiry so a request never races an
// about-to-expire cached token. // about-to-expire cached token.
const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
// Every request to Microsoft is bounded. One outbox entry takes at most a
// token request plus two event requests, so it always finishes well within
// the outbox claim (CalendarSyncService) - a hanging call must not outlive
// the claim and let a second pod write the same entry.
export const GRAPH_REQUEST_TIMEOUT_MS = 30 * 1000;
/** A non-2xx answer from Graph; callers decide by its HTTP status. */
export class GraphRequestError extends Error {
constructor(
operation: string,
readonly status: number,
body: string,
) {
super(`Graph ${operation} fehlgeschlagen (${status}): ${body}`);
this.name = 'GraphRequestError';
}
}
function timeoutSignal(): AbortSignal {
return AbortSignal.timeout(GRAPH_REQUEST_TIMEOUT_MS);
}
function tokenEndpoint(tenantId: string): string { function tokenEndpoint(tenantId: string): string {
return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
@@ -84,6 +115,7 @@ export class GraphClientService {
client_secret: clientSecret, client_secret: clientSecret,
scope: 'https://graph.microsoft.com/.default', scope: 'https://graph.microsoft.com/.default',
}), }),
signal: timeoutSignal(),
}); });
} catch (error: unknown) { } catch (error: unknown) {
throw new Error( throw new Error(
@@ -128,22 +160,30 @@ export class GraphClientService {
}; };
} }
// transactionId is Graph's guard against a repeated create: a retry after
// a timed-out request carries the same value, so Graph can drop it instead
// of adding a second event. Graph does not let an update change it, so
// updateEvent() never sends one.
async createEvent( async createEvent(
groupId: string, groupId: string,
input: GraphCalendarEventInput, input: GraphCalendarEventInput,
transactionId: string,
): Promise<string> { ): Promise<string> {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const response = await fetch(`${GRAPH_BASE_URL}/groups/${groupId}/events`, { const response = await fetch(eventsUrl(groupId), {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(this.allDayEventBody(input)), body: JSON.stringify({ ...this.allDayEventBody(input), transactionId }),
signal: timeoutSignal(),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error( throw new GraphRequestError(
`Graph createEvent fehlgeschlagen (${response.status}): ${await response.text()}`, 'createEvent',
response.status,
await response.text(),
); );
} }
const data = (await response.json()) as { id: string }; const data = (await response.json()) as { id: string };
@@ -156,38 +196,38 @@ export class GraphClientService {
input: GraphCalendarEventInput, input: GraphCalendarEventInput,
): Promise<void> { ): Promise<void> {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const response = await fetch( const response = await fetch(eventsUrl(groupId, eventId), {
`${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`, method: 'PATCH',
{ headers: {
method: 'PATCH', Authorization: `Bearer ${token}`,
headers: { 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(this.allDayEventBody(input)),
}, },
); body: JSON.stringify(this.allDayEventBody(input)),
signal: timeoutSignal(),
});
if (!response.ok) { if (!response.ok) {
throw new Error( throw new GraphRequestError(
`Graph updateEvent fehlgeschlagen (${response.status}): ${await response.text()}`, 'updateEvent',
response.status,
await response.text(),
); );
} }
} }
async deleteEvent(groupId: string, eventId: string): Promise<void> { async deleteEvent(groupId: string, eventId: string): Promise<void> {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const response = await fetch( const response = await fetch(eventsUrl(groupId, eventId), {
`${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`, method: 'DELETE',
{ headers: { Authorization: `Bearer ${token}` },
method: 'DELETE', signal: timeoutSignal(),
headers: { Authorization: `Bearer ${token}` }, });
},
);
// A 404 means the event is already gone - treat as success so a retry // A 404 means the event is already gone - treat as success so a retry
// after a partially-applied delete doesn't throw. // after a partially-applied delete doesn't throw.
if (!response.ok && response.status !== 404) { if (!response.ok && response.status !== 404) {
throw new Error( throw new GraphRequestError(
`Graph deleteEvent fehlgeschlagen (${response.status}): ${await response.text()}`, 'deleteEvent',
response.status,
await response.text(),
); );
} }
} }
@@ -313,6 +313,17 @@ describe('ColoniesService', () => {
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
}); });
it.each(['WEAK', 'CONSPICUOUS', 'QUEENLESS'])(
'reactivate rejects a %s colony that was never ended',
async (status) => {
prisma.colony.findUnique.mockResolvedValue({ ...parentColony, status });
await expect(
service.reactivate('colony-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.colony.update).not.toHaveBeenCalled();
},
);
it('reactivate sets status ACTIVE and clears deactivatedAt', async () => { it('reactivate sets status ACTIVE and clears deactivatedAt', async () => {
prisma.colony.findUnique.mockResolvedValue({ prisma.colony.findUnique.mockResolvedValue({
...parentColony, ...parentColony,
@@ -317,7 +317,9 @@ export class ColoniesService {
async reactivate(id: string, actingUserId: string): Promise<Colony> { async reactivate(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id); const before = await this.findById(id);
if (before.status === 'ACTIVE') { // 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'); throw new ConflictException('Colony is already active');
} }
const colony = await this.prisma.colony.update({ const colony = await this.prisma.colony.update({
@@ -1,4 +1,4 @@
import { ColonyStatus } from '../../generated/prisma'; import { ColonyStatus, Prisma } from '../../generated/prisma';
/** /**
* Statuses after which a colony no longer exists as a living unit. The other * Statuses after which a colony no longer exists as a living unit. The other
@@ -16,3 +16,8 @@ export const TERMINAL_COLONY_STATUSES: readonly ColonyStatus[] = [
export function isLivingColonyStatus(status: ColonyStatus): boolean { export function isLivingColonyStatus(status: ColonyStatus): boolean {
return !TERMINAL_COLONY_STATUSES.includes(status); return !TERMINAL_COLONY_STATUSES.includes(status);
} }
/** The same rule as a query filter: colonies that still exist. */
export const LIVING_COLONY_WHERE = {
status: { notIn: [...TERMINAL_COLONY_STATUSES] },
} satisfies Prisma.ColonyWhereInput;
@@ -1,7 +1,8 @@
import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger'; import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator'; import { IsEnum } from 'class-validator';
import { ColonyStatus } from '../../../generated/prisma'; import { ColonyStatus } from '../../../generated/prisma';
import { CreateColonyDto } from './create-colony.dto'; import { CreateColonyDto } from './create-colony.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
/** /**
* `apiaryId` is editable here too, not only through POST /colonies/:id/move: * `apiaryId` is editable here too, not only through POST /colonies/:id/move:
@@ -22,11 +23,12 @@ class UpdatableColonyFieldsDto extends PartialType(
'healthStatus', 'healthStatus',
'notes', 'notes',
] as const), ] as const),
{ skipNullProperties: false },
) {} ) {}
export class UpdateColonyDto extends UpdatableColonyFieldsDto { export class UpdateColonyDto extends UpdatableColonyFieldsDto {
@ApiPropertyOptional({ enum: ColonyStatus }) @ApiPropertyOptional({ enum: ColonyStatus })
@IsOptional() @IsOptionalNotNull()
@IsEnum(ColonyStatus) @IsEnum(ColonyStatus)
status?: ColonyStatus; status?: ColonyStatus;
} }
@@ -0,0 +1,85 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { runAfterCommit } from './after-commit';
describe('runAfterCommit', () => {
let logger: Logger;
let errorSpy: jest.SpyInstance;
beforeEach(() => {
logger = new Logger('AfterCommitSpec');
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
});
const subject = {
action: 'Automation rule evaluation',
entityType: 'Inspection',
entityId: 'inspection-1',
};
it('runs the side effect and logs nothing when it succeeds', async () => {
const work = jest.fn().mockResolvedValue(undefined);
await runAfterCommit(logger, subject, work);
expect(work).toHaveBeenCalledTimes(1);
expect(errorSpy).not.toHaveBeenCalled();
});
it('never rejects when the side effect fails', async () => {
const work = jest.fn().mockRejectedValue(new Error('database gone'));
await expect(
runAfterCommit(logger, subject, work),
).resolves.toBeUndefined();
});
it('logs the failure at error level with entity type, id, error and stack', async () => {
const failure = new Error('database gone');
await runAfterCommit(logger, subject, () => Promise.reject(failure));
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message, stack] = errorSpy.mock.calls[0] as [string, string];
expect(message).toContain('Automation rule evaluation');
expect(message).toContain('Inspection inspection-1');
expect(message).toContain('database gone');
expect(stack).toBe(failure.stack);
});
it('also contains a synchronous throw', async () => {
await runAfterCommit(logger, subject, () => {
throw new Error('thrown synchronously');
});
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain('thrown synchronously');
});
it('never logs the message or stack of a Prisma error (they quote query values)', async () => {
const failure = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on title "Max Mustermann"',
{ code: 'P2002', clientVersion: '7.9.1', meta: { modelName: 'Task' } },
);
await runAfterCommit(logger, subject, () => Promise.reject(failure));
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('Inspection inspection-1');
expect(message).toContain('P2002');
expect(message).toContain('Task');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
});
it('logs a rejection that is not an Error object', async () => {
const work = jest.fn().mockRejectedValue('plain string');
await runAfterCommit(logger, subject, work);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('plain string');
expect(stack).toBeUndefined();
});
});
@@ -0,0 +1,37 @@
import { Logger } from '@nestjs/common';
import { loggableError } from './loggable-error';
/** What a post-commit side effect was about - named in the log line. */
export interface AfterCommitSubject {
action: string;
entityType: string;
entityId: string;
}
/**
* Runs a side effect of a write that is already committed: automation
* rules, calendar sync, webhooks. It must never fail the request - the
* client would get an error for a record that exists, the Idempotency-Key
* would be released, and the offline queue would send the request again and
* store the record twice (for a treatment: a second Bestandsbuch entry).
*
* A failure is logged at error level with the record it concerned, and the
* caller carries on. Only for steps after the commit: anything that has to
* stop the write (number generation, validation) keeps throwing. What of
* the error is logged follows loggableError() - no query values.
*/
export async function runAfterCommit(
logger: Logger,
subject: AfterCommitSubject,
work: () => Promise<void> | void,
): Promise<void> {
try {
await work();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
logger.error(
`${subject.action} failed for ${subject.entityType} ${subject.entityId}: ${message}`,
stack,
);
}
}
@@ -176,4 +176,127 @@ describe('AllExceptionsFilter', () => {
message: 'Internal server error', message: 'Internal server error',
}); });
}); });
// Prisma messages quote the query (titles, names, notes) - the log gets
// name, code and model only, the client the same bodies as before.
describe('Prisma errors in the log', () => {
it('logs an unmapped Prisma error without its message or stack', () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientValidationError(
'Invalid `prisma.task.create()` invocation: { data: { title: "Max Mustermann" } }',
{ clientVersion: '7.9.1' },
);
filter.catch(exception, host);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('PrismaClientValidationError');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 500,
message: 'Internal server error',
});
});
it('logs a translated Prisma error at warn level by code and model only', () => {
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on the fields: (`code`) for "Mustermann"',
{
code: 'P2002',
clientVersion: '7.9.1',
meta: { modelName: 'Colony', target: ['code'] },
},
);
filter.catch(exception, host);
const [message] = warnSpy.mock.calls[0] as [string];
expect(message).toContain('P2002');
expect(message).toContain('Colony');
expect(message).not.toContain('Mustermann');
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 409,
message:
'Code wird bereits verwendet - bitte einen anderen Wert wählen',
});
});
it('still logs message and stack of other server errors', () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const exception = new Error('Graph timeout');
filter.catch(exception, host);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('Graph timeout');
expect(stack).toBe(exception.stack);
});
});
// body-parser rejects a request before Nest sees it. Its message (and the
// `body` property) can quote the request body - names, notes, passwords -
// so the log gets status and type only, the client a fixed German text.
describe('body-parser errors', () => {
/** Shaped like body-parser's http-errors. */
const bodyParserError = (
status: number,
type: string,
message: string,
): Error =>
Object.assign(new Error(message), {
status,
statusCode: status,
type,
expose: true,
body: '{"name":"Max Mustermann","password":"geheim"',
});
it.each([
[
400,
'entity.parse.failed',
'Unexpected token, "{"name":"Max Mustermann"..." is not valid JSON',
'Ungültige Anfrage',
],
[
413,
'entity.too.large',
'request entity too large',
'Anfrage ist zu groß',
],
[
415,
'charset.unsupported',
'unsupported charset "MUSTERMANN-8"',
'Format der Anfrage wird nicht unterstützt',
],
])(
'logs a %i (%s) by status and type only and answers with a fixed text',
(status, type, message, clientMessage) => {
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
filter.catch(bodyParserError(status, type, message), host);
const [logged] = warnSpy.mock.calls[0] as [string];
expect(logged).toContain(String(status));
expect(logged).toContain(type);
expect(logged).not.toMatch(/Mustermann|MUSTERMANN|geheim/);
expect(statusMock).toHaveBeenCalledWith(status);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: status,
message: clientMessage,
});
},
);
});
}); });
@@ -8,6 +8,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { Prisma } from '../../../generated/prisma'; import { Prisma } from '../../../generated/prisma';
import { loggableError } from '../loggable-error';
// Unique-constraint fields a beekeeper might plausibly retype by mistake // Unique-constraint fields a beekeeper might plausibly retype by mistake
// (a hive code, a batch number) - translated so the P2002 branch below can // (a hive code, a batch number) - translated so the P2002 branch below can
@@ -73,6 +74,31 @@ function middlewareErrorResponse(
return { status, message: messages[status] ?? 'Ungültige Anfrage' }; return { status, message: messages[status] ?? 'Ungültige Anfrage' };
} }
/**
* body-parser's errors (http-errors): a numeric status plus a `type` such as
* "entity.parse.failed", "entity.too.large" or "charset.unsupported". Their
* message - and the `body` property - can quote the request body (names,
* notes, passwords), so only status and type may be logged.
*/
function bodyParserErrorType(exception: unknown): string | null {
if (
exception instanceof HttpException ||
typeof exception !== 'object' ||
exception === null
) {
return null;
}
const candidate = exception as {
type?: unknown;
status?: unknown;
statusCode?: unknown;
};
const status = candidate.status ?? candidate.statusCode;
return typeof candidate.type === 'string' && typeof status === 'number'
? candidate.type
: null;
}
function prismaErrorResponse( function prismaErrorResponse(
exception: unknown, exception: unknown,
): { status: number; message: string } | null { ): { status: number; message: string } | null {
@@ -109,7 +135,8 @@ function prismaErrorResponse(
* Client-facing behaviour is unchanged: HttpExceptions still return * Client-facing behaviour is unchanged: HttpExceptions still return
* exactly the body/status Nest would have produced. Non-HttpException * exactly the body/status Nest would have produced. Non-HttpException
* errors still collapse to a generic 500 body - the real message and * errors still collapse to a generic 500 body - the real message and
* stack go to the log, never to the client. The exceptions are the Prisma * stack go to the log, never to the client (for Prisma errors only name,
* code and model: their messages quote query values). The exceptions are the Prisma
* errors listed in prismaErrorResponse: services write records straight * errors listed in prismaErrorResponse: services write records straight
* against DB constraints (`@unique` columns like colony/apiary/queen codes, * against DB constraints (`@unique` columns like colony/apiary/queen codes,
* SKUs and batch numbers; foreign keys for every parent relation) with no * SKUs and batch numbers; foreign keys for every parent relation) with no
@@ -140,8 +167,18 @@ export class AllExceptionsFilter implements ExceptionFilter {
statusCode: status, statusCode: status,
message: prismaResponse?.message ?? 'Internal server error', message: prismaResponse?.message ?? 'Internal server error',
}; };
const message = // A Prisma error is logged by name, code and model only - its message
exception instanceof Error ? exception.message : 'Unknown error'; // and stack quote the query (see loggableError).
const parserType = bodyParserErrorType(exception);
const { message, stack } =
parserType !== null
? {
message: `rejected by body parser (${parserType})`,
stack: undefined,
}
: exception instanceof Error
? loggableError(exception)
: { message: 'Unknown error', stack: undefined };
// Path only - query strings can carry codes and personal data. // Path only - query strings can carry codes and personal data.
const context = `${request.method} ${request.baseUrl}${request.path} -> ${status}`; const context = `${request.method} ${request.baseUrl}${request.path} -> ${status}`;
// Compared against the literal 500, not HttpStatus.INTERNAL_SERVER_ERROR: // Compared against the literal 500, not HttpStatus.INTERNAL_SERVER_ERROR:
@@ -150,10 +187,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
const isServerError = status >= 500; const isServerError = status >= 500;
if (isServerError) { if (isServerError) {
this.logger.error( this.logger.error(`${context}: ${message}`, stack);
`${context}: ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
} else { } else {
this.logger.warn(`${context}: ${message}`); this.logger.warn(`${context}: ${message}`);
} }
@@ -0,0 +1,62 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
import { IdempotencyCleanupService } from './idempotency-cleanup.service';
describe('IdempotencyCleanupService', () => {
let service: IdempotencyCleanupService;
let prisma: {
idempotencyRecord: { deleteMany: jest.Mock };
consumedToken: { deleteMany: jest.Mock };
};
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();
});
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 } },
});
});
// 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' },
}),
);
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();
});
});
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { loggableError } from '../loggable-error';
// Longer than any realistic offline stretch at the apiary: an entry queued // Longer than any realistic offline stretch at the apiary: an entry queued
// on the phone must still find its key when it finally syncs, otherwise the // on the phone must still find its key when it finally syncs, otherwise the
@@ -13,7 +14,19 @@ export class IdempotencyCleanupService {
constructor(private readonly prisma: PrismaService) {} 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) @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> { async removeExpired(now: Date = new Date()): Promise<number> {
const cutoff = new Date( const cutoff = new Date(
now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000, now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
@@ -0,0 +1,76 @@
import { Prisma } from '../../generated/prisma';
import { loggableError } from './loggable-error';
// Prisma messages quote the query - titles, descriptions, names. None of
// that may reach the log; what identifies the failure is name, code, model.
describe('loggableError', () => {
const clientVersion = '7.9.1';
it('keeps message and stack of an ordinary error', () => {
const error = new Error('Graph createEvent fehlgeschlagen (503)');
expect(loggableError(error)).toEqual({
message: 'Graph createEvent fehlgeschlagen (503)',
stack: error.stack,
});
});
it('turns a thrown non-Error value into text without a stack', () => {
expect(loggableError('plain string')).toEqual({
message: 'plain string',
stack: undefined,
});
});
it('logs a known Prisma error by name, code and model only', () => {
const error = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on title "Max Mustermann"',
{
code: 'P2002',
clientVersion,
meta: { modelName: 'Task', target: ['title'] },
},
);
const logged = loggableError(error);
expect(logged).toEqual({
message: 'PrismaClientKnownRequestError P2002 (Task)',
stack: undefined,
});
});
it('leaves out the model when Prisma does not name one', () => {
const error = new Prisma.PrismaClientKnownRequestError('Mustermann', {
code: 'P2025',
clientVersion,
});
expect(loggableError(error).message).toBe(
'PrismaClientKnownRequestError P2025',
);
});
it('never logs the query of a validation error', () => {
const error = new Prisma.PrismaClientValidationError(
'Invalid `prisma.task.create()` invocation: { data: { title: "Max Mustermann" } }',
{ clientVersion },
);
expect(loggableError(error)).toEqual({
message: 'PrismaClientValidationError',
stack: undefined,
});
});
it.each([
new Prisma.PrismaClientUnknownRequestError('Mustermann', { clientVersion }),
new Prisma.PrismaClientRustPanicError('Mustermann', clientVersion),
new Prisma.PrismaClientInitializationError('Mustermann', clientVersion),
])('logs %s by name only', (error) => {
const logged = loggableError(error);
expect(logged.message).toBe(error.name);
expect(logged.stack).toBeUndefined();
});
});
@@ -0,0 +1,46 @@
import { Prisma } from '../../generated/prisma';
/** What of an error may go into the log. */
export interface LoggableError {
message: string;
stack: string | undefined;
}
const PRISMA_ERRORS = [
Prisma.PrismaClientKnownRequestError,
Prisma.PrismaClientUnknownRequestError,
Prisma.PrismaClientRustPanicError,
Prisma.PrismaClientInitializationError,
Prisma.PrismaClientValidationError,
];
function isPrismaError(error: unknown): error is Error {
return PRISMA_ERRORS.some((type) => error instanceof type);
}
function modelName(meta: Record<string, unknown> | undefined): string | null {
return typeof meta?.modelName === 'string' ? meta.modelName : null;
}
/**
* Prisma error messages quote the failing query - titles, descriptions,
* names, i.e. personal data - and so does the stack, whose first line is
* the message. A Prisma error is therefore logged by name, error code and
* model only. Any other error keeps message and stack for diagnosis.
*/
export function loggableError(error: unknown): LoggableError {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
const model = modelName(error.meta);
return {
message: `${error.name} ${error.code}${model ? ` (${model})` : ''}`,
stack: undefined,
};
}
if (isPrismaError(error)) {
return { message: error.name, stack: undefined };
}
if (error instanceof Error) {
return { message: error.message, stack: error.stack };
}
return { message: String(error), stack: undefined };
}
@@ -0,0 +1,15 @@
import { optionalDate } from './patch-values';
describe('optionalDate', () => {
it('leaves an omitted date untouched', () => {
expect(optionalDate(undefined)).toBeUndefined();
});
it('clears the date on null', () => {
expect(optionalDate(null)).toBeNull();
});
it('parses a given date', () => {
expect(optionalDate('2026-07-15')).toEqual(new Date('2026-07-15'));
});
});
@@ -0,0 +1,11 @@
// PATCH semantics for a nullable date column: an omitted field (undefined)
// stays as it is, null clears it. Null for a column that cannot hold it never
// gets this far - the update DTOs reject it (see IsOptionalNotNull).
export function optionalDate(
value: string | null | undefined,
): Date | null | undefined {
if (value === undefined) {
return undefined;
}
return value === null ? null : new Date(value);
}
@@ -0,0 +1,13 @@
import { ValidateIf } from 'class-validator';
/**
* Like @IsOptional, but only an omitted field skips validation: null still
* runs the field's validators and is rejected. For fields whose column cannot
* hold null (required, or filled with a default when omitted) - null is a 400
* here instead of being silently ignored or failing the write with a 500.
* Update DTOs built with PartialType(..., { skipNullProperties: false }) get
* the same treatment for every field the create DTO requires.
*/
export function IsOptionalNotNull(): PropertyDecorator {
return ValidateIf((_object, value) => value !== undefined);
}
@@ -0,0 +1,369 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateApiaryDto } from '../../apiaries/dto/update-apiary.dto';
import { UpdateAutomationRuleDto } from '../../automation/dto/update-automation-rule.dto';
import { UpdateBackupSettingsDto } from '../../backup/dto/update-backup-settings.dto';
import { UpdateCalendarSyncSettingsDto } from '../../calendar-sync/dto/update-calendar-sync-settings.dto';
import { UpdateBottlingBatchDto } from '../../bottling/dto/update-bottling-batch.dto';
import { UpdateColonyDto } from '../../colonies/dto/update-colony.dto';
import { UpdateDocumentDto } from '../../documents/dto/update-document.dto';
import { UpdateEquipmentDto } from '../../equipment/dto/update-equipment.dto';
import { UpdateExtractionDto } from '../../extractions/dto/update-extraction.dto';
import { UpdateFeedingDto } from '../../feedings/dto/update-feeding.dto';
import { UpdateHarvestDto } from '../../harvests/dto/update-harvest.dto';
import { UpdateHoneyBatchDto } from '../../honey-batches/dto/update-honey-batch.dto';
import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto';
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
import { UpdateProductDto } from '../../products/dto/update-product.dto';
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
import { UpdateQueenEvaluationDto } from '../../queens/dto/update-queen-evaluation.dto';
import { UpdateQueenDto } from '../../queens/dto/update-queen.dto';
import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto';
import { UpdateRoleDto } from '../../roles/dto/update-role.dto';
import { UpdateStockItemDto } from '../../storage/dto/update-stock-item.dto';
import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto';
import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto';
import { UpdateTaskTemplateDto } from '../../task-templates/dto/update-task-template.dto';
import { UpdateTaskDto } from '../../tasks/dto/update-task.dto';
import { UpdateTreatmentDto } from '../../treatments/dto/update-treatment.dto';
import { UpdateUserDto } from '../../users/dto/update-user.dto';
import { UpdateVarroaMeasurementDto } from '../../varroa/dto/update-varroa-measurement.dto';
import { UpdateWebhookDto } from '../../webhooks/dto/update-webhook.dto';
import { UpdateWinterCheckDto } from '../../winter-checks/dto/update-winter-check.dto';
// PATCH contract of every update DTO: an omitted field stays unchanged, null
// clears a field whose column is nullable, and null on a column that cannot
// hold it is a 400 - never silently ignored, never a 500 from the write.
interface NullContract {
dto: new () => object;
notNull: string[];
clearable: string[];
}
const contracts: Record<string, NullContract> = {
UpdateApiaryDto: {
dto: UpdateApiaryDto,
notNull: ['name'],
clearable: [
'description',
'location',
'latitude',
'longitude',
'address',
'notes',
],
},
UpdateAutomationRuleDto: {
dto: UpdateAutomationRuleDto,
notNull: ['active'],
clearable: ['config', 'actionTemplate'],
},
UpdateBackupSettingsDto: {
dto: UpdateBackupSettingsDto,
notNull: [],
clearable: ['storagePath', 'cronSchedule', 'retentionCount'],
},
// groupId: null clears it, the same as the empty string.
UpdateCalendarSyncSettingsDto: {
dto: UpdateCalendarSyncSettingsDto,
notNull: [
'enabled',
'syncTask',
'syncInspection',
'syncTreatment',
'syncFeeding',
'syncHarvest',
'syncQueenAssignment',
],
clearable: ['groupId'],
},
UpdateBottlingBatchDto: {
dto: UpdateBottlingBatchDto,
notNull: [
'honeyBatchId',
'date',
'jarSize',
'jarCount',
'bestBeforeDate',
'lotNumber',
],
clearable: ['productId'],
},
UpdateColonyDto: {
dto: UpdateColonyDto,
notNull: ['apiaryId', 'code', 'colonyType', 'status'],
clearable: [
'hiveType',
'originText',
'strength',
'broodStatus',
'feedStatus',
'healthStatus',
'notes',
],
},
UpdateDocumentDto: {
dto: UpdateDocumentDto,
notNull: ['entityType', 'entityId', 'fileName'],
clearable: [],
},
UpdateEquipmentDto: {
dto: UpdateEquipmentDto,
notNull: ['inventoryNumber', 'name', 'condition'],
clearable: ['manufacturer', 'purchaseDate', 'location', 'maintenanceDate'],
},
UpdateExtractionDto: {
dto: UpdateExtractionDto,
notNull: ['date', 'harvestIds'],
clearable: ['amount', 'room', 'waterContent', 'temperature', 'notes'],
},
UpdateFeedingDto: {
dto: UpdateFeedingDto,
notNull: ['colonyId', 'date', 'feedType', 'amount', 'unit'],
clearable: ['product', 'batchNumber', 'notes'],
},
UpdateHarvestDto: {
dto: UpdateHarvestDto,
notNull: ['apiaryId', 'date', 'colonyIds'],
clearable: [
'honeyType',
'honeySuperCount',
'grossWeight',
'netWeight',
'notes',
],
},
UpdateHoneyBatchDto: {
dto: UpdateHoneyBatchDto,
notNull: ['batchNumber', 'extractionId', 'status'],
clearable: ['honeyType', 'amount', 'waterContent', 'storageContainerId'],
},
UpdateHoneyMeasurementDto: {
dto: UpdateHoneyMeasurementDto,
notNull: ['waterContent', 'measuredAt'],
clearable: ['device', 'temperature', 'notes'],
},
UpdateInspectionDto: {
dto: UpdateInspectionDto,
notNull: ['colonyId', 'date'],
clearable: [
'weather',
'temperature',
'colonyStrength',
'broodAmount',
'broodPattern',
'openBroodPresent',
'cappedBroodPresent',
'eggsPresent',
'queenSeen',
'queenCellsPresent',
'swarmMood',
'gentleness',
'combBehavior',
'feedStatus',
'honeySuperStatus',
'healthIssues',
'varroaIssues',
'measures',
'notes',
],
},
UpdateLabelTemplateDto: {
dto: UpdateLabelTemplateDto,
notNull: [
'name',
'elements',
'widthMm',
'heightMm',
'marginTopMm',
'marginLeftMm',
'gapXMm',
'gapYMm',
'cropMarks',
'isDefault',
],
clearable: ['columns', 'rows'],
},
UpdateProductDto: {
dto: UpdateProductDto,
notNull: ['name', 'status', 'kind', 'shopCategory'],
clearable: ['description', 'sku', 'packagingSize', 'weight', 'salesUnit'],
},
UpdateProductionBatchDto: {
dto: UpdateProductionBatchDto,
notNull: ['lotNumber', 'date', 'quantity'],
clearable: ['bestBeforeDate', 'notes'],
},
UpdateProfileDto: {
dto: UpdateProfileDto,
notNull: ['name', 'email'],
clearable: [],
},
UpdateQueenDto: {
dto: UpdateQueenDto,
notNull: ['code', 'status'],
clearable: [
'hatchYear',
'breed',
'origin',
'breeder',
'markingColor',
'markingNumber',
'matingType',
'matingStatus',
'inseminationData',
'motherLine',
'fatherLine',
'motherQueenId',
'notes',
],
},
UpdateQueenEvaluationDto: {
dto: UpdateQueenEvaluationDto,
notNull: [
'date',
'colonyStrength',
'gentleness',
'combBuild',
'swarmingTendency',
'broodPattern',
'honeyYield',
],
clearable: ['colonyId', 'notes'],
},
UpdateRawMaterialLotDto: {
dto: UpdateRawMaterialLotDto,
notNull: ['lotNumber', 'date', 'quantity'],
clearable: ['apiaryId', 'harvestId', 'notes'],
},
UpdateRoleDto: {
dto: UpdateRoleDto,
notNull: ['key', 'name', 'permissionKeys'],
clearable: ['description'],
},
UpdateStockItemDto: {
dto: UpdateStockItemDto,
notNull: ['name', 'category', 'unit', 'quantity'],
clearable: ['minThreshold', 'notes'],
},
UpdateStorageContainerDto: {
dto: UpdateStorageContainerDto,
notNull: ['code'],
clearable: ['containerType', 'capacity', 'location'],
},
UpdateSwarmControlCheckDto: {
dto: UpdateSwarmControlCheckDto,
notNull: [
'colonyId',
'date',
'queenCellsCapped',
'swarmMood',
'actionTaken',
],
clearable: ['queenCellsFound', 'notes', 'nextCheckDate'],
},
UpdateTaskDto: {
dto: UpdateTaskDto,
notNull: ['title', 'priority', 'status', 'relatedType'],
clearable: ['description', 'dueDate', 'assignedUserId', 'relatedId'],
},
UpdateTaskTemplateDto: {
dto: UpdateTaskTemplateDto,
notNull: ['title', 'priority', 'recurrenceType', 'active'],
clearable: [
'description',
'assignedUserId',
'recurrenceMonth',
'recurrenceDayOfMonth',
'intervalDays',
],
},
UpdateTreatmentDto: {
dto: UpdateTreatmentDto,
notNull: ['colonyId', 'date', 'product'],
clearable: [
'startDate',
'endDate',
'activeIngredient',
'amount',
'method',
'reason',
'batchNumber',
'supplier',
'purchaseProof',
'prescribingVet',
'notes',
'withdrawalPeriodDays',
],
},
UpdateUserDto: {
dto: UpdateUserDto,
notNull: ['name', 'email', 'roleId'],
clearable: [],
},
UpdateVarroaMeasurementDto: {
dto: UpdateVarroaMeasurementDto,
notNull: ['colonyId', 'date', 'method'],
clearable: [
'durationMinutes',
'miteCount',
'calculatedValue',
'assessment',
'notes',
],
},
UpdateWebhookDto: {
dto: UpdateWebhookDto,
notNull: ['url', 'events', 'active'],
clearable: [],
},
UpdateWinterCheckDto: {
dto: UpdateWinterCheckDto,
notNull: ['colonyId', 'season', 'date'],
clearable: [
'occupiedSeams',
'feedStockKg',
'queenPresent',
'outcome',
'lossCause',
'notes',
],
},
};
async function errorPropertiesFor(
dto: new () => object,
body: Record<string, unknown>,
): Promise<string[]> {
const errors = await validate(plainToInstance(dto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
describe('update DTO null handling', () => {
describe.each(Object.entries(contracts))('%s', (_name, contract) => {
it('accepts an empty patch', async () => {
await expect(errorPropertiesFor(contract.dto, {})).resolves.toEqual([]);
});
if (contract.notNull.length > 0) {
it.each(contract.notNull)('rejects null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([field]);
});
}
if (contract.clearable.length > 0) {
it.each(contract.clearable)('accepts null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([]);
});
}
});
});
@@ -108,6 +108,34 @@ describe('DashboardService', () => {
expect(alerts).toHaveLength(0); expect(alerts).toHaveLength(0);
}); });
describe('living colonies', () => {
// Weak, conspicuous and queenless colonies still stand in the apiary.
const living = {
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
};
it('summary counts every living colony, not only ACTIVE ones', async () => {
await service.summary();
expect(prisma.colony.count).toHaveBeenCalledWith({ where: living });
});
it('alerts check swarm controls and queens of every living colony', async () => {
await service.alerts();
expect(prisma.swarmControlCheck.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { colony: living } }),
);
const [, [queenedColoniesQuery]] = prisma.colony.findMany.mock.calls as [
unknown,
[{ where: object }],
];
expect(queenedColoniesQuery.where).toEqual(
expect.objectContaining(living),
);
});
});
describe('OLD_QUEEN', () => { describe('OLD_QUEEN', () => {
it('flags a colony whose queen is at least 3 years old', async () => { it('flags a colony whose queen is at least 3 years old', async () => {
prisma.colony.findMany prisma.colony.findMany
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
export interface DashboardSummary { export interface DashboardSummary {
apiariesActive: number; apiariesActive: number;
@@ -42,7 +43,7 @@ export class DashboardService {
stockItems, stockItems,
] = await Promise.all([ ] = await Promise.all([
this.prisma.apiary.count({ where: { status: 'ACTIVE' } }), this.prisma.apiary.count({ where: { status: 'ACTIVE' } }),
this.prisma.colony.count({ where: { status: 'ACTIVE' } }), this.prisma.colony.count({ where: LIVING_COLONY_WHERE }),
this.prisma.queen.count({ this.prisma.queen.count({
where: { status: { in: ['ACTIVE', 'RESERVE'] } }, where: { status: { in: ['ACTIVE', 'RESERVE'] } },
}), }),
@@ -89,7 +90,7 @@ export class DashboardService {
where: { minThreshold: { not: null } }, where: { minThreshold: { not: null } },
}), }),
this.prisma.swarmControlCheck.findMany({ this.prisma.swarmControlCheck.findMany({
where: { colony: { status: 'ACTIVE' } }, where: { colony: LIVING_COLONY_WHERE },
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }], orderBy: [{ colonyId: 'asc' }, { date: 'desc' }],
select: { select: {
colonyId: true, colonyId: true,
@@ -99,7 +100,7 @@ export class DashboardService {
}), }),
this.prisma.colony.findMany({ this.prisma.colony.findMany({
where: { where: {
status: 'ACTIVE', ...LIVING_COLONY_WHERE,
currentQueen: { hatchYear: { not: null } }, currentQueen: { hatchYear: { not: null } },
}, },
select: { select: {
@@ -1,6 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { DocumentEntityType } from '../../../generated/prisma'; import { DocumentEntityType } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
/** /**
* A document filed against the wrong record can be re-linked instead of * A document filed against the wrong record can be re-linked instead of
@@ -9,18 +10,18 @@ import { DocumentEntityType } from '../../../generated/prisma';
*/ */
export class UpdateDocumentDto { export class UpdateDocumentDto {
@ApiPropertyOptional({ enum: DocumentEntityType }) @ApiPropertyOptional({ enum: DocumentEntityType })
@IsOptional() @IsOptionalNotNull()
@IsEnum(DocumentEntityType) @IsEnum(DocumentEntityType)
entityType?: DocumentEntityType; entityType?: DocumentEntityType;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
entityId?: string; entityId?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
fileName?: string; fileName?: string;
@@ -1,11 +1,14 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator'; import { IsEnum } from 'class-validator';
import { EquipmentCondition } from '../../../generated/prisma'; import { EquipmentCondition } from '../../../generated/prisma';
import { CreateEquipmentDto } from './create-equipment.dto'; import { CreateEquipmentDto } from './create-equipment.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class UpdateEquipmentDto extends PartialType(CreateEquipmentDto) { export class UpdateEquipmentDto extends PartialType(CreateEquipmentDto, {
skipNullProperties: false,
}) {
@ApiPropertyOptional({ enum: EquipmentCondition }) @ApiPropertyOptional({ enum: EquipmentCondition })
@IsOptional() @IsOptionalNotNull()
@IsEnum(EquipmentCondition) @IsEnum(EquipmentCondition)
condition?: EquipmentCondition; condition?: EquipmentCondition;
} }
@@ -6,4 +6,6 @@ import { CreateExtractionDto } from './create-extraction.dto';
* linked to the wrong Ernte is a data-entry error, and re-pointing it is the * linked to the wrong Ernte is a data-entry error, and re-pointing it is the
* only way to repair the traceability chain without deleting the record. * only way to repair the traceability chain without deleting the record.
*/ */
export class UpdateExtractionDto extends PartialType(CreateExtractionDto) {} export class UpdateExtractionDto extends PartialType(CreateExtractionDto, {
skipNullProperties: false,
}) {}
@@ -6,4 +6,6 @@ import { CreateFeedingDto } from './create-feeding.dto';
* the wrong colony is a data-entry error, and re-filing it is the only way to * the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record. * repair the colony's history without deleting the record.
*/ */
export class UpdateFeedingDto extends PartialType(CreateFeedingDto) {} export class UpdateFeedingDto extends PartialType(CreateFeedingDto, {
skipNullProperties: false,
}) {}
@@ -17,6 +17,7 @@ describe('FeedingsService', () => {
}; };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
const feeding = { id: 'feeding-1', colonyId: 'colony-1', date: new Date() }; const feeding = { id: 'feeding-1', colonyId: 'colony-1', date: new Date() };
@@ -31,7 +32,7 @@ describe('FeedingsService', () => {
}, },
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
service = new FeedingsService( service = new FeedingsService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
@@ -91,6 +92,15 @@ describe('FeedingsService', () => {
); );
}); });
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);
await service.update('feeding-1', { date: '2026-05-02' }, 'user-1');
expect(calendarSync.enqueue).toHaveBeenCalledWith('FEEDING', 'feeding-1');
});
it('summaryByColony sums amounts per unit', async () => { it('summaryByColony sums amounts per unit', async () => {
prisma.feeding.groupBy.mockResolvedValue([ prisma.feeding.groupBy.mockResolvedValue([
{ unit: 'l', _sum: { amount: 5.5 } }, { unit: 'l', _sum: { amount: 5.5 } },
@@ -86,6 +86,9 @@ export class FeedingsService {
beforeData: before, beforeData: before,
afterData: feeding, 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; return feeding;
} }
} }
@@ -7,4 +7,6 @@ import { CreateHarvestDto } from './create-harvest.dto';
* data-entry error, and re-pointing it is the only way to repair the * data-entry error, and re-pointing it is the only way to repair the
* traceability chain without deleting the record. * traceability chain without deleting the record.
*/ */
export class UpdateHarvestDto extends PartialType(CreateHarvestDto) {} export class UpdateHarvestDto extends PartialType(CreateHarvestDto, {
skipNullProperties: false,
}) {}
@@ -13,6 +13,7 @@ describe('HarvestsService', () => {
$transaction: jest.Mock; $transaction: jest.Mock;
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
let tx: { let tx: {
harvest: { create: jest.Mock; update: jest.Mock }; harvest: { create: jest.Mock; update: jest.Mock };
harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock }; harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock };
@@ -41,7 +42,7 @@ describe('HarvestsService', () => {
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
service = new HarvestsService( service = new HarvestsService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
@@ -119,6 +120,14 @@ describe('HarvestsService', () => {
); );
}); });
it('update re-enqueues the calendar sync so a changed date moves the event', async () => {
prisma.harvest.findUnique.mockResolvedValue(harvest);
await service.update('harvest-1', { date: '2026-07-02' }, 'user-1');
expect(calendarSync.enqueue).toHaveBeenCalledWith('HARVEST', 'harvest-1');
});
it('update replaces the whole colony set when colonyIds is given', async () => { it('update replaces the whole colony set when colonyIds is given', async () => {
prisma.harvest.findUnique.mockResolvedValue(harvest); prisma.harvest.findUnique.mockResolvedValue(harvest);
@@ -142,6 +142,9 @@ export class HarvestsService {
beforeData: before, beforeData: before,
afterData: harvest, 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; return harvest;
} }
} }
@@ -1,4 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common'; import { Logger, ServiceUnavailableException } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -22,9 +23,37 @@ describe('HealthController', () => {
}); });
it('readiness throws ServiceUnavailableException when the database is unreachable', async () => { it('readiness throws ServiceUnavailableException when the database is unreachable', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
prisma.$queryRaw.mockRejectedValueOnce(new Error('connection refused')); prisma.$queryRaw.mockRejectedValueOnce(new Error('connection refused'));
await expect(controller.readiness()).rejects.toBeInstanceOf( await expect(controller.readiness()).rejects.toBeInstanceOf(
ServiceUnavailableException, ServiceUnavailableException,
); );
}); });
// Prisma messages can quote connection details or query values - only
// name, code and model reach the log.
it('logs a failed readiness check without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.$queryRaw.mockRejectedValueOnce(
new Prisma.PrismaClientInitializationError(
'Can not reach database server at secret-host:5432',
'7.9.1',
),
);
await expect(controller.readiness()).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('PrismaClientInitializationError');
expect(message).not.toContain('secret-host');
expect(stack).toBeUndefined();
});
afterEach(() => {
jest.restoreAllMocks();
});
}); });
@@ -8,6 +8,7 @@ import {
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { loggableError } from '../common/loggable-error';
const READINESS_QUERY_TIMEOUT_MS = 5000; const READINESS_QUERY_TIMEOUT_MS = 5000;
@@ -48,7 +49,9 @@ export class HealthController {
); );
return { status: 'ok' }; return { status: 'ok' };
} catch (error: unknown) { } catch (error: unknown) {
this.logger.error('Readiness check failed', error); // Prisma errors by name/code/model only (see loggableError).
const { message, stack } = loggableError(error);
this.logger.error(`Readiness check failed: ${message}`, stack);
throw new ServiceUnavailableException('Database not reachable'); throw new ServiceUnavailableException('Database not reachable');
} }
} }
@@ -1,12 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class CreateHoneyBatchDto { export class CreateHoneyBatchDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Wird automatisch generiert, falls die Automatisierung "Chargennummer-Format" aktiv ist', 'Wird automatisch generiert, falls die Automatisierung "Chargennummer-Format" aktiv ist',
}) })
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
batchNumber?: string; batchNumber?: string;
@@ -1,13 +1,15 @@
import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger'; import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator'; import { IsEnum } from 'class-validator';
import { HoneyBatchStatus } from '../../../generated/prisma'; import { HoneyBatchStatus } from '../../../generated/prisma';
import { CreateHoneyBatchDto } from './create-honey-batch.dto'; import { CreateHoneyBatchDto } from './create-honey-batch.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
/** /**
* `storageContainerId` is editable here too, not only through * `storageContainerId` is editable here too, not only through
* POST /honey-batches/:id/relocate: a batch recorded in the wrong container is * POST /honey-batches/:id/relocate: a batch recorded in the wrong container is
* a data-entry error and must be correctable. The service writes the same * a data-entry error and must be correctable. The service writes the same
* HoneyBatchRelocation entry the relocate endpoint writes. * HoneyBatchRelocation entry the relocate endpoint writes. `null` removes the
* container assignment; that is no move, so only the audit entry records it.
*/ */
class UpdatableHoneyBatchFieldsDto extends PartialType( class UpdatableHoneyBatchFieldsDto extends PartialType(
PickType(CreateHoneyBatchDto, [ PickType(CreateHoneyBatchDto, [
@@ -18,6 +20,7 @@ class UpdatableHoneyBatchFieldsDto extends PartialType(
'waterContent', 'waterContent',
'storageContainerId', 'storageContainerId',
] as const), ] as const),
{ skipNullProperties: false },
) {} ) {}
export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto { export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto {
@@ -27,7 +30,7 @@ export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto {
* undo a status that was set by mistake. * undo a status that was set by mistake.
*/ */
@ApiPropertyOptional({ enum: HoneyBatchStatus }) @ApiPropertyOptional({ enum: HoneyBatchStatus })
@IsOptional() @IsOptionalNotNull()
@IsEnum(HoneyBatchStatus) @IsEnum(HoneyBatchStatus)
status?: HoneyBatchStatus; status?: HoneyBatchStatus;
} }
@@ -8,4 +8,5 @@ import { CreateHoneyMeasurementDto } from './create-honey-measurement.dto';
*/ */
export class UpdateHoneyMeasurementDto extends PartialType( export class UpdateHoneyMeasurementDto extends PartialType(
CreateHoneyMeasurementDto, CreateHoneyMeasurementDto,
{ skipNullProperties: false },
) {} ) {}
@@ -395,6 +395,32 @@ describe('HoneyBatchesService', () => {
}); });
}); });
it('removes the container assignment on null without a relocation', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
storageContainerId: 'container-1',
});
await service.update(
'batch-1',
{ storageContainerId: null as never },
'user-1',
);
const [[updateArg]] = prisma.honeyBatch.update.mock.calls as [
[{ data: { storageContainerId?: string | null } }],
];
expect(updateArg.data.storageContainerId).toBeNull();
expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyBatch',
entityId: 'batch-1',
action: 'UPDATE',
}),
);
});
it('records no relocation when an edit leaves the container untouched', async () => { it('records no relocation when an edit leaves the container untouched', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({ prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch, ...honeyBatch,
@@ -87,9 +87,15 @@ export class HoneyBatchesService {
async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) { async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) {
const before = await this.findById(id); const before = await this.findById(id);
const movesContainer = // A relocation is a move into a container. null only removes the
dto.storageContainerId !== undefined && // assignment - a batch may stand in no container, as it may be created
dto.storageContainerId !== before.storageContainerId; // without one - so it is recorded by the audit entry below, not as a
// relocation (which always needs a target container).
const movedToContainerId =
typeof dto.storageContainerId === 'string' &&
dto.storageContainerId !== before.storageContainerId
? dto.storageContainerId
: null;
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.honeyBatch.update({ await tx.honeyBatch.update({
@@ -99,12 +105,12 @@ export class HoneyBatchesService {
// Correcting the container has to leave the same trace a deliberate // Correcting the container has to leave the same trace a deliberate
// relocation does - otherwise the batch's storage history would skip a // relocation does - otherwise the batch's storage history would skip a
// container it was recorded in. // container it was recorded in.
if (movesContainer) { if (movedToContainerId) {
await tx.honeyBatchRelocation.create({ await tx.honeyBatchRelocation.create({
data: { data: {
honeyBatchId: id, honeyBatchId: id,
fromStorageContainerId: before.storageContainerId, fromStorageContainerId: before.storageContainerId,
toStorageContainerId: dto.storageContainerId!, toStorageContainerId: movedToContainerId,
userId: actingUserId, userId: actingUserId,
notes: 'Korrektur über Honigcharge bearbeiten', notes: 'Korrektur über Honigcharge bearbeiten',
}, },
@@ -6,4 +6,6 @@ import { CreateInspectionDto } from './create-inspection.dto';
* the wrong colony is a data-entry error, and re-filing it is the only way to * the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record. * repair the colony's history without deleting the record.
*/ */
export class UpdateInspectionDto extends PartialType(CreateInspectionDto) {} export class UpdateInspectionDto extends PartialType(CreateInspectionDto, {
skipNullProperties: false,
}) {}
@@ -18,6 +18,7 @@ describe('InspectionsService', () => {
task: { create: jest.Mock }; task: { create: jest.Mock };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
const inspection = { const inspection = {
id: 'inspection-1', id: 'inspection-1',
@@ -40,7 +41,7 @@ describe('InspectionsService', () => {
evaluateInspection: jest.fn().mockResolvedValue(undefined), evaluateInspection: jest.fn().mockResolvedValue(undefined),
}; };
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
service = new InspectionsService( service = new InspectionsService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
webhooks as unknown as WebhooksService, webhooks as unknown as WebhooksService,
@@ -72,6 +73,23 @@ describe('InspectionsService', () => {
expect(createArg.data.userId).toBe('user-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,
});
});
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', async () => {
prisma.inspection.findUnique.mockResolvedValue(inspection); prisma.inspection.findUnique.mockResolvedValue(inspection);
const updated = { ...inspection, notes: 'geändert' }; const updated = { ...inspection, notes: 'geändert' };
@@ -95,6 +113,18 @@ describe('InspectionsService', () => {
); );
}); });
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);
await service.update('inspection-1', { date: '2026-05-02' }, 'user-1');
expect(calendarSync.enqueue).toHaveBeenCalledWith(
'INSPECTION',
'inspection-1',
);
});
it('createTaskFromInspection links the task to the inspection and its colony', async () => { it('createTaskFromInspection links the task to the inspection and its colony', async () => {
prisma.inspection.findUnique.mockResolvedValue(inspection); prisma.inspection.findUnique.mockResolvedValue(inspection);
prisma.task.create.mockResolvedValue({ id: 'task-1' }); prisma.task.create.mockResolvedValue({ id: 'task-1' });
@@ -8,6 +8,7 @@ import { Inspection, Task } from '../../generated/prisma';
import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateInspectionDto } from './dto/create-inspection.dto';
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
import { UpdateInspectionDto } from './dto/update-inspection.dto'; import { UpdateInspectionDto } from './dto/update-inspection.dto';
import { optionalDate } from '../common/patch-values';
@Injectable() @Injectable()
export class InspectionsService { export class InspectionsService {
@@ -47,6 +48,13 @@ export class InspectionsService {
const inspection = await this.prisma.inspection.create({ const inspection = await this.prisma.inspection.create({
data: { ...dto, date: new Date(dto.date), userId: actingUserId }, data: { ...dto, date: new Date(dto.date), userId: actingUserId },
}); });
await this.auditLog.record({
entityType: 'Inspection',
entityId: inspection.id,
action: 'CREATE',
userId: actingUserId,
afterData: inspection,
});
await this.webhooks.dispatch('inspection.created', inspection); await this.webhooks.dispatch('inspection.created', inspection);
await this.automation.evaluateInspection(inspection); await this.automation.evaluateInspection(inspection);
await this.calendarSync.enqueue('INSPECTION', inspection.id); await this.calendarSync.enqueue('INSPECTION', inspection.id);
@@ -71,6 +79,9 @@ export class InspectionsService {
beforeData: before, beforeData: before,
afterData: inspection, 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; return inspection;
} }
@@ -85,7 +96,7 @@ export class InspectionsService {
title: dto.title, title: dto.title,
description: dto.description, description: dto.description,
priority: dto.priority, priority: dto.priority,
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, dueDate: optionalDate(dto.dueDate),
assignedUserId: dto.assignedUserId, assignedUserId: dto.assignedUserId,
createdByUserId: actingUserId, createdByUserId: actingUserId,
relatedType: 'COLONY', relatedType: 'COLONY',
@@ -12,6 +12,7 @@ import {
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { LabelElementDto } from './label-element.dto'; import { LabelElementDto } from './label-element.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class CreateLabelTemplateDto { export class CreateLabelTemplateDto {
@ApiProperty() @ApiProperty()
@@ -26,13 +27,13 @@ export class CreateLabelTemplateDto {
elements!: LabelElementDto[]; elements!: LabelElementDto[];
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsNumber() @IsNumber()
@Min(10) @Min(10)
widthMm?: number; widthMm?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsNumber() @IsNumber()
@Min(10) @Min(10)
heightMm?: number; heightMm?: number;
@@ -55,25 +56,25 @@ export class CreateLabelTemplateDto {
rows?: number; rows?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
marginTopMm?: number; marginTopMm?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
marginLeftMm?: number; marginLeftMm?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
gapXMm?: number; gapXMm?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
gapYMm?: number; gapYMm?: number;
@@ -82,12 +83,12 @@ export class CreateLabelTemplateDto {
description: description:
'Schnittmarken im Blattrand drucken (zum Schneiden mit dem Papierschneider)', 'Schnittmarken im Blattrand drucken (zum Schneiden mit dem Papierschneider)',
}) })
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
cropMarks?: boolean; cropMarks?: boolean;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
isDefault?: boolean; isDefault?: boolean;
} }
@@ -3,4 +3,5 @@ import { CreateLabelTemplateDto } from './create-label-template.dto';
export class UpdateLabelTemplateDto extends PartialType( export class UpdateLabelTemplateDto extends PartialType(
CreateLabelTemplateDto, CreateLabelTemplateDto,
{ skipNullProperties: false },
) {} ) {}
@@ -9,6 +9,7 @@ import {
MaxLength, MaxLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
/** /**
* Corrects a production batch entered with a typo. The article is fixed once * Corrects a production batch entered with a typo. The article is fixed once
@@ -17,19 +18,19 @@ import {
*/ */
export class UpdateProductionBatchDto { export class UpdateProductionBatchDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(100) @MaxLength(100)
lotNumber?: string; lotNumber?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsDateString() @IsDateString()
date?: string; date?: string;
@ApiPropertyOptional({ description: 'Hergestellte Stückzahl' }) @ApiPropertyOptional({ description: 'Hergestellte Stückzahl' })
@IsOptional() @IsOptionalNotNull()
@IsInt() @IsInt()
@IsPositive() @IsPositive()
quantity?: number; quantity?: number;
@@ -11,6 +11,7 @@ import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import { RawMaterialKind } from '../../generated/prisma'; import { RawMaterialKind } from '../../generated/prisma';
import { optionalDate } from '../common/patch-values';
import { CreateProductionBatchDto } from './dto/create-production-batch.dto'; import { CreateProductionBatchDto } from './dto/create-production-batch.dto';
import { UpdateProductionBatchDto } from './dto/update-production-batch.dto'; import { UpdateProductionBatchDto } from './dto/update-production-batch.dto';
@@ -112,9 +113,7 @@ export class ProductionBatchesService {
lotNumber, lotNumber,
date: new Date(dto.date), date: new Date(dto.date),
quantity: dto.quantity, quantity: dto.quantity,
bestBeforeDate: dto.bestBeforeDate bestBeforeDate: optionalDate(dto.bestBeforeDate),
? new Date(dto.bestBeforeDate)
: undefined,
notes: dto.notes, notes: dto.notes,
userId: actingUserId, userId: actingUserId,
}, },
@@ -177,12 +176,7 @@ export class ProductionBatchesService {
lotNumber: dto.lotNumber, lotNumber: dto.lotNumber,
date: dto.date ? new Date(dto.date) : undefined, date: dto.date ? new Date(dto.date) : undefined,
quantity: dto.quantity, quantity: dto.quantity,
bestBeforeDate: bestBeforeDate: optionalDate(dto.bestBeforeDate),
dto.bestBeforeDate === undefined
? undefined
: dto.bestBeforeDate === null
? null
: new Date(dto.bestBeforeDate),
notes: dto.notes, notes: dto.notes,
}, },
}); });
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
import { ProductKind, ShopCategory } from '../../../generated/prisma'; import { ProductKind, ShopCategory } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class CreateProductDto { export class CreateProductDto {
@ApiProperty() @ApiProperty()
@@ -37,12 +38,12 @@ export class CreateProductDto {
description: description:
'BOTTLED_HONEY: Bestand je Abfüllung; MANUFACTURED: Bestand je Herstellungscharge', 'BOTTLED_HONEY: Bestand je Abfüllung; MANUFACTURED: Bestand je Herstellungscharge',
}) })
@IsOptional() @IsOptionalNotNull()
@IsEnum(ProductKind) @IsEnum(ProductKind)
kind?: ProductKind; kind?: ProductKind;
@ApiPropertyOptional({ enum: ShopCategory }) @ApiPropertyOptional({ enum: ShopCategory })
@IsOptional() @IsOptionalNotNull()
@IsEnum(ShopCategory) @IsEnum(ShopCategory)
shopCategory?: ShopCategory; shopCategory?: ShopCategory;
} }
@@ -1,11 +1,14 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator'; import { IsEnum } from 'class-validator';
import { ProductStatus } from '../../../generated/prisma'; import { ProductStatus } from '../../../generated/prisma';
import { CreateProductDto } from './create-product.dto'; import { CreateProductDto } from './create-product.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class UpdateProductDto extends PartialType(CreateProductDto) { export class UpdateProductDto extends PartialType(CreateProductDto, {
skipNullProperties: false,
}) {
@ApiPropertyOptional({ enum: ProductStatus }) @ApiPropertyOptional({ enum: ProductStatus })
@IsOptional() @IsOptionalNotNull()
@IsEnum(ProductStatus) @IsEnum(ProductStatus)
status?: ProductStatus; status?: ProductStatus;
} }
@@ -1,16 +1,17 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
import { NormalizeEmail } from '../../common/validation/normalize-email'; import { NormalizeEmail } from '../../common/validation/normalize-email';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class UpdateProfileDto { export class UpdateProfileDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
@MinLength(1) @MinLength(1)
name?: string; name?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@NormalizeEmail() @NormalizeEmail()
@IsEmail() @IsEmail()
email?: string; email?: string;
@@ -7,4 +7,5 @@ import { CreateQueenEvaluationDto } from './create-queen-evaluation.dto';
*/ */
export class UpdateQueenEvaluationDto extends PartialType( export class UpdateQueenEvaluationDto extends PartialType(
CreateQueenEvaluationDto, CreateQueenEvaluationDto,
{ skipNullProperties: false },
) {} ) {}
@@ -1,11 +1,14 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator'; import { IsEnum } from 'class-validator';
import { QueenStatus } from '../../../generated/prisma'; import { QueenStatus } from '../../../generated/prisma';
import { CreateQueenDto } from './create-queen.dto'; import { CreateQueenDto } from './create-queen.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class UpdateQueenDto extends PartialType(CreateQueenDto) { export class UpdateQueenDto extends PartialType(CreateQueenDto, {
skipNullProperties: false,
}) {
@ApiPropertyOptional({ enum: QueenStatus }) @ApiPropertyOptional({ enum: QueenStatus })
@IsOptional() @IsOptionalNotNull()
@IsEnum(QueenStatus) @IsEnum(QueenStatus)
status?: QueenStatus; status?: QueenStatus;
} }
@@ -9,6 +9,7 @@ import {
MaxLength, MaxLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
/** /**
* Corrects a raw material lot entered with a typo. The kind stays fixed - * Corrects a raw material lot entered with a typo. The kind stays fixed -
@@ -17,19 +18,19 @@ import {
*/ */
export class UpdateRawMaterialLotDto { export class UpdateRawMaterialLotDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(100) @MaxLength(100)
lotNumber?: string; lotNumber?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsDateString() @IsDateString()
date?: string; date?: string;
@ApiPropertyOptional({ description: 'Gewonnene Menge in Gramm' }) @ApiPropertyOptional({ description: 'Gewonnene Menge in Gramm' })
@IsOptional() @IsOptionalNotNull()
@IsInt() @IsInt()
@IsPositive() @IsPositive()
quantity?: number; quantity?: number;
@@ -11,6 +11,7 @@ import {
PERMISSION_CATALOG, PERMISSION_CATALOG,
PermissionKey, PermissionKey,
} from '../../auth/permissions/permission-catalog'; } from '../../auth/permissions/permission-catalog';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key); const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key);
@@ -21,14 +22,14 @@ export class UpdateRoleDto {
* lookup of the ADMIN role by key intact. * lookup of the ADMIN role by key intact.
*/ */
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(50) @MaxLength(50)
key?: string; key?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(100) @MaxLength(100)
@@ -41,7 +42,7 @@ export class UpdateRoleDto {
description?: string; description?: string;
@ApiPropertyOptional({ enum: CATALOG_KEYS, isArray: true }) @ApiPropertyOptional({ enum: CATALOG_KEYS, isArray: true })
@IsOptional() @IsOptionalNotNull()
@IsArray() @IsArray()
@IsIn(CATALOG_KEYS, { each: true }) @IsIn(CATALOG_KEYS, { each: true })
permissionKeys?: PermissionKey[]; permissionKeys?: PermissionKey[];
@@ -156,6 +156,30 @@ describe('StatisticsService', () => {
}); });
}); });
describe('apiaries living colonies', () => {
it('counts weak, conspicuous and queenless colonies as active', async () => {
prisma.apiary.findMany.mockResolvedValueOnce([
{
id: 'a1',
name: 'Wabenhain Nord',
colonies: [
{ id: 'c1', status: 'ACTIVE' },
{ id: 'c2', status: 'WEAK' },
{ id: 'c3', status: 'CONSPICUOUS' },
{ id: 'c4', status: 'QUEENLESS' },
{ id: 'c5', status: 'SOLD' },
{ id: 'c6', status: 'MERGED' },
],
harvests: [],
},
]);
const [result] = await service.apiaries();
expect(result.activeColonies).toBe(4);
});
});
describe('varroa', () => { describe('varroa', () => {
it('returns measurements and treatment counts per colony', async () => { it('returns measurements and treatment counts per colony', async () => {
prisma.varroaMeasurement.findMany.mockResolvedValueOnce([ prisma.varroaMeasurement.findMany.mockResolvedValueOnce([
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { isLivingColonyStatus } from '../colonies/colony-status';
@Injectable() @Injectable()
export class StatisticsService { export class StatisticsService {
@@ -125,8 +126,9 @@ export class StatisticsService {
(sum, harvest) => sum + (harvest.netWeight ?? 0), (sum, harvest) => sum + (harvest.netWeight ?? 0),
0, 0,
); );
const activeColonies = apiary.colonies.filter( // Weak, conspicuous and queenless colonies still stand there.
(c) => c.status === 'ACTIVE', const activeColonies = apiary.colonies.filter((c) =>
isLivingColonyStatus(c.status),
).length; ).length;
const lostColonies = apiary.colonies.filter((c) => const lostColonies = apiary.colonies.filter((c) =>
['DECEASED', 'DISSOLVED'].includes(c.status), ['DECEASED', 'DISSOLVED'].includes(c.status),
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { StockCategory } from '../../../generated/prisma'; import { StockCategory } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class CreateStockItemDto { export class CreateStockItemDto {
@ApiProperty() @ApiProperty()
@@ -16,7 +17,7 @@ export class CreateStockItemDto {
unit!: string; unit!: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
quantity?: number; quantity?: number;
@@ -7,4 +7,6 @@ import { CreateStockItemDto } from './create-stock-item.dto';
* difference as an ADJUSTMENT movement, so the movement history still adds up * difference as an ADJUSTMENT movement, so the movement history still adds up
* to the stored quantity and nothing is corrected behind its back. * to the stored quantity and nothing is corrected behind its back.
*/ */
export class UpdateStockItemDto extends PartialType(CreateStockItemDto) {} export class UpdateStockItemDto extends PartialType(CreateStockItemDto, {
skipNullProperties: false,
}) {}
@@ -8,4 +8,5 @@ export class UpdateStorageContainerDto extends PartialType(
'capacity', 'capacity',
'location', 'location',
] as const), ] as const),
{ skipNullProperties: false },
) {} ) {}
@@ -8,6 +8,7 @@ import {
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
import { SwarmControlAction, SwarmMood } from '../../../generated/prisma'; import { SwarmControlAction, SwarmMood } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class CreateSwarmControlCheckDto { export class CreateSwarmControlCheckDto {
@ApiProperty() @ApiProperty()
@@ -24,17 +25,17 @@ export class CreateSwarmControlCheckDto {
queenCellsFound?: number; queenCellsFound?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
queenCellsCapped?: boolean; queenCellsCapped?: boolean;
@ApiPropertyOptional({ enum: SwarmMood }) @ApiPropertyOptional({ enum: SwarmMood })
@IsOptional() @IsOptionalNotNull()
@IsEnum(SwarmMood) @IsEnum(SwarmMood)
swarmMood?: SwarmMood; swarmMood?: SwarmMood;
@ApiPropertyOptional({ enum: SwarmControlAction }) @ApiPropertyOptional({ enum: SwarmControlAction })
@IsOptional() @IsOptionalNotNull()
@IsEnum(SwarmControlAction) @IsEnum(SwarmControlAction)
actionTaken?: SwarmControlAction; actionTaken?: SwarmControlAction;
@@ -8,4 +8,5 @@ import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto';
*/ */
export class UpdateSwarmControlCheckDto extends PartialType( export class UpdateSwarmControlCheckDto extends PartialType(
CreateSwarmControlCheckDto, CreateSwarmControlCheckDto,
{ skipNullProperties: false },
) {} ) {}
@@ -102,6 +102,24 @@ describe('SwarmControlService', () => {
); );
}); });
it('update clears the next check date on null', async () => {
prisma.swarmControlCheck.findUnique.mockResolvedValue({
...check,
nextCheckDate: new Date('2026-05-08T00:00:00.000Z'),
});
prisma.swarmControlCheck.update.mockResolvedValue(check);
await service.update('check-1', { nextCheckDate: null as never }, 'user-1');
const [[updateArg]] = prisma.swarmControlCheck.update.mock.calls as [
[{ data: { nextCheckDate?: Date | null } }],
];
expect(updateArg.data.nextCheckDate).toBeNull();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'UPDATE', entityId: 'check-1' }),
);
});
it('findByColony orders results chronologically', async () => { it('findByColony orders results chronologically', async () => {
prisma.swarmControlCheck.findMany.mockResolvedValue([check]); prisma.swarmControlCheck.findMany.mockResolvedValue([check]);
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { SwarmControlCheck } from '../../generated/prisma'; import { SwarmControlCheck } from '../../generated/prisma';
import { optionalDate } from '../common/patch-values';
import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto'; import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto';
import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto'; import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto';
@@ -45,9 +46,7 @@ export class SwarmControlService {
data: { data: {
...dto, ...dto,
date: new Date(dto.date), date: new Date(dto.date),
nextCheckDate: dto.nextCheckDate nextCheckDate: optionalDate(dto.nextCheckDate),
? new Date(dto.nextCheckDate)
: undefined,
userId: actingUserId, userId: actingUserId,
}, },
}); });
@@ -73,9 +72,7 @@ export class SwarmControlService {
data: { data: {
...dto, ...dto,
date: dto.date ? new Date(dto.date) : undefined, date: dto.date ? new Date(dto.date) : undefined,
nextCheckDate: dto.nextCheckDate nextCheckDate: optionalDate(dto.nextCheckDate),
? new Date(dto.nextCheckDate)
: undefined,
}, },
}); });
await this.auditLog.record({ await this.auditLog.record({
@@ -4,6 +4,7 @@ import {
TaskPriority, TaskPriority,
TaskTemplateRecurrenceType, TaskTemplateRecurrenceType,
} from '../../../generated/prisma'; } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class CreateTaskTemplateDto { export class CreateTaskTemplateDto {
@ApiProperty() @ApiProperty()
@@ -16,7 +17,7 @@ export class CreateTaskTemplateDto {
description?: string; description?: string;
@ApiPropertyOptional({ enum: TaskPriority }) @ApiPropertyOptional({ enum: TaskPriority })
@IsOptional() @IsOptionalNotNull()
@IsEnum(TaskPriority) @IsEnum(TaskPriority)
priority?: TaskPriority; priority?: TaskPriority;
@@ -1,10 +1,13 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator'; import { IsBoolean } from 'class-validator';
import { CreateTaskTemplateDto } from './create-task-template.dto'; import { CreateTaskTemplateDto } from './create-task-template.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto) { export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto, {
skipNullProperties: false,
}) {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
active?: boolean; active?: boolean;
} }
@@ -131,6 +131,59 @@ describe('TaskTemplatesService', () => {
expect(updateArg.data.nextDueDate).toBeInstanceOf(Date); expect(updateArg.data.nextDueDate).toBeInstanceOf(Date);
}); });
describe('update with emptied recurrence fields', () => {
it('validates the cleared value it writes instead of the stored one', async () => {
prisma.taskTemplate.findUnique.mockResolvedValue(template);
await expect(
service.update(
'template-1',
{ recurrenceDayOfMonth: null as never },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.taskTemplate.update).not.toHaveBeenCalled();
});
it('writes the merged recurrence when switching to an interval', async () => {
prisma.taskTemplate.findUnique.mockResolvedValue(template);
prisma.taskTemplate.update.mockResolvedValue(template);
await service.update(
'template-1',
{
recurrenceType: 'INTERVAL_DAYS',
intervalDays: 10,
recurrenceDayOfMonth: null as never,
},
'user-1',
);
const [[updateArg]] = prisma.taskTemplate.update.mock.calls as [
[
{
data: {
recurrenceType: string;
recurrenceMonth: number | null;
recurrenceDayOfMonth: number | null;
intervalDays: number | null;
nextDueDate: Date;
};
},
],
];
expect(updateArg.data).toEqual(
expect.objectContaining({
recurrenceType: 'INTERVAL_DAYS',
recurrenceMonth: null,
recurrenceDayOfMonth: null,
intervalDays: 10,
}),
);
expect(updateArg.data.nextDueDate).toBeInstanceOf(Date);
});
});
it('deactivate sets active to false', async () => { it('deactivate sets active to false', async () => {
prisma.taskTemplate.findUnique.mockResolvedValue(template); prisma.taskTemplate.findUnique.mockResolvedValue(template);
prisma.taskTemplate.update.mockResolvedValue({ prisma.taskTemplate.update.mockResolvedValue({
@@ -15,6 +15,13 @@ import {
RecurrenceInput, RecurrenceInput,
} from './recurrence'; } from './recurrence';
function valueOrStored(
value: number | null | undefined,
stored: number | null,
): number | null {
return value === undefined ? stored : value;
}
function toRecurrenceInput(input: { function toRecurrenceInput(input: {
recurrenceType: TaskTemplate['recurrenceType']; recurrenceType: TaskTemplate['recurrenceType'];
recurrenceMonth?: number | null; recurrenceMonth?: number | null;
@@ -84,22 +91,31 @@ export class TaskTemplatesService {
dto.recurrenceDayOfMonth !== undefined || dto.recurrenceDayOfMonth !== undefined ||
dto.intervalDays !== undefined; dto.intervalDays !== undefined;
// The recurrence that is validated is exactly the one written: an
// omitted field keeps its stored value, null clears it.
const recurrence = recurrenceChanged
? {
recurrenceType: dto.recurrenceType ?? before.recurrenceType,
recurrenceMonth: valueOrStored(
dto.recurrenceMonth,
before.recurrenceMonth,
),
recurrenceDayOfMonth: valueOrStored(
dto.recurrenceDayOfMonth,
before.recurrenceDayOfMonth,
),
intervalDays: valueOrStored(dto.intervalDays, before.intervalDays),
}
: undefined;
let nextDueDate: Date | undefined; let nextDueDate: Date | undefined;
if (recurrenceChanged) { if (recurrence) {
const recurrence = toRecurrenceInput({
recurrenceType: dto.recurrenceType ?? before.recurrenceType,
recurrenceMonth: dto.recurrenceMonth ?? before.recurrenceMonth,
recurrenceDayOfMonth:
dto.recurrenceDayOfMonth ?? before.recurrenceDayOfMonth,
intervalDays: dto.intervalDays ?? before.intervalDays,
});
this.assertRecurrenceFieldsPresent(recurrence); this.assertRecurrenceFieldsPresent(recurrence);
nextDueDate = computeInitialDueDate(recurrence, new Date()); nextDueDate = computeInitialDueDate(recurrence, new Date());
} }
const template = await this.prisma.taskTemplate.update({ const template = await this.prisma.taskTemplate.update({
where: { id }, where: { id },
data: { ...dto, nextDueDate }, data: { ...dto, ...recurrence, nextDueDate },
}); });
await this.auditLog.record({ await this.auditLog.record({
entityType: 'TaskTemplate', entityType: 'TaskTemplate',
@@ -5,10 +5,11 @@ import {
TaskRelatedType, TaskRelatedType,
TaskStatus, TaskStatus,
} from '../../../generated/prisma'; } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class UpdateTaskDto { export class UpdateTaskDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
title?: string; title?: string;
@@ -18,7 +19,7 @@ export class UpdateTaskDto {
description?: string; description?: string;
@ApiPropertyOptional({ enum: TaskPriority }) @ApiPropertyOptional({ enum: TaskPriority })
@IsOptional() @IsOptionalNotNull()
@IsEnum(TaskPriority) @IsEnum(TaskPriority)
priority?: TaskPriority; priority?: TaskPriority;
@@ -33,12 +34,12 @@ export class UpdateTaskDto {
assignedUserId?: string; assignedUserId?: string;
@ApiPropertyOptional({ enum: TaskStatus }) @ApiPropertyOptional({ enum: TaskStatus })
@IsOptional() @IsOptionalNotNull()
@IsEnum(TaskStatus) @IsEnum(TaskStatus)
status?: TaskStatus; status?: TaskStatus;
@ApiPropertyOptional({ enum: TaskRelatedType }) @ApiPropertyOptional({ enum: TaskRelatedType })
@IsOptional() @IsOptionalNotNull()
@IsEnum(TaskRelatedType) @IsEnum(TaskRelatedType)
relatedType?: TaskRelatedType; relatedType?: TaskRelatedType;
@@ -18,6 +18,7 @@ describe('TasksService', () => {
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let taskTemplates: { generateDueTasks: jest.Mock }; let taskTemplates: { generateDueTasks: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' }; const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' };
@@ -35,7 +36,7 @@ describe('TasksService', () => {
taskTemplates = { taskTemplates = {
generateDueTasks: jest.fn().mockResolvedValue(undefined), generateDueTasks: jest.fn().mockResolvedValue(undefined),
}; };
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
service = new TasksService( service = new TasksService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
@@ -85,4 +86,45 @@ describe('TasksService', () => {
expect(updateArg.data.status).toBe('DONE'); expect(updateArg.data.status).toBe('DONE');
expect(updateArg.data.completedAt).toBeInstanceOf(Date); expect(updateArg.data.completedAt).toBeInstanceOf(Date);
}); });
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();
});
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 });
await service.update('task-1', { dueDate: null as never }, 'user-1');
const [[updateArg]] = prisma.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();
});
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 });
await service.update('task-1', { status }, 'user-1');
expect(calendarSync.cancel).toHaveBeenCalledWith('TASK', 'task-1');
expect(calendarSync.enqueue).not.toHaveBeenCalled();
},
);
});
}); });
+6 -3
View File
@@ -7,6 +7,7 @@ import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma'; import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
import { CreateTaskDto } from './dto/create-task.dto'; import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-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: * Keeps completedAt in step with status when the status is edited directly:
@@ -58,7 +59,7 @@ export class TasksService {
const task = await this.prisma.task.create({ const task = await this.prisma.task.create({
data: { data: {
...dto, ...dto,
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, dueDate: optionalDate(dto.dueDate),
createdByUserId: actingUserId, createdByUserId: actingUserId,
}, },
}); });
@@ -86,7 +87,7 @@ export class TasksService {
where: { id }, where: { id },
data: { data: {
...dto, ...dto,
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, dueDate: optionalDate(dto.dueDate),
completedAt: completedAtForStatusChange(before, dto.status), completedAt: completedAtForStatusChange(before, dto.status),
}, },
}); });
@@ -98,7 +99,9 @@ export class TasksService {
beforeData: before, beforeData: before,
afterData: task, afterData: task,
}); });
if (task.dueDate) { // 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); await this.calendarSync.enqueue('TASK', task.id);
} else { } else {
await this.calendarSync.cancel('TASK', task.id); await this.calendarSync.cancel('TASK', task.id);
@@ -6,4 +6,6 @@ import { CreateTreatmentDto } from './create-treatment.dto';
* the wrong colony is a data-entry error, and re-filing it is the only way to * the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record. * repair the colony's history without deleting the record.
*/ */
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto) {} export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto, {
skipNullProperties: false,
}) {}
@@ -0,0 +1,85 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
MAX_WITHDRAWAL_STATUS_COLONIES,
WithdrawalStatusQueryDto,
} from './withdrawal-status-query.dto';
async function check(query: Record<string, unknown>) {
const dto = plainToInstance(WithdrawalStatusQueryDto, query);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
return {
dto,
messages: errors.flatMap((error) => Object.values(error.constraints ?? {})),
};
}
function cuidLike(index: number): string {
return `cl${index.toString().padStart(23, '0')}`;
}
describe('WithdrawalStatusQueryDto', () => {
describe('colonyIds', () => {
it('splits the comma-separated ids', async () => {
const { dto, messages } = await check({ colonyIds: 'colonya,colonyb' });
expect(messages).toEqual([]);
expect(dto.colonyIds).toEqual(['colonya', 'colonyb']);
});
it('drops empty segments', async () => {
const { dto, messages } = await check({ colonyIds: 'colonya,,' });
expect(messages).toEqual([]);
expect(dto.colonyIds).toEqual(['colonya']);
});
it('accepts the ids of a repeated query parameter', async () => {
const { dto, messages } = await check({
colonyIds: ['colonya', 'colonyb'],
});
expect(messages).toEqual([]);
expect(dto.colonyIds).toEqual(['colonya', 'colonyb']);
});
it(`accepts up to ${MAX_WITHDRAWAL_STATUS_COLONIES} ids`, async () => {
const ids = Array.from(
{ length: MAX_WITHDRAWAL_STATUS_COLONIES },
(_, index) => cuidLike(index),
);
const { messages } = await check({ colonyIds: ids.join(',') });
expect(messages).toEqual([]);
});
it('rejects more ids than one request may ask for', async () => {
const ids = Array.from(
{ length: MAX_WITHDRAWAL_STATUS_COLONIES + 1 },
(_, index) => cuidLike(index),
);
const { messages } = await check({ colonyIds: ids.join(',') });
expect(messages).toEqual([
`colonyIds darf höchstens ${MAX_WITHDRAWAL_STATUS_COLONIES} Volk-IDs enthalten`,
]);
});
it.each([
['too long', 'c'.repeat(65)],
['with a space', 'colony a'],
['with a quote', "colony'a"],
['with a path', '../colony'],
['with a semicolon', 'colony;drop'],
])('rejects an id %s', async (_case, id) => {
const { messages } = await check({ colonyIds: `colonya,${id}` });
expect(messages).toEqual(['colonyIds enthält eine ungültige Volk-ID']);
});
});
});
@@ -0,0 +1,67 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsDateString,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
// A harvest covers the colonies of one apiary - far fewer than this. The cap
// keeps a single request from turning into an unbounded IN (...) query.
export const MAX_WITHDRAWAL_STATUS_COLONIES = 200;
const MAX_COLONY_ID_LENGTH = 64;
const COLONY_ID_MESSAGE = 'colonyIds enthält eine ungültige Volk-ID';
const AT_MESSAGE =
'at muss ein Datum (JJJJ-MM-TT) oder ein ISO-8601-Zeitpunkt sein';
// "a,b" and the repeated form colonyIds=a&colonyIds=b both become a list;
// empty segments ("a,,b", a trailing comma) carry no id and are dropped.
function toColonyIdList({ value }: TransformFnParams): unknown {
const parts: unknown =
typeof value === 'string' ? value.split(',') : (value as unknown);
return Array.isArray(parts) ? parts.filter((part) => part !== '') : parts;
}
export class WithdrawalStatusQueryDto {
@ApiPropertyOptional({
type: String,
description: `Kommagetrennte Volk-IDs, höchstens ${MAX_WITHDRAWAL_STATUS_COLONIES}`,
example: 'colony-a,colony-b',
})
@IsOptional()
@Transform(toColonyIdList)
@IsArray({ message: COLONY_ID_MESSAGE })
@ArrayMaxSize(MAX_WITHDRAWAL_STATUS_COLONIES, {
message: `colonyIds darf höchstens ${MAX_WITHDRAWAL_STATUS_COLONIES} Volk-IDs enthalten`,
})
@IsString({ each: true, message: COLONY_ID_MESSAGE })
@MaxLength(MAX_COLONY_ID_LENGTH, { each: true, message: COLONY_ID_MESSAGE })
// cuid charset (plus - and _): ids never need anything else.
@Matches(/^[A-Za-z0-9_-]+$/, { each: true, message: COLONY_ID_MESSAGE })
colonyIds?: string[];
@ApiPropertyOptional({
description:
'Erntezeitpunkt, für den die Wartezeit geprüft wird (JJJJ-MM-TT oder ISO-8601-Zeitpunkt); ohne Angabe jetzt.',
example: '2026-07-15',
})
@IsOptional()
// ISO 8601 also admits forms Date cannot parse (20260715, 2026-07-15T10Z,
// +02 offsets) and, without strict, impossible days like 2026-02-30. An
// unparseable date would report every window as over, so only the
// calendar-checked extended forms Date understands are accepted.
@IsDateString(
{ strict: true, strictSeparator: true },
{ message: AT_MESSAGE },
)
@Matches(
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/,
{ message: AT_MESSAGE },
)
at?: string;
}
@@ -13,6 +13,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto';
import { WithdrawalStatusQueryDto } from './dto/withdrawal-status-query.dto';
import { TreatmentsService } from './treatments.service'; import { TreatmentsService } from './treatments.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator'; import { Idempotent } from '../common/idempotency/idempotent.decorator';
@@ -28,11 +29,14 @@ export class TreatmentsController {
return this.treatmentsService.findAll(); return this.treatmentsService.findAll();
} }
/** Active withdrawal windows per colony at `at` (the harvest date), default now. */
@RequirePermission('treatments:read') @RequirePermission('treatments:read')
@Get('withdrawal-status') @Get('withdrawal-status')
getWithdrawalStatus(@Query('colonyIds') colonyIds?: string) { getWithdrawalStatus(@Query() query: WithdrawalStatusQueryDto) {
const ids = colonyIds ? colonyIds.split(',').filter(Boolean) : []; return this.treatmentsService.getWithdrawalStatus(
return this.treatmentsService.getWithdrawalStatus(ids); query.colonyIds ?? [],
query.at ? new Date(query.at) : new Date(),
);
} }
@RequirePermission('treatments:read') @RequirePermission('treatments:read')
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { TreatmentsService } from './treatments.service'; import { TreatmentsService } from './treatments.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -17,6 +17,7 @@ describe('TreatmentsService', () => {
colony: { findUnique: jest.Mock }; colony: { findUnique: jest.Mock };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock };
const treatment = { const treatment = {
id: 'treatment-1', id: 'treatment-1',
@@ -37,7 +38,7 @@ describe('TreatmentsService', () => {
}, },
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
service = new TreatmentsService( service = new TreatmentsService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
@@ -103,6 +104,39 @@ describe('TreatmentsService', () => {
); );
}); });
it('update clears start and end date on null', async () => {
prisma.treatment.findUnique.mockResolvedValue({
...treatment,
startDate: new Date('2026-07-01'),
endDate: new Date('2026-07-14'),
});
prisma.treatment.update.mockResolvedValue(treatment);
await service.update(
'treatment-1',
{ startDate: null as never, endDate: null as never },
'user-1',
);
const [[updateArg]] = prisma.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);
await service.update('treatment-1', { date: '2026-05-02' }, 'user-1');
expect(calendarSync.enqueue).toHaveBeenCalledWith(
'TREATMENT',
'treatment-1',
);
});
it('findByColony orders results chronologically', async () => { it('findByColony orders results chronologically', async () => {
prisma.treatment.findMany.mockResolvedValue([treatment]); prisma.treatment.findMany.mockResolvedValue([treatment]);
@@ -125,19 +159,19 @@ describe('TreatmentsService', () => {
it('reports the treatment whose withdrawal window reaches furthest into the future', async () => { it('reports the treatment whose withdrawal window reaches furthest into the future', async () => {
const now = Date.now(); const now = Date.now();
const inTenDays = new Date(now + 10 * 24 * 60 * 60 * 1000); const twoDaysAgo = new Date(now - 2 * 24 * 60 * 60 * 1000);
const inFiveDays = new Date(now + 5 * 24 * 60 * 60 * 1000); const fiveDaysAgo = new Date(now - 5 * 24 * 60 * 60 * 1000);
prisma.treatment.findMany.mockResolvedValue([ prisma.treatment.findMany.mockResolvedValue([
{ {
colonyId: 'colony-1', colonyId: 'colony-1',
date: inTenDays, date: twoDaysAgo,
endDate: null, endDate: null,
withdrawalPeriodDays: 0, withdrawalPeriodDays: 7,
product: 'Ameisensäure', product: 'Ameisensäure',
}, },
{ {
colonyId: 'colony-1', colonyId: 'colony-1',
date: inFiveDays, date: fiveDaysAgo,
endDate: null, endDate: null,
withdrawalPeriodDays: 21, withdrawalPeriodDays: 21,
product: 'Api Bioxal', product: 'Api Bioxal',
@@ -167,5 +201,127 @@ describe('TreatmentsService', () => {
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it('ignores a treatment that has not started yet', async () => {
const inThreeDays = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
prisma.treatment.findMany.mockResolvedValue([
{
colonyId: 'colony-1',
date: inThreeDays,
startDate: null,
endDate: null,
withdrawalPeriodDays: 14,
product: 'Ameisensäure',
},
]);
const result = await service.getWithdrawalStatus(['colony-1']);
expect(result).toEqual([]);
});
describe('evaluated for a harvest date', () => {
// Ended 2026-07-01 with 14 days withdrawal: honey may be harvested
// from 2026-07-15 on.
const endedFirstOfJuly = {
colonyId: 'colony-1',
date: new Date('2026-06-20'),
startDate: null,
endDate: new Date('2026-07-01'),
withdrawalPeriodDays: 14,
product: 'Ameisensäure',
};
it('reports a window that is still open on that date', async () => {
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
const result = await service.getWithdrawalStatus(
['colony-1'],
new Date('2026-07-10'),
);
expect(result).toEqual([
{
colonyId: 'colony-1',
activeUntil: '2026-07-15T00:00:00.000Z',
product: 'Ameisensäure',
},
]);
});
it('omits a window that has ended by that date', async () => {
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
const result = await service.getWithdrawalStatus(
['colony-1'],
new Date('2026-07-15'),
);
expect(result).toEqual([]);
});
it('ignores a treatment that starts after that date', async () => {
prisma.treatment.findMany.mockResolvedValue([
{
...endedFirstOfJuly,
date: new Date('2026-07-16'),
endDate: new Date('2026-07-20'),
},
]);
const result = await service.getWithdrawalStatus(
['colony-1'],
new Date('2026-07-15'),
);
expect(result).toEqual([]);
});
it('counts a treatment begun on that very day', async () => {
prisma.treatment.findMany.mockResolvedValue([
{ ...endedFirstOfJuly, date: new Date('2026-07-15'), endDate: null },
]);
const result = await service.getWithdrawalStatus(
['colony-1'],
new Date('2026-07-15'),
);
expect(result).toHaveLength(1);
});
it('counts a multi-day treatment whose start lies before that date', async () => {
prisma.treatment.findMany.mockResolvedValue([
{
...endedFirstOfJuly,
date: new Date('2026-07-20'),
startDate: new Date('2026-07-10'),
endDate: new Date('2026-07-20'),
withdrawalPeriodDays: 7,
},
]);
const result = await service.getWithdrawalStatus(
['colony-1'],
new Date('2026-07-15'),
);
expect(result).toEqual([
{
colonyId: 'colony-1',
activeUntil: '2026-07-27T00:00:00.000Z',
product: 'Ameisensäure',
},
]);
});
it('rejects an unparseable date instead of reporting every window as over', async () => {
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
await expect(
service.getWithdrawalStatus(['colony-1'], new Date('kein Datum')),
).rejects.toBeInstanceOf(BadRequestException);
});
});
}); });
}); });
@@ -1,9 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { Treatment } from '../../generated/prisma'; import { Treatment } from '../../generated/prisma';
import { optionalDate } from '../common/patch-values';
import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto';
@@ -13,6 +18,16 @@ export interface WithdrawalStatus {
product: string; product: string;
} }
// When a treatment began: the earlier of its date and the start of a
// multi-day treatment - the earlier one errs on the side of withdrawal.
function treatmentStart(
treatment: Pick<Treatment, 'date' | 'startDate'>,
): Date {
return treatment.startDate && treatment.startDate < treatment.date
? treatment.startDate
: treatment.date;
}
@Injectable() @Injectable()
export class TreatmentsService { export class TreatmentsService {
constructor( constructor(
@@ -44,9 +59,19 @@ export class TreatmentsService {
// For each colony, returns the treatment whose withdrawal window (its // For each colony, returns the treatment whose withdrawal window (its
// end date, or start date if the treatment is still ongoing, plus its // end date, or start date if the treatment is still ongoing, plus its
// withdrawalPeriodDays) reaches furthest into the future - only when // withdrawalPeriodDays) reaches furthest into the future - only when
// that window is still active. Colonies with no active window are // that window is still active at `at`, the harvest date (default now).
// omitted rather than returned as null, since most colonies have none. // A treatment begun after `at` has no bearing on honey taken at `at`.
async getWithdrawalStatus(colonyIds: string[]): Promise<WithdrawalStatus[]> { // Colonies with no active window are omitted rather than returned as
// null, since most colonies have none.
async getWithdrawalStatus(
colonyIds: string[],
at: Date = new Date(),
): Promise<WithdrawalStatus[]> {
// An invalid date compares false against every window and would report
// a treated colony as harvestable - fail closed instead.
if (Number.isNaN(at.getTime())) {
throw new BadRequestException('Ungültiges Datum für die Wartezeit');
}
if (colonyIds.length === 0) { if (colonyIds.length === 0) {
return []; return [];
} }
@@ -56,15 +81,17 @@ export class TreatmentsService {
withdrawalPeriodDays: { not: null }, withdrawalPeriodDays: { not: null },
}, },
}); });
const now = new Date();
const activeByColony = new Map<string, WithdrawalStatus>(); const activeByColony = new Map<string, WithdrawalStatus>();
for (const treatment of treatments) { for (const treatment of treatments) {
if (treatmentStart(treatment) > at) {
continue;
}
const referenceDate = treatment.endDate ?? treatment.date; const referenceDate = treatment.endDate ?? treatment.date;
const activeUntil = new Date(referenceDate); const activeUntil = new Date(referenceDate);
activeUntil.setUTCDate( activeUntil.setUTCDate(
activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0), activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0),
); );
if (activeUntil <= now) { if (activeUntil <= at) {
continue; continue;
} }
const current = activeByColony.get(treatment.colonyId); const current = activeByColony.get(treatment.colonyId);
@@ -97,8 +124,8 @@ export class TreatmentsService {
// treatment for the Bestandsbuch, the colony may be moved later. // treatment for the Bestandsbuch, the colony may be moved later.
apiaryId: colony.apiaryId, apiaryId: colony.apiaryId,
date: new Date(dto.date), date: new Date(dto.date),
startDate: dto.startDate ? new Date(dto.startDate) : undefined, startDate: optionalDate(dto.startDate),
endDate: dto.endDate ? new Date(dto.endDate) : undefined, endDate: optionalDate(dto.endDate),
userId: actingUserId, userId: actingUserId,
}, },
}); });
@@ -134,8 +161,8 @@ export class TreatmentsService {
...dto, ...dto,
apiaryId: movedColony ? movedColony.apiaryId : undefined, apiaryId: movedColony ? movedColony.apiaryId : undefined,
date: dto.date ? new Date(dto.date) : undefined, date: dto.date ? new Date(dto.date) : undefined,
startDate: dto.startDate ? new Date(dto.startDate) : undefined, startDate: optionalDate(dto.startDate),
endDate: dto.endDate ? new Date(dto.endDate) : undefined, endDate: optionalDate(dto.endDate),
}, },
}); });
await this.auditLog.record({ await this.auditLog.record({
@@ -146,6 +173,9 @@ export class TreatmentsService {
beforeData: before, beforeData: before,
afterData: treatment, afterData: treatment,
}); });
// A changed date, colony or product must move the calendar event, not
// leave the old one behind.
await this.calendarSync.enqueue('TREATMENT', treatment.id);
return treatment; return treatment;
} }
} }
@@ -1,10 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; import { IsEmail, IsString, MinLength } from 'class-validator';
import { NormalizeEmail } from '../../common/validation/normalize-email'; import { NormalizeEmail } from '../../common/validation/normalize-email';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export class UpdateUserDto { export class UpdateUserDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
name?: string; name?: string;
@@ -16,13 +17,13 @@ export class UpdateUserDto {
* unique constraint and surfaces as a 409. * unique constraint and surfaces as a 409.
*/ */
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptionalNotNull()
@NormalizeEmail() @NormalizeEmail()
@IsEmail() @IsEmail()
email?: string; email?: string;
@ApiPropertyOptional({ description: 'Id of an active Role' }) @ApiPropertyOptional({ description: 'Id of an active Role' })
@IsOptional() @IsOptionalNotNull()
@IsString() @IsString()
@MinLength(1) @MinLength(1)
roleId?: string; roleId?: string;
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more