Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export
# Conflicts: # ARCHITECTURE.md
This commit is contained in:
commit
9c332d4716
310 files changed
+11975
-3544
No files matched your search
+9
-7
@@ -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,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;
|
||||||
@@ -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,7 +244,25 @@ 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.evaluateAfterCommit('Inspection', inspection.id, async () => {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
const rules = await this.prisma.automationRule.findMany({
|
const rules = await this.prisma.automationRule.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -269,11 +295,16 @@ export class AutomationService {
|
|||||||
inspection.id,
|
inspection.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateVarroaMeasurement(
|
async evaluateVarroaMeasurement(
|
||||||
measurement: VarroaMeasurement,
|
measurement: VarroaMeasurement,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await this.evaluateAfterCommit(
|
||||||
|
'VarroaMeasurement',
|
||||||
|
measurement.id,
|
||||||
|
async () => {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
const rule = await this.prisma.automationRule.findUnique({
|
||||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||||
@@ -281,10 +312,11 @@ export class AutomationService {
|
|||||||
if (!rule || !rule.active) {
|
if (!rule || !rule.active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The threshold is a natural mite fall rate (mites per day). Only that
|
// The threshold is a natural mite fall rate (mites per day). Only
|
||||||
// method yields a value in this unit - a mite count from a sugar roll or
|
// that method yields a value in this unit - a mite count from a
|
||||||
// wash is per bee sample and would be compared against the wrong scale,
|
// sugar roll or wash is per bee sample and would be compared against
|
||||||
// as would a raw board count without the number of days it covered.
|
// the wrong scale, as would a raw board count without the number of
|
||||||
|
// days it covered.
|
||||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -294,9 +326,12 @@ export class AutomationService {
|
|||||||
if (value !== null && value >= threshold) {
|
if (value !== null && value >= threshold) {
|
||||||
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
|
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
|
||||||
|
await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
const rule = await this.prisma.automationRule.findUnique({
|
||||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||||
@@ -307,10 +342,12 @@ export class AutomationService {
|
|||||||
if (check.swarmMood === 'HIGH') {
|
if (check.swarmMood === 'HIGH') {
|
||||||
await this.triggerAction(rule, 'COLONY', check.colonyId);
|
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.evaluateAfterCommit('Colony', after.id, async () => {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
if (
|
if (
|
||||||
after.healthStatus !== 'CRITICAL' ||
|
after.healthStatus !== 'CRITICAL' ||
|
||||||
@@ -325,9 +362,11 @@ export class AutomationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerAction(rule, 'COLONY', after.id);
|
await this.triggerAction(rule, 'COLONY', after.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateStockItem(item: StockItem): Promise<void> {
|
async evaluateStockItem(item: StockItem): Promise<void> {
|
||||||
|
await this.evaluateAfterCommit('StockItem', item.id, async () => {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||||
return;
|
return;
|
||||||
@@ -339,6 +378,7 @@ export class AutomationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerAction(rule, 'STORAGE', item.id);
|
await this.triggerAction(rule, 'STORAGE', item.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,6 +392,7 @@ export class AutomationService {
|
|||||||
kind: RawMaterialKind,
|
kind: RawMaterialKind,
|
||||||
remainingGrams: number,
|
remainingGrams: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
const rule = await this.prisma.automationRule.findUnique({
|
||||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||||
@@ -369,23 +410,28 @@ export class AutomationService {
|
|||||||
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
|
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
|
||||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async evaluateTreatment(treatment: Treatment): Promise<void> {
|
async evaluateTreatment(treatment: Treatment): Promise<void> {
|
||||||
await this.evaluateFollowUp(
|
await this.evaluateAfterCommit('Treatment', treatment.id, () =>
|
||||||
|
this.evaluateFollowUp(
|
||||||
'TREATMENT_FOLLOW_UP',
|
'TREATMENT_FOLLOW_UP',
|
||||||
treatment.colonyId,
|
treatment.colonyId,
|
||||||
treatment.endDate ?? treatment.date,
|
treatment.endDate ?? treatment.date,
|
||||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(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, () =>
|
||||||
|
this.evaluateFollowUp(
|
||||||
'FEEDING_FOLLOW_UP',
|
'FEEDING_FOLLOW_UP',
|
||||||
feeding.colonyId,
|
feeding.colonyId,
|
||||||
feeding.date,
|
feeding.date,
|
||||||
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
|
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,7 +567,11 @@ 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({
|
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||||
|
const config = rule.config as { dueInDays?: unknown } | null;
|
||||||
|
const task = await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
|
||||||
|
const alreadyOpen = await tx.task.findFirst({
|
||||||
where: {
|
where: {
|
||||||
sourceAutomationTrigger: rule.triggerType,
|
sourceAutomationTrigger: rule.triggerType,
|
||||||
relatedType,
|
relatedType,
|
||||||
@@ -525,12 +581,9 @@ export class AutomationService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (alreadyOpen) {
|
if (alreadyOpen) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
return tx.task.create({
|
||||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
|
||||||
const config = rule.config as { dueInDays?: unknown } | null;
|
|
||||||
const task = await this.prisma.task.create({
|
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
@@ -542,6 +595,10 @@ export class AutomationService {
|
|||||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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' },
|
|
||||||
data: {
|
|
||||||
status: 'SYNCED',
|
status: 'SYNCED',
|
||||||
graphEventId: 'graph-event-1',
|
graphEventId: 'graph-event-1',
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to creating a fresh event when updating a stale graphEventId 404s', async () => {
|
it.each([404, 410])(
|
||||||
|
'falls back to creating a fresh event when the stored event is gone (%i)',
|
||||||
|
async (status) => {
|
||||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||||
{
|
pendingEntry({ graphEventId: 'deleted-on-m365' }),
|
||||||
id: 'entry-1',
|
|
||||||
sourceType: 'TASK',
|
|
||||||
sourceId: 'task-1',
|
|
||||||
graphEventId: 'deleted-on-m365',
|
|
||||||
attempts: 0,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
prisma.task.findUnique.mockResolvedValue({
|
prisma.task.findUnique.mockResolvedValue(dueTask);
|
||||||
id: 'task-1',
|
graphClient.updateEvent.mockRejectedValue(
|
||||||
title: 'Beute prüfen',
|
new GraphRequestError('updateEvent', status, 'gone'),
|
||||||
dueDate: new Date('2026-03-07'),
|
);
|
||||||
});
|
|
||||||
graphClient.updateEvent.mockRejectedValue(new Error('404 Not Found'));
|
|
||||||
graphClient.createEvent.mockResolvedValue('graph-event-new');
|
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,11 +552,225 @@ 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getStatus', () => {
|
describe('getStatus', () => {
|
||||||
@@ -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,15 +108,37 @@ 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> {
|
||||||
|
await this.afterCommit(
|
||||||
|
'Calendar sync enqueue',
|
||||||
|
sourceType,
|
||||||
|
sourceId,
|
||||||
|
async () => {
|
||||||
const settings = await this.settingsService.get();
|
const settings = await this.settingsService.get();
|
||||||
if (!settings.enabled) {
|
if (!settings.enabled) {
|
||||||
return;
|
return;
|
||||||
@@ -77,8 +149,15 @@ export class CalendarSyncService {
|
|||||||
await this.prisma.calendarSyncOutboxEntry.upsert({
|
await this.prisma.calendarSyncOutboxEntry.upsert({
|
||||||
where: { sourceType_sourceId: { sourceType, sourceId } },
|
where: { sourceType_sourceId: { sourceType, sourceId } },
|
||||||
create: { sourceType, sourceId },
|
create: { sourceType, sourceId },
|
||||||
update: { status: 'PENDING', attempts: 0, lastError: null },
|
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,6 +168,11 @@ export class CalendarSyncService {
|
|||||||
sourceType: CalendarSyncSourceType,
|
sourceType: CalendarSyncSourceType,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await this.afterCommit(
|
||||||
|
'Calendar sync cancel',
|
||||||
|
sourceType,
|
||||||
|
sourceId,
|
||||||
|
async () => {
|
||||||
const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({
|
const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({
|
||||||
where: { sourceType_sourceId: { sourceType, sourceId } },
|
where: { sourceType_sourceId: { sourceType, sourceId } },
|
||||||
});
|
});
|
||||||
@@ -96,31 +180,48 @@ export class CalendarSyncService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (entry.graphEventId) {
|
if (entry.graphEventId) {
|
||||||
const settings = await this.settingsService.get();
|
await this.deleteGraphEvent(entry.graphEventId);
|
||||||
if (
|
|
||||||
settings.enabled &&
|
|
||||||
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)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await this.prisma.calendarSyncOutboxEntry.update({
|
await this.prisma.calendarSyncOutboxEntry.update({
|
||||||
where: { id: entry.id },
|
where: { id: entry.id },
|
||||||
data: { status: 'SKIPPED', graphEventId: null },
|
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 },
|
|
||||||
data: {
|
|
||||||
status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING',
|
status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING',
|
||||||
attempts,
|
attempts,
|
||||||
lastError: getErrorMessage(error),
|
// Stored and shown to admins: Prisma errors by name/code/model only.
|
||||||
},
|
lastError: loggableError(error).message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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,7 +523,8 @@ 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] =
|
||||||
|
await Promise.all([
|
||||||
this.prisma.calendarSyncOutboxEntry.count({
|
this.prisma.calendarSyncOutboxEntry.count({
|
||||||
where: { status: 'PENDING' },
|
where: { status: 'PENDING' },
|
||||||
}),
|
}),
|
||||||
@@ -289,6 +534,10 @@ export class CalendarSyncService {
|
|||||||
this.prisma.calendarSyncOutboxEntry.count({
|
this.prisma.calendarSyncOutboxEntry.count({
|
||||||
where: { status: 'SYNCED' },
|
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(),
|
||||||
@@ -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',
|
method: 'PATCH',
|
||||||
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)),
|
||||||
},
|
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',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
},
|
signal: timeoutSignal(),
|
||||||
);
|
});
|
||||||
// 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;
|
||||||
|
|
||||||
let nextDueDate: Date | undefined;
|
// The recurrence that is validated is exactly the one written: an
|
||||||
if (recurrenceChanged) {
|
// omitted field keeps its stored value, null clears it.
|
||||||
const recurrence = toRecurrenceInput({
|
const recurrence = recurrenceChanged
|
||||||
|
? {
|
||||||
recurrenceType: dto.recurrenceType ?? before.recurrenceType,
|
recurrenceType: dto.recurrenceType ?? before.recurrenceType,
|
||||||
recurrenceMonth: dto.recurrenceMonth ?? before.recurrenceMonth,
|
recurrenceMonth: valueOrStored(
|
||||||
recurrenceDayOfMonth:
|
dto.recurrenceMonth,
|
||||||
dto.recurrenceDayOfMonth ?? before.recurrenceDayOfMonth,
|
before.recurrenceMonth,
|
||||||
intervalDays: dto.intervalDays ?? before.intervalDays,
|
),
|
||||||
});
|
recurrenceDayOfMonth: valueOrStored(
|
||||||
|
dto.recurrenceDayOfMonth,
|
||||||
|
before.recurrenceDayOfMonth,
|
||||||
|
),
|
||||||
|
intervalDays: valueOrStored(dto.intervalDays, before.intervalDays),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
let nextDueDate: Date | undefined;
|
||||||
|
if (recurrence) {
|
||||||
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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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
Reference in new issue
Block a user