diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d07a9bc..ea2ef2f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. 7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. 8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. -9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. 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. +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 – 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. -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. +11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`). +12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. 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. 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. 18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"|"` (`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 @@ -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: -- **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. -- **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. diff --git a/ApiCore-Backend/Dockerfile b/ApiCore-Backend/Dockerfile index 56b3cc4..2227ca9 100644 --- a/ApiCore-Backend/Dockerfile +++ b/ApiCore-Backend/Dockerfile @@ -6,6 +6,12 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . 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 FROM node:22-alpine AS runtime diff --git a/ApiCore-Backend/prisma/migrations/20261012100000_calendar_sync_claims/migration.sql b/ApiCore-Backend/prisma/migrations/20261012100000_calendar_sync_claims/migration.sql new file mode 100644 index 0000000..a3855f1 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261012100000_calendar_sync_claims/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 1d474e0..1e4eb5e 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -925,7 +925,9 @@ model HoneyBatchContainerAllocation { createdAt DateTime @default(now()) 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]) } @@ -1505,6 +1507,14 @@ model CalendarSyncOutboxEntry { graphEventId String? attempts Int @default(0) 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()) updatedAt DateTime @updatedAt diff --git a/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts b/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts index 5afa1a8..c60b9f6 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts @@ -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 () => { prisma.apiary.findUnique.mockResolvedValue(existingApiary); prisma.colony.count.mockResolvedValue(2); diff --git a/ApiCore-Backend/src/apiaries/apiaries.service.ts b/ApiCore-Backend/src/apiaries/apiaries.service.ts index f838a0c..6d39b64 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.service.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; import { Apiary, ApiaryStatus } from '../../generated/prisma'; import { CreateApiaryDto } from './dto/create-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 // colonies elsewhere or dissolve them first instead. const activeColonyCount = await this.prisma.colony.count({ - where: { apiaryId: id, status: 'ACTIVE' }, + where: { apiaryId: id, ...LIVING_COLONY_WHERE }, }); if (activeColonyCount > 0) { throw new ConflictException( diff --git a/ApiCore-Backend/src/apiaries/dto/update-apiary.dto.ts b/ApiCore-Backend/src/apiaries/dto/update-apiary.dto.ts index 9f8c938..e2c8212 100644 --- a/ApiCore-Backend/src/apiaries/dto/update-apiary.dto.ts +++ b/ApiCore-Backend/src/apiaries/dto/update-apiary.dto.ts @@ -1,4 +1,6 @@ import { PartialType } from '@nestjs/swagger'; import { CreateApiaryDto } from './create-apiary.dto'; -export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {} +export class UpdateApiaryDto extends PartialType(CreateApiaryDto, { + skipNullProperties: false, +}) {} diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index d23ece1..3fd7109 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -1,10 +1,10 @@ -import { NotFoundException } from '@nestjs/common'; +import { Logger, NotFoundException } from '@nestjs/common'; import { AutomationService } from './automation.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { NotificationsService } from '../notifications/notifications.service'; import { WebhooksService } from '../webhooks/webhooks.service'; -import { AutomationTriggerType } from '../../generated/prisma'; +import { AutomationTriggerType, Prisma } from '../../generated/prisma'; describe('AutomationService', () => { let service: AutomationService; @@ -17,6 +17,8 @@ describe('AutomationService', () => { }; automationSequence: { upsert: jest.Mock }; task: { create: jest.Mock; findFirst: jest.Mock }; + $transaction: jest.Mock; + $queryRaw: jest.Mock; }; let auditLog: { record: jest.Mock }; let notifications: { record: jest.Mock }; @@ -57,6 +59,9 @@ describe('AutomationService', () => { create: jest.fn().mockResolvedValue({ id: 'task-1' }), 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() }; notifications = { record: jest.fn() }; @@ -91,6 +96,122 @@ describe('AutomationService', () => { 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', () => { it('creates a task and notification when queen not seen and no eggs present', async () => { prisma.automationRule.findMany.mockResolvedValue([queenlessRule]); @@ -238,6 +359,37 @@ describe('AutomationService', () => { 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', () => { @@ -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', () => { diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 713e75b..30951c0 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -1,5 +1,6 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { runAfterCommit } from '../common/after-commit'; import { AuditLogService } from '../audit/audit-log.service'; import { NotificationsService } from '../notifications/notifications.service'; import { WebhooksService } from '../webhooks/webhooks.service'; @@ -164,6 +165,8 @@ function dueDateFromConfig( @Injectable() export class AutomationService { + private readonly logger = new Logger(AutomationService.name); + constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, @@ -222,7 +225,12 @@ export class AutomationService { where: { id }, data: { ...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({ @@ -236,109 +244,141 @@ export class AutomationService { return rule; } + /** + * Every evaluate*() runs after the triggering record is committed and is + * called by the domain services without a guard - so it never throws (see + * runAfterCommit). A failed evaluation is logged, the request succeeds. + */ + private evaluateAfterCommit( + entityType: string, + entityId: string, + evaluate: () => Promise, + ): Promise { + return runAfterCommit( + this.logger, + { action: 'Automation rule evaluation', entityType, entityId }, + evaluate, + ); + } + async evaluateInspection(inspection: Inspection): Promise { - await this.ensureDefaults(); - const rules = await this.prisma.automationRule.findMany({ - where: { - triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] }, - active: true, - }, + await this.evaluateAfterCommit('Inspection', inspection.id, async () => { + await this.ensureDefaults(); + const rules = await this.prisma.automationRule.findMany({ + where: { + triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] }, + active: true, + }, + }); + const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule])); + + const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS'); + if ( + queenlessRule && + inspection.queenSeen === false && + inspection.eggsPresent === false + ) { + await this.triggerAction( + queenlessRule, + 'COLONY', + inspection.colonyId, + inspection.id, + ); + } + + const feedRule = ruleByType.get('FEED_STATUS_CRITICAL'); + if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') { + await this.triggerAction( + feedRule, + 'COLONY', + inspection.colonyId, + inspection.id, + ); + } }); - const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule])); - - const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS'); - if ( - queenlessRule && - inspection.queenSeen === false && - inspection.eggsPresent === false - ) { - await this.triggerAction( - queenlessRule, - 'COLONY', - inspection.colonyId, - inspection.id, - ); - } - - const feedRule = ruleByType.get('FEED_STATUS_CRITICAL'); - if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') { - await this.triggerAction( - feedRule, - 'COLONY', - inspection.colonyId, - inspection.id, - ); - } } async evaluateVarroaMeasurement( measurement: VarroaMeasurement, ): Promise { - await this.ensureDefaults(); - const rule = await this.prisma.automationRule.findUnique({ - where: { triggerType: 'VARROA_THRESHOLD' }, - }); - if (!rule || !rule.active) { - return; - } - // The threshold is a natural mite fall rate (mites per day). Only that - // method yields a value in this unit - a mite count from a sugar roll or - // wash is per bee sample and would be compared against the wrong scale, - // as would a raw board count without the number of days it covered. - if (measurement.method !== 'NATURAL_MITE_FALL') { - return; - } - const config = rule.config as { thresholdMitesPerDay?: number } | null; - const threshold = config?.thresholdMitesPerDay ?? 10; - const value = measurement.calculatedValue; - if (value !== null && value >= threshold) { - await this.triggerAction(rule, 'COLONY', measurement.colonyId); - } + await this.evaluateAfterCommit( + 'VarroaMeasurement', + measurement.id, + async () => { + await this.ensureDefaults(); + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'VARROA_THRESHOLD' }, + }); + if (!rule || !rule.active) { + return; + } + // The threshold is a natural mite fall rate (mites per day). Only + // that method yields a value in this unit - a mite count from a + // sugar roll or wash is per bee sample and would be compared against + // the wrong scale, as would a raw board count without the number of + // days it covered. + if (measurement.method !== 'NATURAL_MITE_FALL') { + return; + } + const config = rule.config as { thresholdMitesPerDay?: number } | null; + const threshold = config?.thresholdMitesPerDay ?? 10; + const value = measurement.calculatedValue; + if (value !== null && value >= threshold) { + await this.triggerAction(rule, 'COLONY', measurement.colonyId); + } + }, + ); } async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise { - await this.ensureDefaults(); - const rule = await this.prisma.automationRule.findUnique({ - where: { triggerType: 'SWARM_MOOD_HIGH' }, + await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => { + await this.ensureDefaults(); + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'SWARM_MOOD_HIGH' }, + }); + if (!rule || !rule.active) { + return; + } + if (check.swarmMood === 'HIGH') { + await this.triggerAction(rule, 'COLONY', check.colonyId); + } }); - if (!rule || !rule.active) { - return; - } - if (check.swarmMood === 'HIGH') { - await this.triggerAction(rule, 'COLONY', check.colonyId); - } } /** Fires only on the transition into CRITICAL, not on every edit while already critical. */ async evaluateColonyHealth(before: Colony, after: Colony): Promise { - await this.ensureDefaults(); - if ( - after.healthStatus !== 'CRITICAL' || - before.healthStatus === 'CRITICAL' - ) { - return; - } - const rule = await this.prisma.automationRule.findUnique({ - where: { triggerType: 'COLONY_HEALTH_CRITICAL' }, + await this.evaluateAfterCommit('Colony', after.id, async () => { + await this.ensureDefaults(); + if ( + after.healthStatus !== 'CRITICAL' || + before.healthStatus === 'CRITICAL' + ) { + return; + } + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'COLONY_HEALTH_CRITICAL' }, + }); + if (!rule || !rule.active) { + return; + } + await this.triggerAction(rule, 'COLONY', after.id); }); - if (!rule || !rule.active) { - return; - } - await this.triggerAction(rule, 'COLONY', after.id); } async evaluateStockItem(item: StockItem): Promise { - await this.ensureDefaults(); - if (item.minThreshold === null || item.quantity >= item.minThreshold) { - return; - } - const rule = await this.prisma.automationRule.findUnique({ - where: { triggerType: 'STOCK_ITEM_LOW' }, + await this.evaluateAfterCommit('StockItem', item.id, async () => { + await this.ensureDefaults(); + if (item.minThreshold === null || item.quantity >= item.minThreshold) { + return; + } + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'STOCK_ITEM_LOW' }, + }); + if (!rule || !rule.active) { + return; + } + await this.triggerAction(rule, 'STORAGE', item.id); }); - if (!rule || !rule.active) { - return; - } - await this.triggerAction(rule, 'STORAGE', item.id); } /** @@ -352,40 +392,46 @@ export class AutomationService { kind: RawMaterialKind, remainingGrams: number, ): Promise { - await this.ensureDefaults(); - const rule = await this.prisma.automationRule.findUnique({ - where: { triggerType: 'RAW_MATERIAL_LOW' }, - }); - if (!rule || !rule.active) { - return; - } - const config = rule.config as { - minGrams?: Partial>; - } | null; - const minGrams = config?.minGrams?.[kind]; - if (typeof minGrams !== 'number' || remainingGrams >= minGrams) { - return; - } - await this.triggerAction(rule, 'GENERAL', kind, undefined, { - description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`, + await this.evaluateAfterCommit('RawMaterialKind', kind, async () => { + await this.ensureDefaults(); + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'RAW_MATERIAL_LOW' }, + }); + if (!rule || !rule.active) { + return; + } + const config = rule.config as { + minGrams?: Partial>; + } | null; + const minGrams = config?.minGrams?.[kind]; + if (typeof minGrams !== 'number' || remainingGrams >= minGrams) { + return; + } + await this.triggerAction(rule, 'GENERAL', kind, undefined, { + description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`, + }); }); } async evaluateTreatment(treatment: Treatment): Promise { - await this.evaluateFollowUp( - 'TREATMENT_FOLLOW_UP', - treatment.colonyId, - treatment.endDate ?? treatment.date, - `Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`, + await this.evaluateAfterCommit('Treatment', treatment.id, () => + this.evaluateFollowUp( + 'TREATMENT_FOLLOW_UP', + treatment.colonyId, + treatment.endDate ?? treatment.date, + `Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`, + ), ); } async evaluateFeeding(feeding: Feeding): Promise { - await this.evaluateFollowUp( - 'FEEDING_FOLLOW_UP', - feeding.colonyId, - feeding.date, - `Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`, + await this.evaluateAfterCommit('Feeding', feeding.id, () => + this.evaluateFollowUp( + 'FEEDING_FOLLOW_UP', + feeding.colonyId, + feeding.date, + `Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`, + ), ); } @@ -507,6 +553,12 @@ export class AutomationService { * stock item or the check task for a colony is still open, further triggers * (the next removal below minimum, a second high varroa count) add nothing. * Repeating the same alarm would only bury the one task that matters. + * + * Check and create run in one transaction under a lock on the rule's row: + * two evaluations at the same moment (parallel requests, several pods) + * would otherwise both see "no open task" and both create one. The lock + * is per rule and held for two short statements; webhook, warning and + * audit entry follow after the commit. */ private async triggerAction( rule: AutomationRule, @@ -515,33 +567,38 @@ export class AutomationService { sourceInspectionId?: string, options: { baseDate?: Date; description?: string } = {}, ): Promise { - const alreadyOpen = await this.prisma.task.findFirst({ - where: { - sourceAutomationTrigger: rule.triggerType, - relatedType, - relatedId, - status: { in: ['OPEN', 'IN_PROGRESS'] }, - }, - select: { id: true }, - }); - if (alreadyOpen) { - return; - } - const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; const config = rule.config as { dueInDays?: unknown } | null; - const task = await this.prisma.task.create({ - data: { - title, - priority: 'HIGH', - relatedType, - relatedId, - sourceInspectionId, - sourceAutomationTrigger: rule.triggerType, - description: options.description, - dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate), - }, + const task = await this.prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`; + const alreadyOpen = await tx.task.findFirst({ + where: { + sourceAutomationTrigger: rule.triggerType, + relatedType, + relatedId, + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + select: { id: true }, + }); + if (alreadyOpen) { + return null; + } + return tx.task.create({ + data: { + title, + priority: 'HIGH', + relatedType, + relatedId, + sourceInspectionId, + sourceAutomationTrigger: rule.triggerType, + description: options.description, + dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate), + }, + }); }); + if (!task) { + return; + } await this.webhooks.dispatch('task.created', task); await this.notifications.record({ priority: 'WARNUNG', diff --git a/ApiCore-Backend/src/automation/dto/update-automation-rule.dto.ts b/ApiCore-Backend/src/automation/dto/update-automation-rule.dto.ts index 9dba527..cbabaf2 100644 --- a/ApiCore-Backend/src/automation/dto/update-automation-rule.dto.ts +++ b/ApiCore-Backend/src/automation/dto/update-automation-rule.dto.ts @@ -1,9 +1,10 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class UpdateAutomationRuleDto { @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() active?: boolean; diff --git a/ApiCore-Backend/src/backup/backup-import-limit.spec.ts b/ApiCore-Backend/src/backup/backup-import-limit.spec.ts new file mode 100644 index 0000000..3f4b439 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-import-limit.spec.ts @@ -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); + }); +}); diff --git a/ApiCore-Backend/src/backup/backup-import-limit.ts b/ApiCore-Backend/src/backup/backup-import-limit.ts new file mode 100644 index 0000000..6aa2d05 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-import-limit.ts @@ -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`; +} diff --git a/ApiCore-Backend/src/backup/backup-scheduler.service.spec.ts b/ApiCore-Backend/src/backup/backup-scheduler.service.spec.ts new file mode 100644 index 0000000..a5b918f --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-scheduler.service.spec.ts @@ -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(); + }); + }); +}); diff --git a/ApiCore-Backend/src/backup/backup-scheduler.service.ts b/ApiCore-Backend/src/backup/backup-scheduler.service.ts index eab67ef..5ab1aa8 100644 --- a/ApiCore-Backend/src/backup/backup-scheduler.service.ts +++ b/ApiCore-Backend/src/backup/backup-scheduler.service.ts @@ -5,10 +5,16 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { BackupService } from './backup.service'; import { BackupSettingsService } from './backup-settings.service'; +import { loggableError } from '../common/loggable-error'; import { assertBackupCron, resolveGuiBackupPath, } from './backup-schedule-rules'; +import { + backupMaxImportBytes, + exceedsBackupImportLimit, + formatMegabytes, +} from './backup-import-limit'; const SCHEDULED_JOB_NAME = 'automatic-backup'; const DEFAULT_CRON_SCHEDULE = '0 3 * * *'; @@ -28,6 +34,13 @@ export interface BackupScheduleStatus { cronSchedule: string | null; retentionCount: number | 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 // editable via the GUI form) - env always wins over the GUI-stored value. envOverrides: { @@ -125,9 +138,11 @@ export class BackupSchedulerService implements OnModuleInit { const job = CronJob.from({ cronTime: settings.cronSchedule, onTick: () => { - this.runScheduled(directory).catch((error) => - this.logger.error('Scheduled backup failed', error), - ); + // Prisma errors by name/code/model only (see loggableError). + this.runScheduled(directory).catch((error: unknown) => { + const { message, stack } = loggableError(error); + this.logger.error(`Scheduled backup failed: ${message}`, stack); + }); }, start: true, }); @@ -139,6 +154,7 @@ export class BackupSchedulerService implements OnModuleInit { async getStatus(): Promise { const settings = await this.effectiveSettings(); + const importLimitBytes = backupMaxImportBytes(); if (!settings.path) { return { enabled: false, @@ -146,12 +162,16 @@ export class BackupSchedulerService implements OnModuleInit { cronSchedule: null, retentionCount: null, lastRunAt: null, + importLimitBytes, + lastBackupSizeBytes: null, + lastBackupExceedsImportLimit: false, envOverrides: settings.envOverrides, }; } const files = await this.listBackupFiles(settings.path); const lastRunAt = files[0]?.mtime.toISOString() ?? null; + const lastBackupSizeBytes = files[0]?.size ?? null; return { enabled: true, @@ -159,6 +179,11 @@ export class BackupSchedulerService implements OnModuleInit { cronSchedule: settings.cronSchedule, retentionCount: settings.retentionCount, lastRunAt, + importLimitBytes, + lastBackupSizeBytes, + lastBackupExceedsImportLimit: + lastBackupSizeBytes !== null && + exceedsBackupImportLimit(lastBackupSizeBytes), envOverrides: settings.envOverrides, }; } @@ -205,8 +230,17 @@ export class BackupSchedulerService implements OnModuleInit { directory, `${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`, ); + const content = JSON.stringify(sealed); // 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; } @@ -231,7 +265,7 @@ export class BackupSchedulerService implements OnModuleInit { private async listBackupFiles( directory: string, - ): Promise<{ filePath: string; mtime: Date }[]> { + ): Promise<{ filePath: string; mtime: Date; size: number }[]> { let entries: string[]; try { entries = await fs.readdir(directory); @@ -249,7 +283,7 @@ export class BackupSchedulerService implements OnModuleInit { backupFiles.map(async (name) => { const filePath = path.join(directory, name); const stat = await fs.stat(filePath); - return { filePath, mtime: stat.mtime }; + return { filePath, mtime: stat.mtime, size: stat.size }; }), ); diff --git a/ApiCore-Backend/src/backup/backup.controller.spec.ts b/ApiCore-Backend/src/backup/backup.controller.spec.ts new file mode 100644 index 0000000..d8ad956 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup.controller.spec.ts @@ -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(); + }); + }); +}); diff --git a/ApiCore-Backend/src/backup/backup.controller.ts b/ApiCore-Backend/src/backup/backup.controller.ts index d2d8c23..5461e0b 100644 --- a/ApiCore-Backend/src/backup/backup.controller.ts +++ b/ApiCore-Backend/src/backup/backup.controller.ts @@ -3,6 +3,7 @@ import { Body, Controller, Get, + Logger, Patch, Post, Res, @@ -25,17 +26,18 @@ import { } from './backup-scheduler.service'; import { BackupSettingsService } from './backup-settings.service'; import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto'; - -// A full backup bundle can be much larger than the 5mb CSV import cap -// elsewhere (dozens of tables, potentially years of records, plus embedded -// label-template images) - 50mb comfortably covers a real dataset without -// raising the app-wide JSON body limit for every other route. -const MAX_BACKUP_FILE_SIZE_BYTES = 50 * 1024 * 1024; +import { + backupMaxImportBytes, + exceedsBackupImportLimit, + formatMegabytes, +} from './backup-import-limit'; @ApiTags('backup') @ApiBearerAuth() @Controller('backup') export class BackupController { + private readonly logger = new Logger(BackupController.name); + constructor( private readonly backupService: BackupService, private readonly backupSchedulerService: BackupSchedulerService, @@ -76,19 +78,29 @@ export class BackupController { @Get('export') async export(@Res() res: Response): Promise { 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`; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Cache-Control', 'no-store'); - res.send(JSON.stringify(sealed)); + res.send(body); } @RequirePermission('backup:import') @Post('import') @ApiConsumes('multipart/form-data') @UseInterceptors( + // Size limit from MulterModule in BackupModule (BACKUP_MAX_IMPORT_BYTES). FileInterceptor('file', { - limits: { fileSize: MAX_BACKUP_FILE_SIZE_BYTES }, fileFilter: (_req, file, callback) => { if (!file.originalname.toLowerCase().endsWith('.json')) { callback( @@ -117,6 +129,9 @@ export class BackupController { } catch { 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; } } diff --git a/ApiCore-Backend/src/backup/backup.module.ts b/ApiCore-Backend/src/backup/backup.module.ts index 89eca13..540df2b 100644 --- a/ApiCore-Backend/src/backup/backup.module.ts +++ b/ApiCore-Backend/src/backup/backup.module.ts @@ -1,12 +1,22 @@ import { Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; import { ReauthModule } from '../auth/reauth/reauth.module'; import { BackupController } from './backup.controller'; import { BackupService } from './backup.service'; import { BackupSchedulerService } from './backup-scheduler.service'; import { BackupSettingsService } from './backup-settings.service'; +import { backupMaxImportBytes } from './backup-import-limit'; @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], providers: [BackupService, BackupSchedulerService, BackupSettingsService], }) diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index e301e32..8591fda 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -1,5 +1,11 @@ -import { BadRequestException } from '@nestjs/common'; -import { BackupService } from './backup.service'; +import { BadRequestException, Logger } from '@nestjs/common'; +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 { AuditLogService } from '../audit/audit-log.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; @@ -8,6 +14,7 @@ jest.mock('node:fs/promises', () => ({ readFile: jest.fn(), writeFile: jest.fn(), mkdir: jest.fn(), + unlink: jest.fn(), })); import * as fs from 'node:fs/promises'; @@ -36,15 +43,25 @@ const BUSINESS_ENTITY_KEYS = [ 'honeyBatch', 'honeyMeasurement', 'honeyBatchRelocation', + 'honeyBatchContainerAllocation', 'stockItem', 'stockMovement', 'product', 'labelTemplate', 'bottlingBatch', + 'rawMaterialLot', + 'productionBatch', + 'productionInput', + 'shopStockRequest', + 'batchMovement', 'equipment', 'webhookSubscription', 'automationRule', + 'automationSequence', 'siteSettings', + 'backupSettings', + 'calendarSyncSettings', + 'calendarSyncOutboxEntry', ] as const; const ALL_ENTITY_KEYS = [ @@ -57,17 +74,30 @@ const ALL_ENTITY_KEYS = [ 'auditLog', ] 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; + describe('BackupService', () => { let service: BackupService; let prisma: Record & { user: { findMany: jest.Mock }; rolePermission: { findMany: jest.Mock }; - document: { findMany: jest.Mock }; + document: { findMany: jest.Mock; count: jest.Mock }; $transaction: jest.Mock; }; let tx: Record & { permission: { findUnique: 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 permissionResolver: { invalidateAll: jest.Mock }; @@ -77,20 +107,14 @@ describe('BackupService', () => { jest.clearAllMocks(); tx = {} as never; - for (const key of [ - ...BUSINESS_ENTITY_KEYS, - 'role', - 'user', - 'rolePermission', - 'document', - 'notification', - 'auditLog', - ]) { + for (const key of MODEL_KEYS) { tx[key] = { upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }), }; } tx.queen.update = jest.fn().mockResolvedValue({}); + tx.queen.findMany = jest.fn().mockResolvedValue([]); + tx.document.findUnique = jest.fn().mockResolvedValue(null); tx.permission = { findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }), }; @@ -100,23 +124,14 @@ describe('BackupService', () => { .mockResolvedValue([]); prisma = { - user: { - findMany: jest.fn().mockResolvedValue([]), - }, - rolePermission: { findMany: jest.fn().mockResolvedValue([]) }, - document: { findMany: jest.fn().mockResolvedValue([]) }, $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => callback(tx), ), } as never; - for (const key of [ - 'role', - ...BUSINESS_ENTITY_KEYS, - 'notification', - 'auditLog', - ]) { + for (const key of MODEL_KEYS) { prisma[key] = { findMany: jest.fn().mockResolvedValue([]) }; } + prisma.document.count = jest.fn().mockResolvedValue(0); auditLog = { record: jest.fn().mockResolvedValue(undefined) }; @@ -129,10 +144,10 @@ describe('BackupService', () => { }); 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(); - expect(bundle.version).toBe(2); + expect(bundle.version).toBe(3); expect(typeof bundle.exportedAt).toBe('string'); for (const key of ALL_ENTITY_KEYS) { expect(bundle.data).toHaveProperty(key); @@ -589,5 +604,567 @@ describe('BackupService', () => { 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; + create: Record; + }, + ][] + )[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 }], + ]; + 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 }], + ]; + 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 }], + ]; + 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 = { + 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(), + ); + }); }); }); diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index b86c54d..6d7dd23 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -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 fs from 'node:fs/promises'; import * as path from 'node:path'; @@ -10,7 +10,7 @@ import { } from '../documents/document-mime-types'; import { matchesFileSignature } from '../common/file-signature'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; -import { DocumentEntityType } from '../../generated/prisma'; +import { DocumentEntityType, Prisma } from '../../generated/prisma'; import { SealedBackup, isSealedBackup, @@ -18,7 +18,10 @@ import { sealBackup, } 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; export interface BackupBundle { @@ -30,34 +33,107 @@ export interface BackupBundle { export interface BackupImportSummary { imported: Record; /** - * Tables left out because the file was an old, unsealed (v2) backup: its - * contents cannot be proven to come from ApiCore, so nothing that grants - * access (users, roles, webhooks) or rewrites history (audit log) is - * taken from it. + * Tables the file carried but an unsealed backup may not write - every + * table outside LEGACY_UNSEALED_TABLES. */ 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 = [ - 'role', - 'user', - 'rolePermission', - 'webhookSubscription', - 'auditLog', +/** + * All an unsealed file may write. Its contents cannot be proven to come from + * ApiCore (it may be hand-built), so it is limited to the plain beekeeping + * master and field records of the old unsealed (v2) format - records an + * admin could just as well type in again - and it may only add missing + * rows, never rewrite existing ones. + * + * 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; +// 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( + 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> +> = { + 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( Object.values(DocumentEntityType), ); -// Excluded on purpose: -// - 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). +// Excluded on purpose: see MODELS_EXCLUDED_FROM_BACKUP. // // 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 @@ -84,6 +160,8 @@ function documentsStorageRoot(): string { @Injectable() export class BackupService { + private readonly logger = new Logger(BackupService.name); + constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, @@ -98,7 +176,7 @@ export class BackupService { /** * 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( parsed: unknown, @@ -162,15 +240,28 @@ export class BackupService { data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany(); data.honeyBatchRelocation = await this.prisma.honeyBatchRelocation.findMany(); + data.honeyBatchContainerAllocation = + await this.prisma.honeyBatchContainerAllocation.findMany(); data.stockItem = await this.prisma.stockItem.findMany(); data.stockMovement = await this.prisma.stockMovement.findMany(); data.product = await this.prisma.product.findMany(); data.labelTemplate = await this.prisma.labelTemplate.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.webhookSubscription = await this.prisma.webhookSubscription.findMany(); data.automationRule = await this.prisma.automationRule.findMany(); + data.automationSequence = await this.prisma.automationSequence.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(); data.document = await Promise.all( @@ -213,21 +304,32 @@ export class BackupService { throw new BadRequestException('Invalid backup file'); } - const isTrustedOnly = (key: string): boolean => - (TRUSTED_ONLY_TABLES as readonly string[]).includes(key); + const isAllowedUnsealed = (key: string): boolean => + (LEGACY_UNSEALED_TABLES as readonly string[]).includes(key); const skipped = trusted ? [] - : TRUSTED_ONLY_TABLES.filter((key) => { + : Object.keys(bundle.data).filter((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[] => { - if (!trusted && isTrustedOnly(key)) { + if (!trusted && !isAllowedUnsealed(key)) { return []; } const value = bundle.data[key]; 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 = {}; @@ -324,11 +426,26 @@ export class BackupService { await tx.apiary.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } 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( + 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 // before her mother in export order) - patched in a second pass // below once every queen row exists. @@ -337,7 +454,7 @@ export class BackupService { await tx.queen.upsert({ where: { id: row.id as string }, create: withoutMother as never, - update: withoutMother as never, + update: updateWith(withoutMother) as never, }); } imported.queen = rowsOf('queen').length; @@ -346,13 +463,13 @@ export class BackupService { await tx.colony.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.colony = rowsOf('colony').length; for (const row of rowsOf('queen')) { - if (row.motherQueenId) { + if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) { await tx.queen.update({ where: { id: row.id as string }, data: { motherQueenId: row.motherQueenId as string }, @@ -369,12 +486,12 @@ export class BackupService { asString(row, 'movedByUserId'), ), } as never, - update: { + update: updateWith({ ...row, movedByUserId: resolveOptionalUserId( asString(row, 'movedByUserId'), ), - } as never, + }) as never, }); } imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length; @@ -383,7 +500,7 @@ export class BackupService { await tx.colonyLineage.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.colonyLineage = rowsOf('colonyLineage').length; @@ -395,10 +512,10 @@ export class BackupService { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveOptionalUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.queenEvaluation = rowsOf('queenEvaluation').length; @@ -407,7 +524,7 @@ export class BackupService { await tx.queenColonyHistory.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.queenColonyHistory = rowsOf('queenColonyHistory').length; @@ -419,10 +536,10 @@ export class BackupService { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.inspection = rowsOf('inspection').length; @@ -439,7 +556,7 @@ export class BackupService { asString(row, 'createdByUserId'), ), } as never, - update: { + update: updateWith({ ...row, assignedUserId: resolveOptionalUserId( asString(row, 'assignedUserId'), @@ -447,7 +564,7 @@ export class BackupService { createdByUserId: resolveOptionalUserId( asString(row, 'createdByUserId'), ), - } as never, + }) as never, }); } imported.taskTemplate = rowsOf('taskTemplate').length; @@ -464,7 +581,7 @@ export class BackupService { asString(row, 'createdByUserId'), ), } as never, - update: { + update: updateWith({ ...row, assignedUserId: resolveOptionalUserId( asString(row, 'assignedUserId'), @@ -472,7 +589,7 @@ export class BackupService { createdByUserId: resolveOptionalUserId( asString(row, 'createdByUserId'), ), - } as never, + }) as never, }); } imported.task = rowsOf('task').length; @@ -484,10 +601,10 @@ export class BackupService { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.varroaMeasurement = rowsOf('varroaMeasurement').length; @@ -499,10 +616,10 @@ export class BackupService { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.swarmControlCheck = rowsOf('swarmControlCheck').length; @@ -514,10 +631,10 @@ export class BackupService { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.winterCheck = rowsOf('winterCheck').length; @@ -529,10 +646,10 @@ export class BackupService { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.treatment = rowsOf('treatment').length; @@ -544,10 +661,10 @@ export class BackupService { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.feeding = rowsOf('feeding').length; @@ -559,10 +676,10 @@ export class BackupService { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.harvest = rowsOf('harvest').length; @@ -571,7 +688,7 @@ export class BackupService { await tx.harvestColony.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.harvestColony = rowsOf('harvestColony').length; @@ -583,10 +700,10 @@ export class BackupService { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.extraction = rowsOf('extraction').length; @@ -595,7 +712,7 @@ export class BackupService { await tx.extractionHarvest.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.extractionHarvest = rowsOf('extractionHarvest').length; @@ -604,7 +721,7 @@ export class BackupService { await tx.storageContainer.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.storageContainer = rowsOf('storageContainer').length; @@ -613,7 +730,7 @@ export class BackupService { await tx.honeyBatch.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.honeyBatch = rowsOf('honeyBatch').length; @@ -625,10 +742,10 @@ export class BackupService { ...row, userId: resolveUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveUserId(asString(row, 'userId')), - } as never, + }) as never, }); } imported.honeyMeasurement = rowsOf('honeyMeasurement').length; @@ -640,14 +757,25 @@ export class BackupService { ...row, userId: resolveOptionalUserId(asString(row, 'userId')), } as never, - update: { + update: updateWith({ ...row, userId: resolveOptionalUserId(asString(row, 'userId')), - } as never, + }) as never, }); } 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')) { await tx.stockItem.upsert({ where: { id: row.id as string }, @@ -676,7 +804,7 @@ export class BackupService { await tx.product.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.product = rowsOf('product').length; @@ -685,13 +813,28 @@ export class BackupService { await tx.labelTemplate.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.labelTemplate = rowsOf('labelTemplate').length; for (const row of rowsOf('bottlingBatch')) { 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 }, create: { ...row, @@ -703,13 +846,62 @@ export class BackupService { } 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')) { await tx.equipment.upsert({ where: { id: row.id as string }, create: row as never, - update: row as never, + update: updateWith(row) as never, }); } imported.equipment = rowsOf('equipment').length; @@ -748,6 +940,23 @@ export class BackupService { } 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')) { const { id, ...withoutId } = row; await tx.siteSettings.upsert({ @@ -758,6 +967,48 @@ export class BackupService { } 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 // surrounding transaction is known to commit - if a later entity in // this same import fails and the transaction rolls back, a file @@ -799,6 +1050,13 @@ export class BackupService { `${crypto.randomUUID()}${extension}`, ); 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 }; delete withoutContent.contentBase64; const data = { @@ -859,6 +1117,39 @@ export class BackupService { 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 { + 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)}`, + ); + } + } } } diff --git a/ApiCore-Backend/src/bottling/dto/update-bottling-batch.dto.ts b/ApiCore-Backend/src/bottling/dto/update-bottling-batch.dto.ts index 01ee348..743d4e1 100644 --- a/ApiCore-Backend/src/bottling/dto/update-bottling-batch.dto.ts +++ b/ApiCore-Backend/src/bottling/dto/update-bottling-batch.dto.ts @@ -8,6 +8,7 @@ import { IsString, ValidateIf, } from 'class-validator'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; /** * Every field of a bottling batch stays correctable after the fact - a @@ -21,7 +22,7 @@ import { */ export class UpdateBottlingBatchDto { @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() honeyBatchId?: string; @@ -35,29 +36,29 @@ export class UpdateBottlingBatchDto { productId?: string | null; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsDateString() date?: string; @ApiPropertyOptional({ description: 'Glasgröße in Gramm' }) - @IsOptional() + @IsOptionalNotNull() @IsNumber() @IsPositive() jarSize?: number; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsInt() @IsPositive() jarCount?: number; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsDateString() bestBeforeDate?: string; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() lotNumber?: string; } diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts index 97aa31c..ee2b490 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts @@ -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 () => { prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing); prisma.calendarSyncSettings.update.mockResolvedValue(existing); diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts index 7540677..040a8d0 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts @@ -6,7 +6,8 @@ import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settin 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); } diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts index 367e583..513e672 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts @@ -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 { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @@ -37,10 +45,13 @@ export class CalendarSyncController { 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') @Post('run-now') - async runNow() { - await this.calendarSyncService.runNow(); + @HttpCode(HttpStatus.ACCEPTED) + runNow() { + this.calendarSyncService.startRun(); return this.calendarSyncService.getStatus(); } } diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts index ff8e85c..3800172 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts @@ -1,7 +1,9 @@ +import { Logger } from '@nestjs/common'; import { CalendarSyncService } from './calendar-sync.service'; import { PrismaService } from '../prisma/prisma.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', () => { let service: CalendarSyncService; @@ -10,6 +12,7 @@ describe('CalendarSyncService', () => { upsert: jest.Mock; findUnique: jest.Mock; update: jest.Mock; + updateMany: jest.Mock; findMany: jest.Mock; count: jest.Mock; }; @@ -41,13 +44,47 @@ describe('CalendarSyncService', () => { updatedAt: new Date(), }; + const pendingEntry = (overrides: Record = {}) => ({ + 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; + data: Record; + }; + 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(() => { prisma = { calendarSyncOutboxEntry: { upsert: jest.fn(), findUnique: jest.fn(), update: jest.fn(), - findMany: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findMany: jest.fn().mockResolvedValue([]), count: jest.fn().mockResolvedValue(0), }, task: { findUnique: jest.fn() }, @@ -94,7 +131,7 @@ describe('CalendarSyncService', () => { 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'); expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({ @@ -102,11 +139,58 @@ describe('CalendarSyncService', () => { sourceType_sourceId: { 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', () => { it('does nothing when no outbox row exists', async () => { prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null); @@ -151,6 +235,25 @@ describe('CalendarSyncService', () => { }); 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) => { + 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 () => { settingsService.get.mockResolvedValue({ ...enabledSettings, @@ -160,76 +263,170 @@ describe('CalendarSyncService', () => { expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled(); }); - it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => { - prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ - { - id: 'entry-1', - sourceType: 'TASK', - sourceId: 'task-1', - graphEventId: null, - attempts: 0, - }, - ]); - prisma.task.findUnique.mockResolvedValue({ - id: 'task-1', - title: 'Beute prüfen', - dueDate: new Date('2026-03-07'), + it('only picks PENDING rows that nobody holds, or whose holder gave up long ago', async () => { + await service.processOutbox(); + const after = Date.now(); + + const [[query]] = prisma.calendarSyncOutboxEntry.findMany.mock.calls as [ + [{ where: Record }], + ]; + expect(query.where).toEqual({ + status: 'PENDING', + ...CLAIMABLE, }); + 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'); await service.processOutbox(); - expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', { - title: 'Beute prüfen', - date: new Date('2026-03-07'), - }); - expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ - where: { id: 'entry-1' }, - data: { - status: 'SYNCED', - graphEventId: 'graph-event-1', - lastError: null, + expect(updateManyCalls()[0]).toEqual({ + where: { + id: 'entry-1', + status: 'PENDING', + revision: 3, + ...CLAIMABLE, }, + 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([ - { - id: 'entry-1', - sourceType: 'TASK', - sourceId: 'task-1', - graphEventId: null, - attempts: 0, - }, + pendingEntry(), ]); - prisma.task.findUnique.mockResolvedValue({ - id: 'task-1', - title: 'Beute prüfen', - dueDate: null, + prisma.calendarSyncOutboxEntry.updateMany.mockResolvedValueOnce({ + count: 0, }); await service.processOutbox(); + expect(prisma.task.findUnique).not.toHaveBeenCalled(); expect(graphClient.createEvent).not.toHaveBeenCalled(); - expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ - where: { id: 'entry-1' }, - data: { status: 'SKIPPED' }, + expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledTimes( + 1, + ); + }); + + 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 () => { prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ - { - id: 'entry-1', - sourceType: 'TASK', - sourceId: 'task-1', - graphEventId: 'graph-event-1', - attempts: 0, - }, + pendingEntry({ graphEventId: 'graph-event-1' }), ]); prisma.task.findUnique.mockResolvedValue({ - id: 'task-1', + ...dueTask, title: 'Beute prüfen (geändert)', dueDate: new Date('2026-03-10'), }); @@ -243,96 +440,108 @@ describe('CalendarSyncService', () => { { title: 'Beute prüfen (geändert)', date: new Date('2026-03-10') }, ); expect(graphClient.createEvent).not.toHaveBeenCalled(); - expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ - where: { id: 'entry-1' }, - data: { - status: 'SYNCED', - graphEventId: 'graph-event-1', - lastError: null, - }, + expectSettled({ + status: 'SYNCED', + graphEventId: 'graph-event-1', + lastError: null, }); }); - it('falls back to creating a fresh event when updating a stale graphEventId 404s', async () => { - prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ - { - id: 'entry-1', - sourceType: 'TASK', - sourceId: 'task-1', - graphEventId: 'deleted-on-m365', - attempts: 0, - }, - ]); - prisma.task.findUnique.mockResolvedValue({ - id: 'task-1', - title: 'Beute prüfen', - dueDate: new Date('2026-03-07'), - }); - graphClient.updateEvent.mockRejectedValue(new Error('404 Not Found')); - graphClient.createEvent.mockResolvedValue('graph-event-new'); + it.each([404, 410])( + 'falls back to creating a fresh event when the stored event is gone (%i)', + async (status) => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + pendingEntry({ graphEventId: 'deleted-on-m365' }), + ]); + prisma.task.findUnique.mockResolvedValue(dueTask); + graphClient.updateEvent.mockRejectedValue( + new GraphRequestError('updateEvent', status, 'gone'), + ); + graphClient.createEvent.mockResolvedValue('graph-event-new'); - await service.processOutbox(); + await service.processOutbox(); - expect(graphClient.createEvent).toHaveBeenCalled(); - expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ - where: { id: 'entry-1' }, - data: { + expect(graphClient.createEvent).toHaveBeenCalled(); + expectSettled({ status: 'SYNCED', graphEventId: 'graph-event-new', 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 () => { prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ - { - id: 'entry-1', - sourceType: 'TASK', - sourceId: 'task-1', - graphEventId: null, - attempts: 1, - }, + pendingEntry({ attempts: 1 }), ]); prisma.task.findUnique.mockRejectedValue(new Error('db error')); await service.processOutbox(); - expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ - where: { id: 'entry-1' }, - data: { status: 'PENDING', attempts: 2, lastError: 'db error' }, + expectSettled({ 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 () => { prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ - { - id: 'entry-1', - sourceType: 'TASK', - sourceId: 'task-1', - graphEventId: null, - attempts: 4, - }, + pendingEntry({ attempts: 4 }), ]); prisma.task.findUnique.mockRejectedValue(new Error('db error')); await service.processOutbox(); - expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ - where: { id: 'entry-1' }, - data: { status: 'FAILED', attempts: 5, lastError: 'db error' }, - }); + expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' }); }); it('builds titles per source type matching the read-only calendar view', async () => { prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ - { - id: 'e1', - sourceType: 'INSPECTION', - sourceId: 'i1', - graphEventId: null, - attempts: 0, - }, + pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }), ]); prisma.inspection.findUnique.mockResolvedValue({ id: 'i1', @@ -343,10 +552,224 @@ describe('CalendarSyncService', () => { await service.processOutbox(); - expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', { - title: 'Durchsicht V-001', - date: new Date('2026-03-07'), + expect(graphClient.createEvent).toHaveBeenCalledWith( + 'group-1', + { 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) => { + graphClient.createEvent.mockClear(); + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + pendingEntry(entry), + ]); + await service.processOutbox(); + const [[, , transactionId]] = graphClient.createEvent.mock.calls as [ + [string, unknown, string], + ]; + return transactionId; + }; + + beforeEach(() => { + prisma.task.findUnique.mockResolvedValue({ + id: 'task-1', + title: 'Beute prüfen', + dueDate: new Date('2026-03-07'), }); + graphClient.createEvent.mockResolvedValue('graph-event-1'); + }); + + it('is the same for every retry of the same create', async () => { + graphClient.createEvent.mockRejectedValueOnce(new Error('timeout')); + const first = await transactionIdFor({ attempts: 0 }); + const retry = await transactionIdFor({ attempts: 1 }); + + expect(first).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(retry).toBe(first); + }); + + it('differs per outbox entry', async () => { + const one = await transactionIdFor({ id: 'entry-1' }); + const other = await transactionIdFor({ id: 'entry-2' }); + + expect(other).not.toBe(one); + }); + + // A cancelled source deleted its event; enqueued again it needs a new + // one, which an old transactionId might be matched against. + it('is new once the entry was enqueued again', async () => { + const before = await transactionIdFor({ revision: 3 }); + const after = await transactionIdFor({ revision: 4 }); + + expect(after).not.toBe(before); + }); + + it('is new when replacing an event that was deleted in M365', async () => { + const initial = await transactionIdFor({ graphEventId: null }); + graphClient.updateEvent.mockRejectedValue( + new GraphRequestError('updateEvent', 404, 'gone'), + ); + const replacement = await transactionIdFor({ + graphEventId: 'deleted-on-m365', + }); + + expect(replacement).not.toBe(initial); + }); + }); + + describe('runNow', () => { + it('gives FAILED entries a fresh set of attempts before processing', async () => { + await service.runNow(); + + expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({ + where: { status: 'FAILED' }, + data: { status: 'PENDING', attempts: 0, lastError: null }, + }); + const [resetOrder] = + prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder; + const [readOrder] = + prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder; + expect(resetOrder).toBeLessThan(readOrder); + }); + + it('leaves FAILED entries alone on a regular cron tick', async () => { + await service.processOutbox(); + + expect( + prisma.calendarSyncOutboxEntry.updateMany, + ).not.toHaveBeenCalledWith( + expect.objectContaining({ where: { status: 'FAILED' } }), + ); + }); + }); + + // "Jetzt synchronisieren" must not hold the HTTP request for a whole pass + // (up to 20 entries with several Graph calls each): it only starts it. + describe('startRun', () => { + const flush = async () => { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } + }; + /** Lets the pass hang on reading the outbox until released. */ + const holdOutbox = () => { + let release: () => void = () => undefined; + prisma.calendarSyncOutboxEntry.findMany.mockReturnValue( + new Promise((resolve) => { + release = () => resolve([]); + }), + ); + return () => release(); + }; + + it('returns while the pass is still running and reports it as running', async () => { + const release = holdOutbox(); + + service.startRun(); + await flush(); + + expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({ + where: { status: 'FAILED' }, + data: { status: 'PENDING', attempts: 0, lastError: null }, + }); + expect((await service.getStatus()).running).toBe(true); + + release(); + await flush(); + + expect((await service.getStatus()).running).toBe(false); + }); + + it('starts only one pass per pod at a time', async () => { + const release = holdOutbox(); + + service.startRun(); + service.startRun(); + await flush(); + + expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1); + release(); + await flush(); + }); + + it('logs a failed pass without query values and clears the indicator', async () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', { + code: 'P2010', + clientVersion: '7.9.1', + meta: { modelName: 'CalendarSyncOutboxEntry' }, + }), + ); + + service.startRun(); + await flush(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const [message, stack] = errorSpy.mock.calls[0] as [string, unknown]; + expect(message).toContain('P2010'); + expect(message).not.toContain('Mustermann'); + expect(stack).toBeUndefined(); + expect((await service.getStatus()).running).toBe(false); + errorSpy.mockRestore(); + }); + + it('lets shutdown wait for a running pass', async () => { + const release = holdOutbox(); + service.startRun(); + let destroyed = false; + + const shutdown = service.onModuleDestroy().then(() => { + destroyed = true; + }); + await flush(); + expect(destroyed).toBe(false); + + release(); + await shutdown; + expect(destroyed).toBe(true); + }); + }); + + // The cron tick's own errors would otherwise reach the scheduler's logger + // with the full Prisma message and stack. + describe('scheduledRun (cron tick)', () => { + it('runs the regular pass', async () => { + await service.scheduledRun(); + + expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1); + }); + + it('never throws and logs a failed tick without the Prisma message', async () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', { + code: 'P2010', + clientVersion: '7.9.1', + meta: { modelName: 'CalendarSyncOutboxEntry' }, + }), + ); + + await expect(service.scheduledRun()).resolves.toBeUndefined(); + + const [message, stack] = errorSpy.mock.calls[0] as [string, unknown]; + expect(message).toContain('P2010'); + expect(message).not.toContain('Mustermann'); + expect(stack).toBeUndefined(); + errorSpy.mockRestore(); }); }); @@ -355,7 +778,8 @@ describe('CalendarSyncService', () => { prisma.calendarSyncOutboxEntry.count .mockResolvedValueOnce(2) // PENDING .mockResolvedValueOnce(1) // FAILED - .mockResolvedValueOnce(5); // SYNCED + .mockResolvedValueOnce(5) // SYNCED + .mockResolvedValueOnce(0); // held by a worker right now const status = await service.getStatus(); @@ -366,6 +790,24 @@ describe('CalendarSyncService', () => { pendingCount: 2, failedCount: 1, 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 } }, }); }); }); diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts index 7767315..2c11cc2 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts @@ -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 { 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 { GraphClientService, GraphCalendarEventInput, + GraphRequestError, } from './graph-client.service'; import { CalendarSyncOutboxEntry, CalendarSyncSettings, CalendarSyncSourceType, + Prisma, } from '../../generated/prisma'; // 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 // (e.g. editing the source record) resets it back to PENDING. 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 { configured: boolean; @@ -27,6 +73,8 @@ export interface CalendarSyncStatusReport { pendingCount: number; failedCount: number; syncedCount: number; + /** A pass is under way - on this pod, or any pod holding an entry. */ + running: boolean; } function getErrorMessage(error: unknown): string { @@ -34,8 +82,10 @@ function getErrorMessage(error: unknown): string { } @Injectable() -export class CalendarSyncService { +export class CalendarSyncService implements OnModuleDestroy { private readonly logger = new Logger(CalendarSyncService.name); + // The pass started by "Jetzt synchronisieren" on this pod, if one runs. + private activeRun: Promise | null = null; constructor( private readonly prisma: PrismaService, @@ -58,27 +108,56 @@ export class CalendarSyncService { return flagBySourceType[sourceType]; } + // enqueue()/cancel() follow a committed write in the domain services and + // are called without a guard - so they never throw (see runAfterCommit). + private afterCommit( + action: string, + sourceType: CalendarSyncSourceType, + sourceId: string, + work: () => Promise, + ): Promise { + return runAfterCommit( + this.logger, + { action, entityType: sourceType, entityId: sourceId }, + work, + ); + } + // Called from the ~6 domain services right after a create/update that // should appear on the M365 calendar. Upserts rather than always // inserting: re-enqueuing an already-synced row (e.g. a Task's due date // changed) resets it to PENDING while keeping its graphEventId, so the // worker updates the existing Graph event instead of creating a duplicate. + // The revision counts up so a worker syncing the row right now notices the + // newer state instead of marking it SYNCED. async enqueue( sourceType: CalendarSyncSourceType, sourceId: string, ): Promise { - const settings = await this.settingsService.get(); - if (!settings.enabled) { - return; - } - if (!this.isSourceTypeEnabled(settings, sourceType)) { - return; - } - await this.prisma.calendarSyncOutboxEntry.upsert({ - where: { sourceType_sourceId: { sourceType, sourceId } }, - create: { sourceType, sourceId }, - update: { status: 'PENDING', attempts: 0, lastError: null }, - }); + await this.afterCommit( + 'Calendar sync enqueue', + sourceType, + sourceId, + async () => { + const settings = await this.settingsService.get(); + if (!settings.enabled) { + return; + } + if (!this.isSourceTypeEnabled(settings, sourceType)) { + return; + } + await this.prisma.calendarSyncOutboxEntry.upsert({ + where: { sourceType_sourceId: { sourceType, sourceId } }, + create: { sourceType, sourceId }, + update: { + status: 'PENDING', + attempts: 0, + lastError: null, + revision: { increment: 1 }, + }, + }); + }, + ); } // Called when a source record no longer belongs on the calendar (e.g. a @@ -89,38 +168,60 @@ export class CalendarSyncService { sourceType: CalendarSyncSourceType, sourceId: string, ): Promise { - const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({ - where: { sourceType_sourceId: { sourceType, sourceId } }, - }); - if (!entry) { - return; - } - if (entry.graphEventId) { - const settings = await this.settingsService.get(); - 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.afterCommit( + 'Calendar sync cancel', + sourceType, + sourceId, + async () => { + const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({ + where: { sourceType_sourceId: { sourceType, sourceId } }, + }); + if (!entry) { + return; } - } - } - await this.prisma.calendarSyncOutboxEntry.update({ - where: { id: entry.id }, - data: { status: 'SKIPPED', graphEventId: null }, - }); + if (entry.graphEventId) { + await this.deleteGraphEvent(entry.graphEventId); + } + await this.prisma.calendarSyncOutboxEntry.update({ + where: { id: entry.id }, + data: { status: 'SKIPPED', graphEventId: null }, + }); + }, + ); } + // Best-effort removal from M365: logged, never thrown. + private async deleteGraphEvent(graphEventId: string): Promise { + 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) + async scheduledRun(): Promise { + try { + await this.processOutbox(); + } catch (error: unknown) { + const { message, stack } = loggableError(error); + this.logger.error(`Calendar sync tick failed: ${message}`, stack); + } + } + async processOutbox(): Promise { const settings = await this.settingsService.get(); if ( @@ -131,22 +232,80 @@ export class CalendarSyncService { return; } - const entries = await this.prisma.calendarSyncOutboxEntry.findMany({ - where: { status: 'PENDING' }, + // Several workers may run at once - one cron tick per pod, plus "Jetzt + // 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' }, take: BATCH_SIZE, }); - for (const entry of entries) { - await this.processEntry(entry, settings.groupId); + for (const candidate of candidates) { + 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 { + await this.prisma.calendarSyncOutboxEntry.updateMany({ + where: { status: 'FAILED' }, + data: { status: 'PENDING', attempts: 0, lastError: null }, + }); 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 { + 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, + ): Promise { + 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, ): Promise { try { @@ -155,47 +314,132 @@ export class CalendarSyncService { entry.sourceId, ); if (!input) { - await this.prisma.calendarSyncOutboxEntry.update({ - where: { id: entry.id }, - data: { status: 'SKIPPED' }, - }); + await this.settle(entry, { status: 'SKIPPED' }); return; } const graphEventId = entry.graphEventId - ? await this.updateExisting(groupId, entry.graphEventId, input) - : await this.graphClient.createEvent(groupId, input); + ? await this.updateExisting(groupId, entry, entry.graphEventId, input) + : await this.graphClient.createEvent( + groupId, + input, + eventTransactionId(entry), + ); - await this.prisma.calendarSyncOutboxEntry.update({ - where: { id: entry.id }, - data: { status: 'SYNCED', graphEventId, lastError: null }, - }); + await this.recordSynced(entry, graphEventId); } catch (error: unknown) { const attempts = entry.attempts + 1; - await this.prisma.calendarSyncOutboxEntry.update({ - where: { id: entry.id }, - data: { - status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING', - attempts, - lastError: getErrorMessage(error), - }, + await this.settle(entry, { + status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING', + attempts, + // 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 { + 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 { + 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 { + 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 // 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( groupId: string, + entry: ClaimedEntry, graphEventId: string, input: GraphCalendarEventInput, ): Promise { try { await this.graphClient.updateEvent(groupId, graphEventId, input); return graphEventId; - } catch { - return this.graphClient.createEvent(groupId, input); + } catch (error: unknown) { + const eventGone = + error instanceof GraphRequestError && + EVENT_GONE_STATUSES.includes(error.status); + if (!eventGone) { + throw error; + } + return this.graphClient.createEvent( + groupId, + input, + eventTransactionId(entry), + ); } } @@ -279,17 +523,22 @@ export class CalendarSyncService { async getStatus(): Promise { const settings = await this.settingsService.get(); - const [pendingCount, failedCount, syncedCount] = await Promise.all([ - this.prisma.calendarSyncOutboxEntry.count({ - where: { status: 'PENDING' }, - }), - this.prisma.calendarSyncOutboxEntry.count({ - where: { status: 'FAILED' }, - }), - this.prisma.calendarSyncOutboxEntry.count({ - where: { status: 'SYNCED' }, - }), - ]); + const [pendingCount, failedCount, syncedCount, heldCount] = + await Promise.all([ + this.prisma.calendarSyncOutboxEntry.count({ + where: { status: 'PENDING' }, + }), + this.prisma.calendarSyncOutboxEntry.count({ + where: { status: 'FAILED' }, + }), + this.prisma.calendarSyncOutboxEntry.count({ + where: { status: 'SYNCED' }, + }), + // Live claims: some worker is writing an entry right now. + this.prisma.calendarSyncOutboxEntry.count({ + where: { claimedAt: { gte: new Date(Date.now() - STALE_CLAIM_MS) } }, + }), + ]); return { configured: this.graphClient.isConfigured(), enabled: settings.enabled, @@ -297,6 +546,7 @@ export class CalendarSyncService { pendingCount, failedCount, syncedCount, + running: this.activeRun !== null || heldCount > 0, }; } } diff --git a/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.spec.ts b/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.spec.ts new file mode 100644 index 0000000..a9bd2d2 --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.spec.ts @@ -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 { + 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/admin@example.com', + '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)', + ]); + }); +}); diff --git a/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts b/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts index 0da4d9f..e11e08a 100644 --- a/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts +++ b/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts @@ -1,48 +1,69 @@ 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 { @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() enabled?: boolean; @ApiPropertyOptional({ + type: String, + nullable: true, 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() @IsString() @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() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() syncTask?: boolean; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() syncInspection?: boolean; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() syncTreatment?: boolean; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() syncFeeding?: boolean; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() syncHarvest?: boolean; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() syncQueenAssignment?: boolean; } diff --git a/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts b/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts index 8fe5603..39db2fc 100644 --- a/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts @@ -1,4 +1,4 @@ -import { GraphClientService } from './graph-client.service'; +import { GraphClientService, GraphRequestError } from './graph-client.service'; function jsonResponse(status: number, body: unknown): Response { return { @@ -53,10 +53,11 @@ describe('GraphClientService', () => { ) .mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' })); - const result = await service.createEvent('group-1', { - title: 'Durchsicht V-001', - date: new Date(Date.UTC(2026, 2, 7)), - }); + const result = await service.createEvent( + 'group-1', + { title: 'Durchsicht V-001', date: new Date(Date.UTC(2026, 2, 7)) }, + 'tx-durchsicht', + ); expect(result).toBe('graph-event-1'); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -82,8 +83,11 @@ describe('GraphClientService', () => { isAllDay: boolean; start: { dateTime: string }; end: { dateTime: string }; + transactionId: string; }; 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.start.dateTime).toBe('2026-03-07T00: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-b' })); - await service.createEvent('group-1', { - title: 'A', - date: new Date(Date.UTC(2026, 2, 7)), - }); - await service.createEvent('group-1', { - title: 'B', - date: new Date(Date.UTC(2026, 2, 8)), - }); + await service.createEvent( + 'group-1', + { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) }, + 'tx-a', + ); + await service.createEvent( + 'group-1', + { title: 'B', date: new Date(Date.UTC(2026, 2, 8)) }, + 'tx-b', + ); expect(fetchMock).toHaveBeenCalledTimes(3); }); @@ -117,14 +123,99 @@ describe('GraphClientService', () => { .mockResolvedValueOnce(jsonResponse(403, { error: 'Forbidden' })); await expect( - service.createEvent('group-1', { - title: 'A', - date: new Date(Date.UTC(2026, 2, 7)), - }), + service.createEvent( + 'group-1', + { title: 'A', date: new Date(Date.UTC(2026, 2, 7)) }, + 'tx-a', + ), ).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', () => { it('treats a 404 as success (idempotent delete)', async () => { fetchMock diff --git a/ApiCore-Backend/src/calendar-sync/graph-client.service.ts b/ApiCore-Backend/src/calendar-sync/graph-client.service.ts index 4e76c27..77676cf 100644 --- a/ApiCore-Backend/src/calendar-sync/graph-client.service.ts +++ b/ApiCore-Backend/src/calendar-sync/graph-client.service.ts @@ -11,6 +11,16 @@ interface CachedToken { } 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, // 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. @@ -18,6 +28,27 @@ const EVENT_TIME_ZONE = 'Europe/Berlin'; // Refresh a little before actual expiry so a request never races an // about-to-expire cached token. 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 { return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; @@ -84,6 +115,7 @@ export class GraphClientService { client_secret: clientSecret, scope: 'https://graph.microsoft.com/.default', }), + signal: timeoutSignal(), }); } catch (error: unknown) { 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( groupId: string, input: GraphCalendarEventInput, + transactionId: string, ): Promise { const token = await this.getAccessToken(); - const response = await fetch(`${GRAPH_BASE_URL}/groups/${groupId}/events`, { + const response = await fetch(eventsUrl(groupId), { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - body: JSON.stringify(this.allDayEventBody(input)), + body: JSON.stringify({ ...this.allDayEventBody(input), transactionId }), + signal: timeoutSignal(), }); if (!response.ok) { - throw new Error( - `Graph createEvent fehlgeschlagen (${response.status}): ${await response.text()}`, + throw new GraphRequestError( + 'createEvent', + response.status, + await response.text(), ); } const data = (await response.json()) as { id: string }; @@ -156,38 +196,38 @@ export class GraphClientService { input: GraphCalendarEventInput, ): Promise { const token = await this.getAccessToken(); - const response = await fetch( - `${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(this.allDayEventBody(input)), + const response = await fetch(eventsUrl(groupId, eventId), { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', }, - ); + body: JSON.stringify(this.allDayEventBody(input)), + signal: timeoutSignal(), + }); if (!response.ok) { - throw new Error( - `Graph updateEvent fehlgeschlagen (${response.status}): ${await response.text()}`, + throw new GraphRequestError( + 'updateEvent', + response.status, + await response.text(), ); } } async deleteEvent(groupId: string, eventId: string): Promise { const token = await this.getAccessToken(); - const response = await fetch( - `${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }, - ); + const response = await fetch(eventsUrl(groupId, eventId), { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + signal: timeoutSignal(), + }); // A 404 means the event is already gone - treat as success so a retry // after a partially-applied delete doesn't throw. if (!response.ok && response.status !== 404) { - throw new Error( - `Graph deleteEvent fehlgeschlagen (${response.status}): ${await response.text()}`, + throw new GraphRequestError( + 'deleteEvent', + response.status, + await response.text(), ); } } diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index fb85209..eb57ccd 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -313,6 +313,17 @@ describe('ColoniesService', () => { ).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 () => { prisma.colony.findUnique.mockResolvedValue({ ...parentColony, diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 1dfee5e..0917fc7 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -317,7 +317,9 @@ export class ColoniesService { async reactivate(id: string, actingUserId: string): Promise { 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'); } const colony = await this.prisma.colony.update({ diff --git a/ApiCore-Backend/src/colonies/colony-status.ts b/ApiCore-Backend/src/colonies/colony-status.ts index a9ace43..2a59234 100644 --- a/ApiCore-Backend/src/colonies/colony-status.ts +++ b/ApiCore-Backend/src/colonies/colony-status.ts @@ -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 @@ -16,3 +16,8 @@ export const TERMINAL_COLONY_STATUSES: readonly ColonyStatus[] = [ export function isLivingColonyStatus(status: ColonyStatus): boolean { 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; diff --git a/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts index 11833e1..9f995dd 100644 --- a/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts +++ b/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts @@ -1,7 +1,8 @@ import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger'; -import { IsEnum, IsOptional } from 'class-validator'; +import { IsEnum } from 'class-validator'; import { ColonyStatus } from '../../../generated/prisma'; 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: @@ -22,11 +23,12 @@ class UpdatableColonyFieldsDto extends PartialType( 'healthStatus', 'notes', ] as const), + { skipNullProperties: false }, ) {} export class UpdateColonyDto extends UpdatableColonyFieldsDto { @ApiPropertyOptional({ enum: ColonyStatus }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(ColonyStatus) status?: ColonyStatus; } diff --git a/ApiCore-Backend/src/common/after-commit.spec.ts b/ApiCore-Backend/src/common/after-commit.spec.ts new file mode 100644 index 0000000..623ddc8 --- /dev/null +++ b/ApiCore-Backend/src/common/after-commit.spec.ts @@ -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(); + }); +}); diff --git a/ApiCore-Backend/src/common/after-commit.ts b/ApiCore-Backend/src/common/after-commit.ts new file mode 100644 index 0000000..558d52a --- /dev/null +++ b/ApiCore-Backend/src/common/after-commit.ts @@ -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, +): Promise { + try { + await work(); + } catch (error: unknown) { + const { message, stack } = loggableError(error); + logger.error( + `${subject.action} failed for ${subject.entityType} ${subject.entityId}: ${message}`, + stack, + ); + } +} diff --git a/ApiCore-Backend/src/common/filters/all-exceptions.filter.spec.ts b/ApiCore-Backend/src/common/filters/all-exceptions.filter.spec.ts index 6b1493e..dfa879c 100644 --- a/ApiCore-Backend/src/common/filters/all-exceptions.filter.spec.ts +++ b/ApiCore-Backend/src/common/filters/all-exceptions.filter.spec.ts @@ -176,4 +176,127 @@ describe('AllExceptionsFilter', () => { 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, + }); + }, + ); + }); }); diff --git a/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts b/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts index ec98389..3023d5c 100644 --- a/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts +++ b/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts @@ -8,6 +8,7 @@ import { } from '@nestjs/common'; import type { Request, Response } from 'express'; import { Prisma } from '../../../generated/prisma'; +import { loggableError } from '../loggable-error'; // Unique-constraint fields a beekeeper might plausibly retype by mistake // (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' }; } +/** + * 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( exception: unknown, ): { status: number; message: string } | null { @@ -109,7 +135,8 @@ function prismaErrorResponse( * Client-facing behaviour is unchanged: HttpExceptions still return * exactly the body/status Nest would have produced. Non-HttpException * 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 * against DB constraints (`@unique` columns like colony/apiary/queen codes, * SKUs and batch numbers; foreign keys for every parent relation) with no @@ -140,8 +167,18 @@ export class AllExceptionsFilter implements ExceptionFilter { statusCode: status, message: prismaResponse?.message ?? 'Internal server error', }; - const message = - exception instanceof Error ? exception.message : 'Unknown error'; + // A Prisma error is logged by name, code and model only - its message + // 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. const context = `${request.method} ${request.baseUrl}${request.path} -> ${status}`; // Compared against the literal 500, not HttpStatus.INTERNAL_SERVER_ERROR: @@ -150,10 +187,7 @@ export class AllExceptionsFilter implements ExceptionFilter { const isServerError = status >= 500; if (isServerError) { - this.logger.error( - `${context}: ${message}`, - exception instanceof Error ? exception.stack : undefined, - ); + this.logger.error(`${context}: ${message}`, stack); } else { this.logger.warn(`${context}: ${message}`); } diff --git a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.spec.ts b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.spec.ts new file mode 100644 index 0000000..22717c8 --- /dev/null +++ b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.spec.ts @@ -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(); + }); +}); diff --git a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts index 7ae9bc8..e9ec5b1 100644 --- a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts +++ b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { PrismaService } from '../../prisma/prisma.service'; +import { loggableError } from '../loggable-error'; // Longer than any realistic offline stretch at the apiary: an entry queued // on the phone must still find its key when it finally syncs, otherwise the @@ -13,7 +14,19 @@ export class IdempotencyCleanupService { constructor(private readonly prisma: PrismaService) {} + // The daily tick. An error is logged here, Prisma errors by name/code/model + // only (see loggableError) - left uncaught, the scheduler would log the + // full Prisma message and stack. @Cron(CronExpression.EVERY_DAY_AT_4AM) + async scheduledCleanup(): Promise { + 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 { const cutoff = new Date( now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000, diff --git a/ApiCore-Backend/src/common/loggable-error.spec.ts b/ApiCore-Backend/src/common/loggable-error.spec.ts new file mode 100644 index 0000000..99cbcb0 --- /dev/null +++ b/ApiCore-Backend/src/common/loggable-error.spec.ts @@ -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(); + }); +}); diff --git a/ApiCore-Backend/src/common/loggable-error.ts b/ApiCore-Backend/src/common/loggable-error.ts new file mode 100644 index 0000000..3ad87ba --- /dev/null +++ b/ApiCore-Backend/src/common/loggable-error.ts @@ -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 | 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 }; +} diff --git a/ApiCore-Backend/src/common/patch-values.spec.ts b/ApiCore-Backend/src/common/patch-values.spec.ts new file mode 100644 index 0000000..044c9da --- /dev/null +++ b/ApiCore-Backend/src/common/patch-values.spec.ts @@ -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')); + }); +}); diff --git a/ApiCore-Backend/src/common/patch-values.ts b/ApiCore-Backend/src/common/patch-values.ts new file mode 100644 index 0000000..bd3337c --- /dev/null +++ b/ApiCore-Backend/src/common/patch-values.ts @@ -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); +} diff --git a/ApiCore-Backend/src/common/validation/is-optional-not-null.ts b/ApiCore-Backend/src/common/validation/is-optional-not-null.ts new file mode 100644 index 0000000..df3e9c6 --- /dev/null +++ b/ApiCore-Backend/src/common/validation/is-optional-not-null.ts @@ -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); +} diff --git a/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts b/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts new file mode 100644 index 0000000..8b42ce7 --- /dev/null +++ b/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts @@ -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 = { + 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, +): Promise { + 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([]); + }); + } + }); +}); diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts index b0448f9..578a7a3 100644 --- a/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts +++ b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts @@ -108,6 +108,34 @@ describe('DashboardService', () => { 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', () => { it('flags a colony whose queen is at least 3 years old', async () => { prisma.colony.findMany diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.ts b/ApiCore-Backend/src/dashboard/dashboard.service.ts index 9f7ecea..b36ac62 100644 --- a/ApiCore-Backend/src/dashboard/dashboard.service.ts +++ b/ApiCore-Backend/src/dashboard/dashboard.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; export interface DashboardSummary { apiariesActive: number; @@ -42,7 +43,7 @@ export class DashboardService { stockItems, ] = await Promise.all([ 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({ where: { status: { in: ['ACTIVE', 'RESERVE'] } }, }), @@ -89,7 +90,7 @@ export class DashboardService { where: { minThreshold: { not: null } }, }), this.prisma.swarmControlCheck.findMany({ - where: { colony: { status: 'ACTIVE' } }, + where: { colony: LIVING_COLONY_WHERE }, orderBy: [{ colonyId: 'asc' }, { date: 'desc' }], select: { colonyId: true, @@ -99,7 +100,7 @@ export class DashboardService { }), this.prisma.colony.findMany({ where: { - status: 'ACTIVE', + ...LIVING_COLONY_WHERE, currentQueen: { hatchYear: { not: null } }, }, select: { diff --git a/ApiCore-Backend/src/documents/dto/update-document.dto.ts b/ApiCore-Backend/src/documents/dto/update-document.dto.ts index 44db93f..7abda0a 100644 --- a/ApiCore-Backend/src/documents/dto/update-document.dto.ts +++ b/ApiCore-Backend/src/documents/dto/update-document.dto.ts @@ -1,6 +1,7 @@ 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 { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; /** * 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 { @ApiPropertyOptional({ enum: DocumentEntityType }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(DocumentEntityType) entityType?: DocumentEntityType; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() @IsNotEmpty() entityId?: string; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() @IsNotEmpty() fileName?: string; diff --git a/ApiCore-Backend/src/equipment/dto/update-equipment.dto.ts b/ApiCore-Backend/src/equipment/dto/update-equipment.dto.ts index f474d88..dde0677 100644 --- a/ApiCore-Backend/src/equipment/dto/update-equipment.dto.ts +++ b/ApiCore-Backend/src/equipment/dto/update-equipment.dto.ts @@ -1,11 +1,14 @@ import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsEnum, IsOptional } from 'class-validator'; +import { IsEnum } from 'class-validator'; import { EquipmentCondition } from '../../../generated/prisma'; 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 }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(EquipmentCondition) condition?: EquipmentCondition; } diff --git a/ApiCore-Backend/src/extractions/dto/update-extraction.dto.ts b/ApiCore-Backend/src/extractions/dto/update-extraction.dto.ts index 1f8de7f..c3ae0af 100644 --- a/ApiCore-Backend/src/extractions/dto/update-extraction.dto.ts +++ b/ApiCore-Backend/src/extractions/dto/update-extraction.dto.ts @@ -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 * 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, +}) {} diff --git a/ApiCore-Backend/src/feedings/dto/update-feeding.dto.ts b/ApiCore-Backend/src/feedings/dto/update-feeding.dto.ts index fee5e17..613beb2 100644 --- a/ApiCore-Backend/src/feedings/dto/update-feeding.dto.ts +++ b/ApiCore-Backend/src/feedings/dto/update-feeding.dto.ts @@ -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 * repair the colony's history without deleting the record. */ -export class UpdateFeedingDto extends PartialType(CreateFeedingDto) {} +export class UpdateFeedingDto extends PartialType(CreateFeedingDto, { + skipNullProperties: false, +}) {} diff --git a/ApiCore-Backend/src/feedings/feedings.service.spec.ts b/ApiCore-Backend/src/feedings/feedings.service.spec.ts index 94f3710..90c02b3 100644 --- a/ApiCore-Backend/src/feedings/feedings.service.spec.ts +++ b/ApiCore-Backend/src/feedings/feedings.service.spec.ts @@ -17,6 +17,7 @@ describe('FeedingsService', () => { }; }; let auditLog: { record: jest.Mock }; + let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock }; const feeding = { id: 'feeding-1', colonyId: 'colony-1', date: new Date() }; @@ -31,7 +32,7 @@ describe('FeedingsService', () => { }, }; auditLog = { record: jest.fn() }; - const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; + calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; service = new FeedingsService( prisma as unknown as PrismaService, 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 () => { prisma.feeding.groupBy.mockResolvedValue([ { unit: 'l', _sum: { amount: 5.5 } }, diff --git a/ApiCore-Backend/src/feedings/feedings.service.ts b/ApiCore-Backend/src/feedings/feedings.service.ts index 8ce102c..f4d47ec 100644 --- a/ApiCore-Backend/src/feedings/feedings.service.ts +++ b/ApiCore-Backend/src/feedings/feedings.service.ts @@ -86,6 +86,9 @@ export class FeedingsService { beforeData: before, afterData: feeding, }); + // A changed date or colony must move the calendar event, not leave the + // old one behind. + await this.calendarSync.enqueue('FEEDING', feeding.id); return feeding; } } diff --git a/ApiCore-Backend/src/harvests/dto/update-harvest.dto.ts b/ApiCore-Backend/src/harvests/dto/update-harvest.dto.ts index 39bdb05..0e540b7 100644 --- a/ApiCore-Backend/src/harvests/dto/update-harvest.dto.ts +++ b/ApiCore-Backend/src/harvests/dto/update-harvest.dto.ts @@ -7,4 +7,6 @@ import { CreateHarvestDto } from './create-harvest.dto'; * data-entry error, and re-pointing it is the only way to repair the * traceability chain without deleting the record. */ -export class UpdateHarvestDto extends PartialType(CreateHarvestDto) {} +export class UpdateHarvestDto extends PartialType(CreateHarvestDto, { + skipNullProperties: false, +}) {} diff --git a/ApiCore-Backend/src/harvests/harvests.service.spec.ts b/ApiCore-Backend/src/harvests/harvests.service.spec.ts index cd72d92..dcdaf3e 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.spec.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.spec.ts @@ -13,6 +13,7 @@ describe('HarvestsService', () => { $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; + let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock }; let tx: { harvest: { create: jest.Mock; update: jest.Mock }; harvestColony: { createMany: jest.Mock; deleteMany: jest.Mock }; @@ -41,7 +42,7 @@ describe('HarvestsService', () => { }; auditLog = { record: jest.fn() }; 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( prisma as unknown as PrismaService, 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 () => { prisma.harvest.findUnique.mockResolvedValue(harvest); diff --git a/ApiCore-Backend/src/harvests/harvests.service.ts b/ApiCore-Backend/src/harvests/harvests.service.ts index 9c1b62b..5efc392 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.ts @@ -142,6 +142,9 @@ export class HarvestsService { beforeData: before, afterData: harvest, }); + // A changed date must move the calendar event, not leave the old one + // behind. + await this.calendarSync.enqueue('HARVEST', harvest.id); return harvest; } } diff --git a/ApiCore-Backend/src/health/health.controller.spec.ts b/ApiCore-Backend/src/health/health.controller.spec.ts index b447bcc..585cbcc 100644 --- a/ApiCore-Backend/src/health/health.controller.spec.ts +++ b/ApiCore-Backend/src/health/health.controller.spec.ts @@ -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 { PrismaService } from '../prisma/prisma.service'; @@ -22,9 +23,37 @@ describe('HealthController', () => { }); it('readiness throws ServiceUnavailableException when the database is unreachable', async () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); prisma.$queryRaw.mockRejectedValueOnce(new Error('connection refused')); await expect(controller.readiness()).rejects.toBeInstanceOf( 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(); + }); }); diff --git a/ApiCore-Backend/src/health/health.controller.ts b/ApiCore-Backend/src/health/health.controller.ts index c904318..b15aa93 100644 --- a/ApiCore-Backend/src/health/health.controller.ts +++ b/ApiCore-Backend/src/health/health.controller.ts @@ -8,6 +8,7 @@ import { import { ApiTags } from '@nestjs/swagger'; import { PrismaService } from '../prisma/prisma.service'; import { Public } from '../auth/decorators/public.decorator'; +import { loggableError } from '../common/loggable-error'; const READINESS_QUERY_TIMEOUT_MS = 5000; @@ -48,7 +49,9 @@ export class HealthController { ); return { status: 'ok' }; } 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'); } } diff --git a/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts b/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts index 2a82372..cc44431 100644 --- a/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts +++ b/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts @@ -1,12 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class CreateHoneyBatchDto { @ApiPropertyOptional({ description: 'Wird automatisch generiert, falls die Automatisierung "Chargennummer-Format" aktiv ist', }) - @IsOptional() + @IsOptionalNotNull() @IsString() batchNumber?: string; diff --git a/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts b/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts index ec163c0..a162141 100644 --- a/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts +++ b/ApiCore-Backend/src/honey-batches/dto/update-honey-batch.dto.ts @@ -1,13 +1,15 @@ import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger'; -import { IsEnum, IsOptional } from 'class-validator'; +import { IsEnum } from 'class-validator'; import { HoneyBatchStatus } from '../../../generated/prisma'; import { CreateHoneyBatchDto } from './create-honey-batch.dto'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; /** * `storageContainerId` is editable here too, not only through * 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 - * 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( PickType(CreateHoneyBatchDto, [ @@ -18,6 +20,7 @@ class UpdatableHoneyBatchFieldsDto extends PartialType( 'waterContent', 'storageContainerId', ] as const), + { skipNullProperties: false }, ) {} export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto { @@ -27,7 +30,7 @@ export class UpdateHoneyBatchDto extends UpdatableHoneyBatchFieldsDto { * undo a status that was set by mistake. */ @ApiPropertyOptional({ enum: HoneyBatchStatus }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(HoneyBatchStatus) status?: HoneyBatchStatus; } diff --git a/ApiCore-Backend/src/honey-batches/dto/update-honey-measurement.dto.ts b/ApiCore-Backend/src/honey-batches/dto/update-honey-measurement.dto.ts index ec5ef53..105d3dc 100644 --- a/ApiCore-Backend/src/honey-batches/dto/update-honey-measurement.dto.ts +++ b/ApiCore-Backend/src/honey-batches/dto/update-honey-measurement.dto.ts @@ -8,4 +8,5 @@ import { CreateHoneyMeasurementDto } from './create-honey-measurement.dto'; */ export class UpdateHoneyMeasurementDto extends PartialType( CreateHoneyMeasurementDto, + { skipNullProperties: false }, ) {} diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts index 972d73f..7a85c9d 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts @@ -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 () => { prisma.honeyBatch.findUnique.mockResolvedValue({ ...honeyBatch, diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts index 8b52fa5..8cfda29 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts @@ -87,9 +87,15 @@ export class HoneyBatchesService { async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) { const before = await this.findById(id); - const movesContainer = - dto.storageContainerId !== undefined && - dto.storageContainerId !== before.storageContainerId; + // A relocation is a move into a container. null only removes the + // assignment - a batch may stand in no container, as it may be created + // 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 tx.honeyBatch.update({ @@ -99,12 +105,12 @@ export class HoneyBatchesService { // Correcting the container has to leave the same trace a deliberate // relocation does - otherwise the batch's storage history would skip a // container it was recorded in. - if (movesContainer) { + if (movedToContainerId) { await tx.honeyBatchRelocation.create({ data: { honeyBatchId: id, fromStorageContainerId: before.storageContainerId, - toStorageContainerId: dto.storageContainerId!, + toStorageContainerId: movedToContainerId, userId: actingUserId, notes: 'Korrektur über Honigcharge bearbeiten', }, diff --git a/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts index cd59941..cd64e52 100644 --- a/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts +++ b/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts @@ -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 * repair the colony's history without deleting the record. */ -export class UpdateInspectionDto extends PartialType(CreateInspectionDto) {} +export class UpdateInspectionDto extends PartialType(CreateInspectionDto, { + skipNullProperties: false, +}) {} diff --git a/ApiCore-Backend/src/inspections/inspections.service.spec.ts b/ApiCore-Backend/src/inspections/inspections.service.spec.ts index 220ffda..22d3f32 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.spec.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.spec.ts @@ -18,6 +18,7 @@ describe('InspectionsService', () => { task: { create: jest.Mock }; }; let auditLog: { record: jest.Mock }; + let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock }; const inspection = { id: 'inspection-1', @@ -40,7 +41,7 @@ describe('InspectionsService', () => { evaluateInspection: 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( prisma as unknown as PrismaService, webhooks as unknown as WebhooksService, @@ -72,6 +73,23 @@ describe('InspectionsService', () => { 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 () => { prisma.inspection.findUnique.mockResolvedValue(inspection); 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 () => { prisma.inspection.findUnique.mockResolvedValue(inspection); prisma.task.create.mockResolvedValue({ id: 'task-1' }); diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts index dd4b2d4..85f03be 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -8,6 +8,7 @@ import { Inspection, Task } from '../../generated/prisma'; import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; import { UpdateInspectionDto } from './dto/update-inspection.dto'; +import { optionalDate } from '../common/patch-values'; @Injectable() export class InspectionsService { @@ -47,6 +48,13 @@ export class InspectionsService { const inspection = await this.prisma.inspection.create({ data: { ...dto, date: new Date(dto.date), userId: actingUserId }, }); + await this.auditLog.record({ + entityType: 'Inspection', + entityId: inspection.id, + action: 'CREATE', + userId: actingUserId, + afterData: inspection, + }); await this.webhooks.dispatch('inspection.created', inspection); await this.automation.evaluateInspection(inspection); await this.calendarSync.enqueue('INSPECTION', inspection.id); @@ -71,6 +79,9 @@ export class InspectionsService { beforeData: before, afterData: inspection, }); + // A changed date or colony must move the calendar event, not leave the + // old one behind. + await this.calendarSync.enqueue('INSPECTION', inspection.id); return inspection; } @@ -85,7 +96,7 @@ export class InspectionsService { title: dto.title, description: dto.description, priority: dto.priority, - dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, + dueDate: optionalDate(dto.dueDate), assignedUserId: dto.assignedUserId, createdByUserId: actingUserId, relatedType: 'COLONY', diff --git a/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts index d08c52c..c6aba96 100644 --- a/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts +++ b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts @@ -12,6 +12,7 @@ import { ValidateNested, } from 'class-validator'; import { LabelElementDto } from './label-element.dto'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class CreateLabelTemplateDto { @ApiProperty() @@ -26,13 +27,13 @@ export class CreateLabelTemplateDto { elements!: LabelElementDto[]; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsNumber() @Min(10) widthMm?: number; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsNumber() @Min(10) heightMm?: number; @@ -55,25 +56,25 @@ export class CreateLabelTemplateDto { rows?: number; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsNumber() @Min(0) marginTopMm?: number; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsNumber() @Min(0) marginLeftMm?: number; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsNumber() @Min(0) gapXMm?: number; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsNumber() @Min(0) gapYMm?: number; @@ -82,12 +83,12 @@ export class CreateLabelTemplateDto { description: 'Schnittmarken im Blattrand drucken (zum Schneiden mit dem Papierschneider)', }) - @IsOptional() + @IsOptionalNotNull() @IsBoolean() cropMarks?: boolean; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() isDefault?: boolean; } diff --git a/ApiCore-Backend/src/label-templates/dto/update-label-template.dto.ts b/ApiCore-Backend/src/label-templates/dto/update-label-template.dto.ts index c143c87..9df3d3f 100644 --- a/ApiCore-Backend/src/label-templates/dto/update-label-template.dto.ts +++ b/ApiCore-Backend/src/label-templates/dto/update-label-template.dto.ts @@ -3,4 +3,5 @@ import { CreateLabelTemplateDto } from './create-label-template.dto'; export class UpdateLabelTemplateDto extends PartialType( CreateLabelTemplateDto, + { skipNullProperties: false }, ) {} diff --git a/ApiCore-Backend/src/production-batches/dto/update-production-batch.dto.ts b/ApiCore-Backend/src/production-batches/dto/update-production-batch.dto.ts index f7f9e1e..3df5c33 100644 --- a/ApiCore-Backend/src/production-batches/dto/update-production-batch.dto.ts +++ b/ApiCore-Backend/src/production-batches/dto/update-production-batch.dto.ts @@ -9,6 +9,7 @@ import { MaxLength, ValidateIf, } 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 @@ -17,19 +18,19 @@ import { */ export class UpdateProductionBatchDto { @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() @IsNotEmpty() @MaxLength(100) lotNumber?: string; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsDateString() date?: string; @ApiPropertyOptional({ description: 'Hergestellte Stückzahl' }) - @IsOptional() + @IsOptionalNotNull() @IsInt() @IsPositive() quantity?: number; diff --git a/ApiCore-Backend/src/production-batches/production-batches.service.ts b/ApiCore-Backend/src/production-batches/production-batches.service.ts index 1d4cc42..37287e1 100644 --- a/ApiCore-Backend/src/production-batches/production-batches.service.ts +++ b/ApiCore-Backend/src/production-batches/production-batches.service.ts @@ -11,6 +11,7 @@ import { AuditLogService } from '../audit/audit-log.service'; import { AutomationService } from '../automation/automation.service'; import { StockService } from '../stock/stock.service'; import { RawMaterialKind } from '../../generated/prisma'; +import { optionalDate } from '../common/patch-values'; import { CreateProductionBatchDto } from './dto/create-production-batch.dto'; import { UpdateProductionBatchDto } from './dto/update-production-batch.dto'; @@ -112,9 +113,7 @@ export class ProductionBatchesService { lotNumber, date: new Date(dto.date), quantity: dto.quantity, - bestBeforeDate: dto.bestBeforeDate - ? new Date(dto.bestBeforeDate) - : undefined, + bestBeforeDate: optionalDate(dto.bestBeforeDate), notes: dto.notes, userId: actingUserId, }, @@ -177,12 +176,7 @@ export class ProductionBatchesService { lotNumber: dto.lotNumber, date: dto.date ? new Date(dto.date) : undefined, quantity: dto.quantity, - bestBeforeDate: - dto.bestBeforeDate === undefined - ? undefined - : dto.bestBeforeDate === null - ? null - : new Date(dto.bestBeforeDate), + bestBeforeDate: optionalDate(dto.bestBeforeDate), notes: dto.notes, }, }); diff --git a/ApiCore-Backend/src/products/dto/create-product.dto.ts b/ApiCore-Backend/src/products/dto/create-product.dto.ts index 25366a4..512c11e 100644 --- a/ApiCore-Backend/src/products/dto/create-product.dto.ts +++ b/ApiCore-Backend/src/products/dto/create-product.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; import { ProductKind, ShopCategory } from '../../../generated/prisma'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class CreateProductDto { @ApiProperty() @@ -37,12 +38,12 @@ export class CreateProductDto { description: 'BOTTLED_HONEY: Bestand je Abfüllung; MANUFACTURED: Bestand je Herstellungscharge', }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(ProductKind) kind?: ProductKind; @ApiPropertyOptional({ enum: ShopCategory }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(ShopCategory) shopCategory?: ShopCategory; } diff --git a/ApiCore-Backend/src/products/dto/update-product.dto.ts b/ApiCore-Backend/src/products/dto/update-product.dto.ts index 549700b..0be8464 100644 --- a/ApiCore-Backend/src/products/dto/update-product.dto.ts +++ b/ApiCore-Backend/src/products/dto/update-product.dto.ts @@ -1,11 +1,14 @@ import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsEnum, IsOptional } from 'class-validator'; +import { IsEnum } from 'class-validator'; import { ProductStatus } from '../../../generated/prisma'; 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 }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(ProductStatus) status?: ProductStatus; } diff --git a/ApiCore-Backend/src/profile/dto/update-profile.dto.ts b/ApiCore-Backend/src/profile/dto/update-profile.dto.ts index 3eaefdf..702a3c1 100644 --- a/ApiCore-Backend/src/profile/dto/update-profile.dto.ts +++ b/ApiCore-Backend/src/profile/dto/update-profile.dto.ts @@ -1,16 +1,17 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; import { NormalizeEmail } from '../../common/validation/normalize-email'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class UpdateProfileDto { @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() @MinLength(1) name?: string; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @NormalizeEmail() @IsEmail() email?: string; diff --git a/ApiCore-Backend/src/queens/dto/update-queen-evaluation.dto.ts b/ApiCore-Backend/src/queens/dto/update-queen-evaluation.dto.ts index f2dd41a..f186ed9 100644 --- a/ApiCore-Backend/src/queens/dto/update-queen-evaluation.dto.ts +++ b/ApiCore-Backend/src/queens/dto/update-queen-evaluation.dto.ts @@ -7,4 +7,5 @@ import { CreateQueenEvaluationDto } from './create-queen-evaluation.dto'; */ export class UpdateQueenEvaluationDto extends PartialType( CreateQueenEvaluationDto, + { skipNullProperties: false }, ) {} diff --git a/ApiCore-Backend/src/queens/dto/update-queen.dto.ts b/ApiCore-Backend/src/queens/dto/update-queen.dto.ts index 72bf224..2b9e6d9 100644 --- a/ApiCore-Backend/src/queens/dto/update-queen.dto.ts +++ b/ApiCore-Backend/src/queens/dto/update-queen.dto.ts @@ -1,11 +1,14 @@ import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsEnum, IsOptional } from 'class-validator'; +import { IsEnum } from 'class-validator'; import { QueenStatus } from '../../../generated/prisma'; 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 }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(QueenStatus) status?: QueenStatus; } diff --git a/ApiCore-Backend/src/raw-materials/dto/update-raw-material-lot.dto.ts b/ApiCore-Backend/src/raw-materials/dto/update-raw-material-lot.dto.ts index 1472f65..9aed699 100644 --- a/ApiCore-Backend/src/raw-materials/dto/update-raw-material-lot.dto.ts +++ b/ApiCore-Backend/src/raw-materials/dto/update-raw-material-lot.dto.ts @@ -9,6 +9,7 @@ import { MaxLength, ValidateIf, } 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 - @@ -17,19 +18,19 @@ import { */ export class UpdateRawMaterialLotDto { @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() @IsNotEmpty() @MaxLength(100) lotNumber?: string; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsDateString() date?: string; @ApiPropertyOptional({ description: 'Gewonnene Menge in Gramm' }) - @IsOptional() + @IsOptionalNotNull() @IsInt() @IsPositive() quantity?: number; diff --git a/ApiCore-Backend/src/roles/dto/update-role.dto.ts b/ApiCore-Backend/src/roles/dto/update-role.dto.ts index 42ef38b..95c5f7e 100644 --- a/ApiCore-Backend/src/roles/dto/update-role.dto.ts +++ b/ApiCore-Backend/src/roles/dto/update-role.dto.ts @@ -11,6 +11,7 @@ import { PERMISSION_CATALOG, PermissionKey, } from '../../auth/permissions/permission-catalog'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key); @@ -21,14 +22,14 @@ export class UpdateRoleDto { * lookup of the ADMIN role by key intact. */ @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() @MinLength(2) @MaxLength(50) key?: string; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() @MinLength(2) @MaxLength(100) @@ -41,7 +42,7 @@ export class UpdateRoleDto { description?: string; @ApiPropertyOptional({ enum: CATALOG_KEYS, isArray: true }) - @IsOptional() + @IsOptionalNotNull() @IsArray() @IsIn(CATALOG_KEYS, { each: true }) permissionKeys?: PermissionKey[]; diff --git a/ApiCore-Backend/src/statistics/statistics.service.spec.ts b/ApiCore-Backend/src/statistics/statistics.service.spec.ts index 29cd5a1..0877efd 100644 --- a/ApiCore-Backend/src/statistics/statistics.service.spec.ts +++ b/ApiCore-Backend/src/statistics/statistics.service.spec.ts @@ -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', () => { it('returns measurements and treatment counts per colony', async () => { prisma.varroaMeasurement.findMany.mockResolvedValueOnce([ diff --git a/ApiCore-Backend/src/statistics/statistics.service.ts b/ApiCore-Backend/src/statistics/statistics.service.ts index fc6e39e..ef6672d 100644 --- a/ApiCore-Backend/src/statistics/statistics.service.ts +++ b/ApiCore-Backend/src/statistics/statistics.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { isLivingColonyStatus } from '../colonies/colony-status'; @Injectable() export class StatisticsService { @@ -125,8 +126,9 @@ export class StatisticsService { (sum, harvest) => sum + (harvest.netWeight ?? 0), 0, ); - const activeColonies = apiary.colonies.filter( - (c) => c.status === 'ACTIVE', + // Weak, conspicuous and queenless colonies still stand there. + const activeColonies = apiary.colonies.filter((c) => + isLivingColonyStatus(c.status), ).length; const lostColonies = apiary.colonies.filter((c) => ['DECEASED', 'DISSOLVED'].includes(c.status), diff --git a/ApiCore-Backend/src/storage/dto/create-stock-item.dto.ts b/ApiCore-Backend/src/storage/dto/create-stock-item.dto.ts index 654294a..6b4ad6b 100644 --- a/ApiCore-Backend/src/storage/dto/create-stock-item.dto.ts +++ b/ApiCore-Backend/src/storage/dto/create-stock-item.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; import { StockCategory } from '../../../generated/prisma'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class CreateStockItemDto { @ApiProperty() @@ -16,7 +17,7 @@ export class CreateStockItemDto { unit!: string; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsNumber() @Min(0) quantity?: number; diff --git a/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts b/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts index 3d7a302..563fa88 100644 --- a/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts +++ b/ApiCore-Backend/src/storage/dto/update-stock-item.dto.ts @@ -7,4 +7,6 @@ import { CreateStockItemDto } from './create-stock-item.dto'; * difference as an ADJUSTMENT movement, so the movement history still adds up * 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, +}) {} diff --git a/ApiCore-Backend/src/storage/dto/update-storage-container.dto.ts b/ApiCore-Backend/src/storage/dto/update-storage-container.dto.ts index 2530d5b..afb589b 100644 --- a/ApiCore-Backend/src/storage/dto/update-storage-container.dto.ts +++ b/ApiCore-Backend/src/storage/dto/update-storage-container.dto.ts @@ -8,4 +8,5 @@ export class UpdateStorageContainerDto extends PartialType( 'capacity', 'location', ] as const), + { skipNullProperties: false }, ) {} diff --git a/ApiCore-Backend/src/swarm-control/dto/create-swarm-control-check.dto.ts b/ApiCore-Backend/src/swarm-control/dto/create-swarm-control-check.dto.ts index 42eaca7..2b2be9c 100644 --- a/ApiCore-Backend/src/swarm-control/dto/create-swarm-control-check.dto.ts +++ b/ApiCore-Backend/src/swarm-control/dto/create-swarm-control-check.dto.ts @@ -8,6 +8,7 @@ import { IsString, } from 'class-validator'; import { SwarmControlAction, SwarmMood } from '../../../generated/prisma'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class CreateSwarmControlCheckDto { @ApiProperty() @@ -24,17 +25,17 @@ export class CreateSwarmControlCheckDto { queenCellsFound?: number; @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() queenCellsCapped?: boolean; @ApiPropertyOptional({ enum: SwarmMood }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(SwarmMood) swarmMood?: SwarmMood; @ApiPropertyOptional({ enum: SwarmControlAction }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(SwarmControlAction) actionTaken?: SwarmControlAction; diff --git a/ApiCore-Backend/src/swarm-control/dto/update-swarm-control-check.dto.ts b/ApiCore-Backend/src/swarm-control/dto/update-swarm-control-check.dto.ts index 7252645..ed74fcd 100644 --- a/ApiCore-Backend/src/swarm-control/dto/update-swarm-control-check.dto.ts +++ b/ApiCore-Backend/src/swarm-control/dto/update-swarm-control-check.dto.ts @@ -8,4 +8,5 @@ import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto'; */ export class UpdateSwarmControlCheckDto extends PartialType( CreateSwarmControlCheckDto, + { skipNullProperties: false }, ) {} diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts b/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts index 27a48d5..03f863e 100644 --- a/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts +++ b/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts @@ -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 () => { prisma.swarmControlCheck.findMany.mockResolvedValue([check]); diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.service.ts b/ApiCore-Backend/src/swarm-control/swarm-control.service.ts index 054ccfe..0106045 100644 --- a/ApiCore-Backend/src/swarm-control/swarm-control.service.ts +++ b/ApiCore-Backend/src/swarm-control/swarm-control.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { AutomationService } from '../automation/automation.service'; import { SwarmControlCheck } from '../../generated/prisma'; +import { optionalDate } from '../common/patch-values'; import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto'; import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto'; @@ -45,9 +46,7 @@ export class SwarmControlService { data: { ...dto, date: new Date(dto.date), - nextCheckDate: dto.nextCheckDate - ? new Date(dto.nextCheckDate) - : undefined, + nextCheckDate: optionalDate(dto.nextCheckDate), userId: actingUserId, }, }); @@ -73,9 +72,7 @@ export class SwarmControlService { data: { ...dto, date: dto.date ? new Date(dto.date) : undefined, - nextCheckDate: dto.nextCheckDate - ? new Date(dto.nextCheckDate) - : undefined, + nextCheckDate: optionalDate(dto.nextCheckDate), }, }); await this.auditLog.record({ diff --git a/ApiCore-Backend/src/task-templates/dto/create-task-template.dto.ts b/ApiCore-Backend/src/task-templates/dto/create-task-template.dto.ts index 4711301..744799c 100644 --- a/ApiCore-Backend/src/task-templates/dto/create-task-template.dto.ts +++ b/ApiCore-Backend/src/task-templates/dto/create-task-template.dto.ts @@ -4,6 +4,7 @@ import { TaskPriority, TaskTemplateRecurrenceType, } from '../../../generated/prisma'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class CreateTaskTemplateDto { @ApiProperty() @@ -16,7 +17,7 @@ export class CreateTaskTemplateDto { description?: string; @ApiPropertyOptional({ enum: TaskPriority }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(TaskPriority) priority?: TaskPriority; diff --git a/ApiCore-Backend/src/task-templates/dto/update-task-template.dto.ts b/ApiCore-Backend/src/task-templates/dto/update-task-template.dto.ts index 3bd6dc2..75c79a8 100644 --- a/ApiCore-Backend/src/task-templates/dto/update-task-template.dto.ts +++ b/ApiCore-Backend/src/task-templates/dto/update-task-template.dto.ts @@ -1,10 +1,13 @@ 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 { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; -export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto) { +export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto, { + skipNullProperties: false, +}) { @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() active?: boolean; } diff --git a/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts b/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts index c6ce214..8978284 100644 --- a/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts +++ b/ApiCore-Backend/src/task-templates/task-templates.service.spec.ts @@ -131,6 +131,59 @@ describe('TaskTemplatesService', () => { 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 () => { prisma.taskTemplate.findUnique.mockResolvedValue(template); prisma.taskTemplate.update.mockResolvedValue({ diff --git a/ApiCore-Backend/src/task-templates/task-templates.service.ts b/ApiCore-Backend/src/task-templates/task-templates.service.ts index 21bad39..2c90a79 100644 --- a/ApiCore-Backend/src/task-templates/task-templates.service.ts +++ b/ApiCore-Backend/src/task-templates/task-templates.service.ts @@ -15,6 +15,13 @@ import { RecurrenceInput, } from './recurrence'; +function valueOrStored( + value: number | null | undefined, + stored: number | null, +): number | null { + return value === undefined ? stored : value; +} + function toRecurrenceInput(input: { recurrenceType: TaskTemplate['recurrenceType']; recurrenceMonth?: number | null; @@ -84,22 +91,31 @@ export class TaskTemplatesService { dto.recurrenceDayOfMonth !== undefined || dto.intervalDays !== undefined; + // The recurrence that is validated is exactly the one written: an + // omitted field keeps its stored value, null clears it. + const recurrence = recurrenceChanged + ? { + recurrenceType: dto.recurrenceType ?? before.recurrenceType, + recurrenceMonth: valueOrStored( + dto.recurrenceMonth, + before.recurrenceMonth, + ), + recurrenceDayOfMonth: valueOrStored( + dto.recurrenceDayOfMonth, + before.recurrenceDayOfMonth, + ), + intervalDays: valueOrStored(dto.intervalDays, before.intervalDays), + } + : undefined; let nextDueDate: Date | undefined; - if (recurrenceChanged) { - const recurrence = toRecurrenceInput({ - recurrenceType: dto.recurrenceType ?? before.recurrenceType, - recurrenceMonth: dto.recurrenceMonth ?? before.recurrenceMonth, - recurrenceDayOfMonth: - dto.recurrenceDayOfMonth ?? before.recurrenceDayOfMonth, - intervalDays: dto.intervalDays ?? before.intervalDays, - }); + if (recurrence) { this.assertRecurrenceFieldsPresent(recurrence); nextDueDate = computeInitialDueDate(recurrence, new Date()); } const template = await this.prisma.taskTemplate.update({ where: { id }, - data: { ...dto, nextDueDate }, + data: { ...dto, ...recurrence, nextDueDate }, }); await this.auditLog.record({ entityType: 'TaskTemplate', diff --git a/ApiCore-Backend/src/tasks/dto/update-task.dto.ts b/ApiCore-Backend/src/tasks/dto/update-task.dto.ts index 0b6fe95..ad1c4b5 100644 --- a/ApiCore-Backend/src/tasks/dto/update-task.dto.ts +++ b/ApiCore-Backend/src/tasks/dto/update-task.dto.ts @@ -5,10 +5,11 @@ import { TaskRelatedType, TaskStatus, } from '../../../generated/prisma'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class UpdateTaskDto { @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() title?: string; @@ -18,7 +19,7 @@ export class UpdateTaskDto { description?: string; @ApiPropertyOptional({ enum: TaskPriority }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(TaskPriority) priority?: TaskPriority; @@ -33,12 +34,12 @@ export class UpdateTaskDto { assignedUserId?: string; @ApiPropertyOptional({ enum: TaskStatus }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(TaskStatus) status?: TaskStatus; @ApiPropertyOptional({ enum: TaskRelatedType }) - @IsOptional() + @IsOptionalNotNull() @IsEnum(TaskRelatedType) relatedType?: TaskRelatedType; diff --git a/ApiCore-Backend/src/tasks/tasks.service.spec.ts b/ApiCore-Backend/src/tasks/tasks.service.spec.ts index 0cd773e..c73cfba 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.spec.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.spec.ts @@ -18,6 +18,7 @@ describe('TasksService', () => { }; let auditLog: { record: 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' }; @@ -35,7 +36,7 @@ describe('TasksService', () => { taskTemplates = { generateDueTasks: jest.fn().mockResolvedValue(undefined), }; - const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; + calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; service = new TasksService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, @@ -85,4 +86,45 @@ describe('TasksService', () => { expect(updateArg.data.status).toBe('DONE'); 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(); + }, + ); + }); }); diff --git a/ApiCore-Backend/src/tasks/tasks.service.ts b/ApiCore-Backend/src/tasks/tasks.service.ts index 5536f57..b191ed3 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.ts @@ -7,6 +7,7 @@ import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { Task, TaskPriority, TaskStatus } from '../../generated/prisma'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; +import { optionalDate } from '../common/patch-values'; /** * Keeps completedAt in step with status when the status is edited directly: @@ -58,7 +59,7 @@ export class TasksService { const task = await this.prisma.task.create({ data: { ...dto, - dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, + dueDate: optionalDate(dto.dueDate), createdByUserId: actingUserId, }, }); @@ -86,7 +87,7 @@ export class TasksService { where: { id }, data: { ...dto, - dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, + dueDate: optionalDate(dto.dueDate), completedAt: completedAtForStatusChange(before, dto.status), }, }); @@ -98,7 +99,9 @@ export class TasksService { beforeData: before, 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); } else { await this.calendarSync.cancel('TASK', task.id); diff --git a/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts b/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts index e121c60..e491736 100644 --- a/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts +++ b/ApiCore-Backend/src/treatments/dto/update-treatment.dto.ts @@ -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 * repair the colony's history without deleting the record. */ -export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto) {} +export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto, { + skipNullProperties: false, +}) {} diff --git a/ApiCore-Backend/src/treatments/dto/withdrawal-status-query.dto.spec.ts b/ApiCore-Backend/src/treatments/dto/withdrawal-status-query.dto.spec.ts new file mode 100644 index 0000000..695e565 --- /dev/null +++ b/ApiCore-Backend/src/treatments/dto/withdrawal-status-query.dto.spec.ts @@ -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) { + 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']); + }); + }); +}); diff --git a/ApiCore-Backend/src/treatments/dto/withdrawal-status-query.dto.ts b/ApiCore-Backend/src/treatments/dto/withdrawal-status-query.dto.ts new file mode 100644 index 0000000..a7169fe --- /dev/null +++ b/ApiCore-Backend/src/treatments/dto/withdrawal-status-query.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/treatments/treatments.controller.ts b/ApiCore-Backend/src/treatments/treatments.controller.ts index e6b81a2..c4c9be0 100644 --- a/ApiCore-Backend/src/treatments/treatments.controller.ts +++ b/ApiCore-Backend/src/treatments/treatments.controller.ts @@ -13,6 +13,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto'; +import { WithdrawalStatusQueryDto } from './dto/withdrawal-status-query.dto'; import { TreatmentsService } from './treatments.service'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; @@ -28,11 +29,14 @@ export class TreatmentsController { return this.treatmentsService.findAll(); } + /** Active withdrawal windows per colony at `at` (the harvest date), default now. */ @RequirePermission('treatments:read') @Get('withdrawal-status') - getWithdrawalStatus(@Query('colonyIds') colonyIds?: string) { - const ids = colonyIds ? colonyIds.split(',').filter(Boolean) : []; - return this.treatmentsService.getWithdrawalStatus(ids); + getWithdrawalStatus(@Query() query: WithdrawalStatusQueryDto) { + return this.treatmentsService.getWithdrawalStatus( + query.colonyIds ?? [], + query.at ? new Date(query.at) : new Date(), + ); } @RequirePermission('treatments:read') diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts index cb07bc9..e5979ed 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.spec.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { AutomationService } from '../automation/automation.service'; import { TreatmentsService } from './treatments.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -17,6 +17,7 @@ describe('TreatmentsService', () => { colony: { findUnique: jest.Mock }; }; let auditLog: { record: jest.Mock }; + let calendarSync: { enqueue: jest.Mock; cancel: jest.Mock }; const treatment = { id: 'treatment-1', @@ -37,7 +38,7 @@ describe('TreatmentsService', () => { }, }; auditLog = { record: jest.fn() }; - const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; + calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; service = new TreatmentsService( prisma as unknown as PrismaService, 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 () => { prisma.treatment.findMany.mockResolvedValue([treatment]); @@ -125,19 +159,19 @@ describe('TreatmentsService', () => { it('reports the treatment whose withdrawal window reaches furthest into the future', async () => { const now = Date.now(); - const inTenDays = new Date(now + 10 * 24 * 60 * 60 * 1000); - const inFiveDays = new Date(now + 5 * 24 * 60 * 60 * 1000); + const twoDaysAgo = new Date(now - 2 * 24 * 60 * 60 * 1000); + const fiveDaysAgo = new Date(now - 5 * 24 * 60 * 60 * 1000); prisma.treatment.findMany.mockResolvedValue([ { colonyId: 'colony-1', - date: inTenDays, + date: twoDaysAgo, endDate: null, - withdrawalPeriodDays: 0, + withdrawalPeriodDays: 7, product: 'Ameisensäure', }, { colonyId: 'colony-1', - date: inFiveDays, + date: fiveDaysAgo, endDate: null, withdrawalPeriodDays: 21, product: 'Api Bioxal', @@ -167,5 +201,127 @@ describe('TreatmentsService', () => { 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); + }); + }); }); }); diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts index 0bdab48..0a214d2 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.ts @@ -1,9 +1,14 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { AutomationService } from '../automation/automation.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { Treatment } from '../../generated/prisma'; +import { optionalDate } from '../common/patch-values'; import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto'; @@ -13,6 +18,16 @@ export interface WithdrawalStatus { 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, +): Date { + return treatment.startDate && treatment.startDate < treatment.date + ? treatment.startDate + : treatment.date; +} + @Injectable() export class TreatmentsService { constructor( @@ -44,9 +59,19 @@ export class TreatmentsService { // For each colony, returns the treatment whose withdrawal window (its // end date, or start date if the treatment is still ongoing, plus its // withdrawalPeriodDays) reaches furthest into the future - only when - // that window is still active. Colonies with no active window are - // omitted rather than returned as null, since most colonies have none. - async getWithdrawalStatus(colonyIds: string[]): Promise { + // that window is still active at `at`, the harvest date (default now). + // A treatment begun after `at` has no bearing on honey taken at `at`. + // 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 { + // 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) { return []; } @@ -56,15 +81,17 @@ export class TreatmentsService { withdrawalPeriodDays: { not: null }, }, }); - const now = new Date(); const activeByColony = new Map(); for (const treatment of treatments) { + if (treatmentStart(treatment) > at) { + continue; + } const referenceDate = treatment.endDate ?? treatment.date; const activeUntil = new Date(referenceDate); activeUntil.setUTCDate( activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0), ); - if (activeUntil <= now) { + if (activeUntil <= at) { continue; } const current = activeByColony.get(treatment.colonyId); @@ -97,8 +124,8 @@ export class TreatmentsService { // treatment for the Bestandsbuch, the colony may be moved later. apiaryId: colony.apiaryId, date: new Date(dto.date), - startDate: dto.startDate ? new Date(dto.startDate) : undefined, - endDate: dto.endDate ? new Date(dto.endDate) : undefined, + startDate: optionalDate(dto.startDate), + endDate: optionalDate(dto.endDate), userId: actingUserId, }, }); @@ -134,8 +161,8 @@ export class TreatmentsService { ...dto, apiaryId: movedColony ? movedColony.apiaryId : undefined, date: dto.date ? new Date(dto.date) : undefined, - startDate: dto.startDate ? new Date(dto.startDate) : undefined, - endDate: dto.endDate ? new Date(dto.endDate) : undefined, + startDate: optionalDate(dto.startDate), + endDate: optionalDate(dto.endDate), }, }); await this.auditLog.record({ @@ -146,6 +173,9 @@ export class TreatmentsService { beforeData: before, 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; } } diff --git a/ApiCore-Backend/src/users/dto/update-user.dto.ts b/ApiCore-Backend/src/users/dto/update-user.dto.ts index e23818f..86e4102 100644 --- a/ApiCore-Backend/src/users/dto/update-user.dto.ts +++ b/ApiCore-Backend/src/users/dto/update-user.dto.ts @@ -1,10 +1,11 @@ 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 { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; export class UpdateUserDto { @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsString() name?: string; @@ -16,13 +17,13 @@ export class UpdateUserDto { * unique constraint and surfaces as a 409. */ @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @NormalizeEmail() @IsEmail() email?: string; @ApiPropertyOptional({ description: 'Id of an active Role' }) - @IsOptional() + @IsOptionalNotNull() @IsString() @MinLength(1) roleId?: string; diff --git a/ApiCore-Backend/src/varroa/dto/update-varroa-measurement.dto.ts b/ApiCore-Backend/src/varroa/dto/update-varroa-measurement.dto.ts index 0c38283..ea6999d 100644 --- a/ApiCore-Backend/src/varroa/dto/update-varroa-measurement.dto.ts +++ b/ApiCore-Backend/src/varroa/dto/update-varroa-measurement.dto.ts @@ -8,4 +8,5 @@ import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto'; */ export class UpdateVarroaMeasurementDto extends PartialType( CreateVarroaMeasurementDto, + { skipNullProperties: false }, ) {} diff --git a/ApiCore-Backend/src/varroa/varroa.service.spec.ts b/ApiCore-Backend/src/varroa/varroa.service.spec.ts index 20f0de0..57eee4e 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.spec.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.spec.ts @@ -201,5 +201,143 @@ describe('VarroaService', () => { ]; expect(updateArg.data.calculatedValue).toBeUndefined(); }); + + describe('when an input of the derivation is corrected', () => { + const naturalMiteFall = { + ...measurement, + method: 'NATURAL_MITE_FALL', + miteCount: 45, + durationMinutes: 3 * 24 * 60, + calculatedValue: 15, + }; + + async function storedCalculatedValueAfter( + before: object, + dto: Parameters[1], + ): Promise { + prisma.varroaMeasurement.findUnique.mockResolvedValue(before); + prisma.varroaMeasurement.update.mockResolvedValue(measurement); + await service.update('varroa-1', dto, 'user-1'); + const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [ + [{ data: { calculatedValue?: number | null } }], + ]; + return updateArg.data.calculatedValue; + } + + it('recomputes mites/day when the count changes', async () => { + await expect( + storedCalculatedValueAfter(naturalMiteFall, { miteCount: 60 }), + ).resolves.toBe(20); + }); + + it('recomputes mites/day when the duration changes', async () => { + await expect( + storedCalculatedValueAfter(naturalMiteFall, { + durationMinutes: 5 * 24 * 60, + }), + ).resolves.toBe(9); + }); + + it('recomputes when the edit form echoes the stored rate back unchanged', async () => { + await expect( + storedCalculatedValueAfter(naturalMiteFall, { + method: 'NATURAL_MITE_FALL', + miteCount: 60, + durationMinutes: 3 * 24 * 60, + calculatedValue: 15, + }), + ).resolves.toBe(20); + }); + + it('keeps a changed rate as a manual override', async () => { + await expect( + storedCalculatedValueAfter(naturalMiteFall, { + miteCount: 60, + calculatedValue: 99, + }), + ).resolves.toBe(99); + }); + + it('keeps a manually entered rate on an edit that echoes all fields back', async () => { + await expect( + storedCalculatedValueAfter( + { ...naturalMiteFall, calculatedValue: 99 }, + { + method: 'NATURAL_MITE_FALL', + miteCount: 45, + durationMinutes: 3 * 24 * 60, + calculatedValue: 99, + notes: 'Windel sauber', + }, + ), + ).resolves.toBeUndefined(); + }); + + it('clears a rate that can no longer be derived', async () => { + await expect( + storedCalculatedValueAfter(naturalMiteFall, { durationMinutes: 0 }), + ).resolves.toBeNull(); + }); + + it('clears the mites/day rate when the method changes to a non-time-based one', async () => { + await expect( + storedCalculatedValueAfter(naturalMiteFall, { + method: 'POWDERED_SUGAR', + }), + ).resolves.toBeNull(); + }); + + it('derives the rate when the method changes to natural mite fall', async () => { + await expect( + storedCalculatedValueAfter( + { + ...naturalMiteFall, + method: 'POWDERED_SUGAR', + calculatedValue: 3, + }, + { method: 'NATURAL_MITE_FALL' }, + ), + ).resolves.toBe(15); + }); + + // @IsOptional lets an explicit null through the validation pipe. + const clearedValue = { calculatedValue: null as unknown as number }; + + it('re-derives the rate when a natural mite fall value is cleared', async () => { + await expect( + storedCalculatedValueAfter( + { ...naturalMiteFall, calculatedValue: 99 }, + clearedValue, + ), + ).resolves.toBe(15); + }); + + it('clears the manual value of a non-time-based method on request', async () => { + await expect( + storedCalculatedValueAfter( + { + ...naturalMiteFall, + method: 'POWDERED_SUGAR', + calculatedValue: 3, + }, + clearedValue, + ), + ).resolves.toBeNull(); + }); + + it('leaves a manual value of a non-time-based method alone when its count changes', async () => { + await expect( + storedCalculatedValueAfter( + { + ...naturalMiteFall, + method: 'POWDERED_SUGAR', + durationMinutes: null, + calculatedValue: 3, + }, + { miteCount: 12 }, + ), + ).resolves.toBeUndefined(); + }); + }); }); }); diff --git a/ApiCore-Backend/src/varroa/varroa.service.ts b/ApiCore-Backend/src/varroa/varroa.service.ts index 76fdeca..f1847f0 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.ts @@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { AutomationService } from '../automation/automation.service'; -import { VarroaMeasurement } from '../../generated/prisma'; +import { VarroaMeasurement, VarroaMethod } from '../../generated/prisma'; import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; @@ -30,6 +30,56 @@ function computeNaturalMiteFallPerDay( return (miteCount / durationMinutes) * MINUTES_PER_DAY; } +function deriveCalculatedValue( + method: VarroaMethod, + miteCount: number | null | undefined, + durationMinutes: number | null | undefined, +): number | undefined { + return method === 'NATURAL_MITE_FALL' + ? computeNaturalMiteFallPerDay(miteCount, durationMinutes) + : undefined; +} + +// calculatedValue for an edit, undefined meaning "leave the stored value". +// A value that differs from the stored one is a manual override and wins, +// as on create. Otherwise - no value sent, or the edit form echoing the +// stored one back - the value is derived again from the merged record +// whenever an input of the derivation changed (the method, and count or +// duration for a natural mite fall) or nothing is stored yet, exactly as +// create would derive it: a corrected count must never leave a stale +// mites/day rate (and threshold evaluation) behind. Unrelated edits keep +// the stored value, which may have been entered by hand. +function calculatedValueForUpdate( + before: VarroaMeasurement, + dto: UpdateVarroaMeasurementDto, +): number | null | undefined { + const sent = dto.calculatedValue; + if (sent !== undefined && sent !== null && sent !== before.calculatedValue) { + return sent; + } + const method = dto.method ?? before.method; + const miteCount = + dto.miteCount === undefined ? before.miteCount : dto.miteCount; + const durationMinutes = + dto.durationMinutes === undefined + ? before.durationMinutes + : dto.durationMinutes; + const derivationInputChanged = + method !== before.method || + (method === 'NATURAL_MITE_FALL' && + (miteCount !== before.miteCount || + durationMinutes !== before.durationMinutes)); + // An explicit null asks for the value to be cleared - like a create + // without a value, that means "derive it where a rule exists". + const mustDerive = + derivationInputChanged || sent === null || before.calculatedValue === null; + if (!mustDerive) { + return undefined; + } + // null clears a value the merged record no longer supports. + return deriveCalculatedValue(method, miteCount, durationMinutes) ?? null; +} + @Injectable() export class VarroaService { constructor( @@ -67,9 +117,7 @@ export class VarroaService { ): Promise { const calculatedValue = dto.calculatedValue ?? - (dto.method === 'NATURAL_MITE_FALL' - ? computeNaturalMiteFallPerDay(dto.miteCount, dto.durationMinutes) - : undefined); + deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes); const measurement = await this.prisma.varroaMeasurement.create({ data: { ...dto, @@ -95,20 +143,7 @@ export class VarroaService { actingUserId: string, ): Promise { const before = await this.findById(id); - // Only auto-fill calculatedValue when nothing has set it yet - never - // overwrite a value the user (or a prior auto-fill) already stored, - // even if this edit only touches an unrelated field like notes. - const shouldAutoCompute = - dto.calculatedValue === undefined && before.calculatedValue === null; - const method = dto.method ?? before.method; - const calculatedValue = - dto.calculatedValue ?? - (shouldAutoCompute && method === 'NATURAL_MITE_FALL' - ? computeNaturalMiteFallPerDay( - dto.miteCount ?? before.miteCount, - dto.durationMinutes ?? before.durationMinutes, - ) - : undefined); + const calculatedValue = calculatedValueForUpdate(before, dto); const measurement = await this.prisma.varroaMeasurement.update({ where: { id }, data: { diff --git a/ApiCore-Backend/src/webhooks/dto/update-webhook.dto.ts b/ApiCore-Backend/src/webhooks/dto/update-webhook.dto.ts index 0d8c87b..d62e9c0 100644 --- a/ApiCore-Backend/src/webhooks/dto/update-webhook.dto.ts +++ b/ApiCore-Backend/src/webhooks/dto/update-webhook.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsBoolean, IsOptional } from 'class-validator'; +import { IsBoolean } from 'class-validator'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; import { CreateWebhookDto } from './create-webhook.dto'; /** @@ -10,13 +11,15 @@ import { CreateWebhookDto } from './create-webhook.dto'; * A changed URL goes through the same SSRF check as a new one - see * WebhooksService.update. */ -export class UpdateWebhookDto extends PartialType(CreateWebhookDto) { +export class UpdateWebhookDto extends PartialType(CreateWebhookDto, { + skipNullProperties: false, +}) { /** * Pausing a subscription without losing it. There was no way to set this * after creation, so a misbehaving receiver could only be deleted. */ @ApiPropertyOptional() - @IsOptional() + @IsOptionalNotNull() @IsBoolean() active?: boolean; } diff --git a/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts b/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts index 2899ade..2fa9514 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.service.spec.ts @@ -1,5 +1,5 @@ import * as crypto from 'node:crypto'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { WebhooksService } from './webhooks.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; @@ -149,6 +149,47 @@ describe('WebhooksService', () => { await flushDeliveries(); }); + // dispatch() runs after the event's record is committed: a failure must + // not fail that request (the client would retry and store it twice). + describe('dispatch after commit', () => { + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + prisma.webhookSubscription.findMany.mockRejectedValue( + new Error('db down'), + ); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it('never throws when the subscriptions cannot be loaded and logs the record', async () => { + await expect( + service.dispatch('colony.created', { id: 'colony-1' }), + ).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const [message] = errorSpy.mock.calls[0] as [string]; + expect(message).toContain('colony.created colony-1'); + expect(message).toContain('db down'); + }); + + it('also logs a payload without its own id', async () => { + await service.dispatch('stock.changed', { + bottlingBatchIds: ['b-1'], + productIds: [], + }); + + const [message] = errorSpy.mock.calls[0] as [string]; + expect(message).toContain('stock.changed'); + expect(message).toContain('db down'); + }); + }); + describe('update', () => { const existing = { id: 'wh-1', diff --git a/ApiCore-Backend/src/webhooks/webhooks.service.ts b/ApiCore-Backend/src/webhooks/webhooks.service.ts index 0732554..dd0e94d 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.service.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.service.ts @@ -9,9 +9,23 @@ import { WebhookEvent } from './webhook-events'; import { assertPublicWebhookUrl } from './webhook-url-guard'; import { postWebhook } from './webhook-delivery'; import { openWebhookSecret, sealWebhookSecret } from './webhook-secret'; +import { runAfterCommit } from '../common/after-commit'; const SECRET_BYTES = 32; +/** The record an event is about, for the log - some payloads have none. */ +function payloadId(payload: unknown): string { + if ( + typeof payload === 'object' && + payload !== null && + 'id' in payload && + typeof payload.id === 'string' + ) { + return payload.id; + } + return '(no id)'; +} + @Injectable() export class WebhooksService { private readonly logger = new Logger(WebhooksService.name); @@ -135,14 +149,27 @@ export class WebhooksService { * Starts delivery and returns without waiting for the receivers: a slow * or hanging endpoint must never hold up the request that caused the * event. Failures are logged by deliver(). + * + * Called after the event's record is committed, so it never throws - not + * even when the subscriptions cannot be loaded (see runAfterCommit). */ async dispatch(event: WebhookEvent, payload: unknown): Promise { - const subscriptions = await this.prisma.webhookSubscription.findMany({ - where: { active: true, events: { has: event } }, - }); - for (const subscription of subscriptions) { - void this.deliver(subscription, event, payload); - } + await runAfterCommit( + this.logger, + { + action: 'Webhook dispatch', + entityType: event, + entityId: payloadId(payload), + }, + async () => { + const subscriptions = await this.prisma.webhookSubscription.findMany({ + where: { active: true, events: { has: event } }, + }); + for (const subscription of subscriptions) { + void this.deliver(subscription, event, payload); + } + }, + ); } private async deliver( diff --git a/ApiCore-Backend/src/winter-checks/dto/update-winter-check.dto.ts b/ApiCore-Backend/src/winter-checks/dto/update-winter-check.dto.ts index aebf265..62c72e0 100644 --- a/ApiCore-Backend/src/winter-checks/dto/update-winter-check.dto.ts +++ b/ApiCore-Backend/src/winter-checks/dto/update-winter-check.dto.ts @@ -6,4 +6,6 @@ import { CreateWinterCheckDto } from './create-winter-check.dto'; * against the wrong colony or season is a data-entry error, and re-filing it * is the only way to repair the winter record without deleting it. */ -export class UpdateWinterCheckDto extends PartialType(CreateWinterCheckDto) {} +export class UpdateWinterCheckDto extends PartialType(CreateWinterCheckDto, { + skipNullProperties: false, +}) {} diff --git a/ApiCore-Backend/test/apiaries.e2e-spec.ts b/ApiCore-Backend/test/apiaries.e2e-spec.ts index 3077340..06d6f14 100644 --- a/ApiCore-Backend/test/apiaries.e2e-spec.ts +++ b/ApiCore-Backend/test/apiaries.e2e-spec.ts @@ -127,4 +127,23 @@ describe('Apiaries (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .expect(409); }); + + it('refuses to deactivate an apiary that still holds a weak colony', async () => { + const weakColony = await prisma.colony.create({ + data: { + code: `W-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + status: 'WEAK', + }, + }); + try { + await request(app.getHttpServer()) + .post(`/api/v1/apiaries/${apiaryId}/deactivate`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(409); + } finally { + await prisma.colony.delete({ where: { id: weakColony.id } }); + } + }); }); diff --git a/ApiCore-Backend/test/automation.e2e-spec.ts b/ApiCore-Backend/test/automation.e2e-spec.ts index 9532d6f..8710e30 100644 --- a/ApiCore-Backend/test/automation.e2e-spec.ts +++ b/ApiCore-Backend/test/automation.e2e-spec.ts @@ -3,7 +3,7 @@ import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; -import { AutomationTriggerType } from '../generated/prisma'; +import { AutomationTriggerType, Prisma } from '../generated/prisma'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; @@ -212,6 +212,37 @@ describe('Automation rules (e2e)', () => { ); }); + it('lets an admin clear a rule config, which falls back to its defaults', async () => { + const before = await prisma.automationRule.findUniqueOrThrow({ + where: { id: varroaRuleId }, + }); + try { + const response = await request(app.getHttpServer()) + .patch(`/api/v1/automation-rules/${varroaRuleId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ config: null }) + .expect(200); + expect((response.body as { config: unknown }).config).toBeNull(); + + await request(app.getHttpServer()) + .patch(`/api/v1/automation-rules/${varroaRuleId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ active: null }) + .expect(400); + } finally { + // The rule is shared by every suite - put its config back. + await prisma.automationRule.update({ + where: { id: varroaRuleId }, + data: { + config: + before.config === null + ? Prisma.DbNull + : (before.config as Prisma.InputJsonValue), + }, + }); + } + }); + it('lets an admin deactivate a rule, preventing further triggers', async () => { await request(app.getHttpServer()) .patch(`/api/v1/automation-rules/${varroaRuleId}`) diff --git a/ApiCore-Backend/test/backup-import-limit.e2e-spec.ts b/ApiCore-Backend/test/backup-import-limit.e2e-spec.ts new file mode 100644 index 0000000..98b9ba4 --- /dev/null +++ b/ApiCore-Backend/test/backup-import-limit.e2e-spec.ts @@ -0,0 +1,75 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface ScheduleBody { + importLimitBytes: number; + lastBackupExceedsImportLimit: boolean; +} + +const IMPORT_LIMIT_BYTES = 2048; + +/** + * BACKUP_MAX_IMPORT_BYTES is the one limit for both sides: the upload of + * POST /backup/import is cut off at it, and GET /backup/schedule reports it + * so the UI can tell when a backup has outgrown it. + */ +describe('Backup import limit (e2e)', () => { + let app: INestApplication; + const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const password = 'SuperSecret123!'; + let token: string; + + beforeAll(async () => { + process.env.BACKUP_MAX_IMPORT_BYTES = String(IMPORT_LIMIT_BYTES); + app = await createTestApp(); + await app.get(PrismaService).user.create({ + data: { + email: `backup-limit-${suffix}@example.com`, + passwordHash: await bcrypt.hash(password, 12), + name: 'Backup Admin', + role: { connect: { key: 'ADMIN' } }, + }, + }); + token = (await login(app, `backup-limit-${suffix}@example.com`, password)) + .accessToken; + }); + + afterAll(async () => { + await app.close(); + if (originalLimit === undefined) { + delete process.env.BACKUP_MAX_IMPORT_BYTES; + } else { + process.env.BACKUP_MAX_IMPORT_BYTES = originalLimit; + } + }); + + it('reports the configured import limit in the schedule status', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/backup/schedule') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect((response.body as ScheduleBody).importLimitBytes).toBe( + IMPORT_LIMIT_BYTES, + ); + }); + + it('refuses an upload larger than the import limit before reading it in', async () => { + const tooLarge = Buffer.from( + JSON.stringify({ padding: 'x'.repeat(IMPORT_LIMIT_BYTES) }), + ); + + await request(app.getHttpServer()) + .post('/api/v1/backup/import') + .set('Authorization', `Bearer ${token}`) + .field('password', password) + .attach('file', tooLarge, 'apicore-backup.json') + .expect(413); + }); +}); diff --git a/ApiCore-Backend/test/backup.e2e-spec.ts b/ApiCore-Backend/test/backup.e2e-spec.ts new file mode 100644 index 0000000..ca702e1 --- /dev/null +++ b/ApiCore-Backend/test/backup.e2e-spec.ts @@ -0,0 +1,441 @@ +import { INestApplication } from '@nestjs/common'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { AutomationService } from '../src/automation/automation.service'; +import { BackupBundle, BackupService } from '../src/backup/backup.service'; +import { createTestApp } from './utils/create-test-app'; + +type Row = Record; + +// Fixtures plus whole-database exports of the shared test database - well +// beyond the 5 s default when other specs run alongside. +const ROUND_TRIP_TIMEOUT_MS = 60_000; + +// Wipe order: every row before the rows it references. +const SECTIONS_CHILD_FIRST = [ + 'batchMovement', + 'productionInput', + 'shopStockRequest', + 'productionBatch', + 'rawMaterialLot', + 'bottlingBatch', + 'honeyBatchContainerAllocation', + 'honeyBatch', + 'storageContainer', + 'extraction', + 'harvest', + 'apiary', + 'product', + 'automationSequence', + 'calendarSyncOutboxEntry', +] as const; +type Section = (typeof SECTIONS_CHILD_FIRST)[number]; + +// AutomationSequence has no id of its own. +function identity(row: Row): string { + return typeof row.id === 'string' + ? row.id + : `${String(row.triggerType)}|${String(row.scope)}`; +} + +/** + * Export -> loss -> import against the real database: production, raw + * material, stock ledger, number sequence and calendar-sync rows come back + * exactly as exported, which also proves the import writes every parent + * before the rows that reference it. Only this spec's own rows are wiped and + * re-imported, so specs running alongside never see their data rewritten. + */ +describe('Backup round trip (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let backup: BackupService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const own: Record> = Object.fromEntries( + SECTIONS_CHILD_FIRST.map((section) => [section, new Set()]), + ) as Record>; + let userId: string; + let exported: Record; + + const remember = (section: Section, row: T): T => { + own[section].add(row.id); + return row; + }; + + function ownRows(bundle: BackupBundle): Record { + return Object.fromEntries( + SECTIONS_CHILD_FIRST.map((section) => [ + section, + ((bundle.data[section] ?? []) as Row[]).filter((row) => + own[section].has(identity(row)), + ), + ]), + ) as Record; + } + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + backup = app.get(BackupService); + // Makes sure the number-format rules exist (the sequence references one). + await app.get(AutomationService).findAll(); + + const user = await prisma.user.create({ + data: { + email: `backup-${suffix}@example.com`, + passwordHash: 'not-used-for-login', + name: 'Backup', + role: { connect: { key: 'BEEKEEPER' } }, + }, + }); + userId = user.id; + const day = new Date('2026-07-01T00:00:00.000Z'); + + const apiary = remember( + 'apiary', + await prisma.apiary.create({ data: { name: `Stand ${suffix}` } }), + ); + const harvest = remember( + 'harvest', + await prisma.harvest.create({ + data: { apiaryId: apiary.id, userId, date: day }, + }), + ); + const extraction = remember( + 'extraction', + await prisma.extraction.create({ data: { date: day, userId } }), + ); + const container = remember( + 'storageContainer', + await prisma.storageContainer.create({ data: { code: `E-${suffix}` } }), + ); + const honeyBatch = remember( + 'honeyBatch', + await prisma.honeyBatch.create({ + data: { batchNumber: `H-${suffix}`, extractionId: extraction.id }, + }), + ); + remember( + 'honeyBatchContainerAllocation', + await prisma.honeyBatchContainerAllocation.create({ + data: { + honeyBatchId: honeyBatch.id, + storageContainerId: container.id, + amount: 25, + }, + }), + ); + const honey = remember( + 'product', + await prisma.product.create({ data: { name: `Honig ${suffix}` } }), + ); + const candle = remember( + 'product', + await prisma.product.create({ + data: { + name: `Kerze ${suffix}`, + kind: 'MANUFACTURED', + shopCategory: 'KERZEN', + }, + }), + ); + const bottling = remember( + 'bottlingBatch', + await prisma.bottlingBatch.create({ + data: { + honeyBatchId: honeyBatch.id, + productId: honey.id, + date: day, + jarSize: 500, + jarCount: 10, + totalAmount: 5, + bestBeforeDate: new Date('2028-07-01T00:00:00.000Z'), + lotNumber: `L-${suffix}`, + userId, + }, + }), + ); + const wax = remember( + 'rawMaterialLot', + await prisma.rawMaterialLot.create({ + data: { + kind: 'BEESWAX', + lotNumber: `W-${suffix}`, + date: day, + quantity: 1000, + apiaryId: apiary.id, + harvestId: harvest.id, + userId, + }, + }), + ); + const production = remember( + 'productionBatch', + await prisma.productionBatch.create({ + data: { + productId: candle.id, + lotNumber: `K-${suffix}`, + date: day, + quantity: 12, + userId, + }, + }), + ); + remember( + 'productionInput', + await prisma.productionInput.create({ + data: { + productionBatchId: production.id, + rawMaterialLotId: wax.id, + quantity: 300, + }, + }), + ); + const shopRequest = remember( + 'shopStockRequest', + await prisma.shopStockRequest.create({ + data: { + externalId: `order-${suffix}`, + kind: 'SALE', + bottlingBatchId: bottling.id, + quantity: 2, + apiKeyId: 'key-not-in-backup', + }, + }), + ); + for (const data of [ + { + bottlingBatchId: bottling.id, + type: 'SHOP_SALE' as const, + quantity: -2, + shopRequestId: shopRequest.id, + apiKeyId: 'key-not-in-backup', + }, + { + rawMaterialLotId: wax.id, + type: 'CONSUMPTION' as const, + quantity: -300, + userId, + }, + { + productionBatchId: production.id, + type: 'WITHDRAWAL' as const, + quantity: -1, + reason: 'EIGENBEDARF' as const, + userId, + }, + ]) { + remember('batchMovement', await prisma.batchMovement.create({ data })); + } + const sequence = await prisma.automationSequence.create({ + data: { + triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT', + scope: `backup-${suffix}`, + value: 41, + }, + }); + own.automationSequence.add(identity(sequence)); + remember( + 'calendarSyncOutboxEntry', + await prisma.calendarSyncOutboxEntry.create({ + data: { + sourceType: 'TASK', + sourceId: `backup-${suffix}`, + status: 'SYNCED', + graphEventId: `graph-${suffix}`, + }, + }), + ); + }, ROUND_TRIP_TIMEOUT_MS); + + afterAll(async () => { + await app.close(); + }); + + it( + 'exports the production, raw material and stock ledger tables', + async () => { + exported = ownRows(await backup.exportAll()); + + for (const section of SECTIONS_CHILD_FIRST) { + expect({ section, count: exported[section].length }).toEqual({ + section, + count: own[section].size, + }); + } + }, + ROUND_TRIP_TIMEOUT_MS, + ); + + it( + 'restores every one of those rows exactly after they were lost', + async () => { + for (const section of SECTIONS_CHILD_FIRST) { + if (section === 'automationSequence') { + await prisma.automationSequence.deleteMany({ + where: { scope: `backup-${suffix}` }, + }); + continue; + } + const delegate = prisma[section] as unknown as { + deleteMany(args: { + where: { id: { in: string[] } }; + }): Promise; + }; + await delegate.deleteMany({ where: { id: { in: [...own[section]] } } }); + } + expect( + await prisma.batchMovement.count({ + where: { id: { in: [...own.batchMovement] } }, + }), + ).toBe(0); + + await backup.importAll( + { + version: 3, + exportedAt: new Date().toISOString(), + data: exported, + }, + userId, + { trusted: true }, + ); + + const where = (section: Section) => ({ + where: { id: { in: [...own[section]] } }, + }); + expect({ + productionBatch: await prisma.productionBatch.count( + where('productionBatch'), + ), + rawMaterialLot: await prisma.rawMaterialLot.count( + where('rawMaterialLot'), + ), + productionInput: await prisma.productionInput.count( + where('productionInput'), + ), + shopStockRequest: await prisma.shopStockRequest.count( + where('shopStockRequest'), + ), + batchMovement: await prisma.batchMovement.count(where('batchMovement')), + honeyBatchContainerAllocation: + await prisma.honeyBatchContainerAllocation.count( + where('honeyBatchContainerAllocation'), + ), + automationSequence: await prisma.automationSequence.count({ + where: { scope: `backup-${suffix}` }, + }), + calendarSyncOutboxEntry: await prisma.calendarSyncOutboxEntry.count( + where('calendarSyncOutboxEntry'), + ), + }).toEqual({ + productionBatch: 1, + rawMaterialLot: 1, + productionInput: 1, + shopStockRequest: 1, + batchMovement: 3, + honeyBatchContainerAllocation: 1, + automationSequence: 1, + calendarSyncOutboxEntry: 1, + }); + expect(ownRows(await backup.exportAll())).toEqual(exported); + }, + ROUND_TRIP_TIMEOUT_MS, + ); + + it('adds rows from an unsealed file but never rewrites existing ones or takes ledger entries', async () => { + const existing = await prisma.apiary.create({ + data: { name: `Bestand ${suffix}` }, + }); + const addedId = `apiary-added-${suffix}`; + const forgedId = `forged-movement-${suffix}`; + + const summary = await backup.importFile( + { + version: 2, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { + apiary: [ + { id: existing.id, name: 'Überschrieben' }, + { id: addedId, name: `Ergänzt ${suffix}` }, + ], + batchMovement: [ + { + id: forgedId, + productionBatchId: [...own.productionBatch][0], + type: 'CORRECTION', + quantity: 1000, + }, + ], + }, + }, + userId, + ); + + expect(summary).toEqual( + expect.objectContaining({ sealed: false, skipped: ['batchMovement'] }), + ); + expect( + (await prisma.apiary.findUniqueOrThrow({ where: { id: existing.id } })) + .name, + ).toBe(`Bestand ${suffix}`); + expect( + (await prisma.apiary.findUniqueOrThrow({ where: { id: addedId } })).name, + ).toBe(`Ergänzt ${suffix}`); + expect( + await prisma.batchMovement.findUnique({ where: { id: forgedId } }), + ).toBeNull(); + }); + + it('removes the file a restored document row replaced', async () => { + const directory = path.join( + path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'), + 'colony', + ); + await fs.mkdir(directory, { recursive: true }); + const oldFile = path.join(directory, `backup-e2e-${suffix}.pdf`); + await fs.writeFile(oldFile, '%PDF-1.4 old'); + const document = await prisma.document.create({ + data: { + entityType: 'COLONY', + entityId: `colony-${suffix}`, + fileName: 'alt.pdf', + mimeType: 'application/pdf', + sizeBytes: 12, + storagePath: oldFile, + }, + }); + + await backup.importAll( + { + version: 3, + exportedAt: new Date().toISOString(), + data: { + document: [ + { + id: document.id, + entityType: 'COLONY', + entityId: `colony-${suffix}`, + fileName: 'neu.pdf', + mimeType: 'application/pdf', + sizeBytes: 12, + contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'), + }, + ], + }, + }, + userId, + { trusted: true }, + ); + + const restored = await prisma.document.findUniqueOrThrow({ + where: { id: document.id }, + }); + expect(restored.storagePath).not.toBe(oldFile); + await expect(fs.readFile(restored.storagePath, 'utf8')).resolves.toBe( + '%PDF-1.4 new', + ); + await expect(fs.access(oldFile)).rejects.toThrow(); + await fs.unlink(restored.storagePath); + }); +}); diff --git a/ApiCore-Backend/test/calendar-sync.e2e-spec.ts b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts new file mode 100644 index 0000000..cbc4862 --- /dev/null +++ b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts @@ -0,0 +1,291 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service'; +import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service'; +import { GraphClientService } from '../src/calendar-sync/graph-client.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +/** + * The M365 outbox against the real database, with Microsoft Graph replaced + * by a recorder: several workers (pods, the cron and "Jetzt + * synchronisieren") must never write the same entry twice, and an edit + * that arrives while an entry is being synced must not get lost. + */ +describe('Calendar sync outbox (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let calendarSync: CalendarSyncService; + let graph: GraphClientService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const taskIds: string[] = []; + let createdEvents: string[]; + let updateEvent: jest.SpyInstance; + let eventCounter = 0; + const adminEmail = `calendar-sync-e2e-${suffix}@example.com`; + const password = 'SuperSecret123!'; + let token: string; + + const settings = { + id: 'singleton', + enabled: true, + groupId: 'group-e2e', + syncTask: true, + syncInspection: true, + syncTreatment: true, + syncFeeding: true, + syncHarvest: true, + syncQueenAssignment: true, + updatedAt: new Date(), + }; + + const wait = (ms: number) => new Promise((done) => setTimeout(done, ms)); + + /** Polls until the condition holds - for work that runs in the background. */ + async function eventually(condition: () => Promise | boolean) { + for (let i = 0; i < 100; i++) { + if (await condition()) return; + await wait(50); + } + throw new Error('condition not met in time'); + } + + async function queueTask(title: string) { + const task = await prisma.task.create({ + data: { title: `${title} ${suffix}`, dueDate: new Date('2027-05-01') }, + }); + taskIds.push(task.id); + await prisma.calendarSyncOutboxEntry.create({ + data: { sourceType: 'TASK', sourceId: task.id }, + }); + return task; + } + + const entryFor = (taskId: string) => + prisma.calendarSyncOutboxEntry.findUniqueOrThrow({ + where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: taskId } }, + }); + + /** Graph calls made for the tasks of this spec (not other leftovers). */ + const createsFor = (title: string) => + createdEvents.filter((created) => created === `${title} ${suffix}`); + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + calendarSync = app.get(CalendarSyncService); + graph = app.get(GraphClientService); + await prisma.user.create({ + data: { + email: adminEmail, + passwordHash: await bcrypt.hash(password, 12), + name: 'Calendar Sync Admin', + role: { connect: { key: 'ADMIN' } }, + }, + }); + token = (await login(app, adminEmail, password)).accessToken; + }); + + beforeEach(() => { + createdEvents = []; + jest + .spyOn(app.get(CalendarSyncSettingsService), 'get') + .mockResolvedValue(settings); + jest.spyOn(graph, 'isConfigured').mockReturnValue(true); + // A real Graph round trip takes a moment - long enough for a second + // worker to read the same row in the meantime. + jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => { + createdEvents.push(input.title); + await wait(50); + eventCounter += 1; + return `event-${suffix}-${eventCounter}`; + }); + updateEvent = jest.spyOn(graph, 'updateEvent').mockResolvedValue(undefined); + jest.spyOn(graph, 'deleteEvent').mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(async () => { + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceType: 'TASK', sourceId: { in: taskIds } }, + }); + await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); + const admin = await prisma.user.findUnique({ + where: { email: adminEmail }, + }); + if (admin) { + await prisma.refreshToken.deleteMany({ where: { userId: admin.id } }); + await prisma.auditLog.deleteMany({ where: { userId: admin.id } }); + await prisma.user.delete({ where: { id: admin.id } }); + } + await app.close(); + }); + + it('creates each event once when several workers run at the same time', async () => { + const titles = ['Parallel A', 'Parallel B', 'Parallel C']; + const tasks: { id: string }[] = []; + for (const title of titles) { + tasks.push(await queueTask(title)); + } + + // Two pods' cron ticks plus a click on "Jetzt synchronisieren". + await Promise.all([ + calendarSync.processOutbox(), + calendarSync.processOutbox(), + calendarSync.runNow(), + ]); + + for (const title of titles) { + expect(createsFor(title)).toHaveLength(1); + } + for (const task of tasks) { + const entry = await entryFor(task.id); + expect(entry.status).toBe('SYNCED'); + expect(entry.graphEventId).toMatch(/^event-/); + expect(entry.claimedAt).toBeNull(); + } + }); + + it('syncs an edit that arrives while the entry is being written to M365', async () => { + const task = await queueTask('Edited during sync'); + jest + .spyOn(graph, 'createEvent') + .mockImplementationOnce(async (_g, input) => { + createdEvents.push(input.title); + // The task's due date is changed right now, on another request. + await calendarSync.enqueue('TASK', task.id); + return `event-${suffix}-edited`; + }); + + await calendarSync.processOutbox(); + + const afterFirstPass = await entryFor(task.id); + // Not SYNCED: the newer state still has to go out - onto the same event. + expect(afterFirstPass.status).toBe('PENDING'); + expect(afterFirstPass.graphEventId).toBe(`event-${suffix}-edited`); + expect(afterFirstPass.claimedAt).toBeNull(); + + await calendarSync.processOutbox(); + + expect(createsFor('Edited during sync')).toHaveLength(1); + expect(updateEvent).toHaveBeenCalledWith( + 'group-e2e', + `event-${suffix}-edited`, + expect.objectContaining({ title: `Edited during sync ${suffix}` }), + ); + expect((await entryFor(task.id)).status).toBe('SYNCED'); + }); + + it('takes over an entry whose worker died, but not one still being worked on', async () => { + const abandoned = await queueTask('Abandoned claim'); + const busy = await queueTask('Busy claim'); + await prisma.calendarSyncOutboxEntry.update({ + where: { + sourceType_sourceId: { sourceType: 'TASK', sourceId: abandoned.id }, + }, + data: { claimedAt: new Date(Date.now() - 60 * 60 * 1000) }, + }); + await prisma.calendarSyncOutboxEntry.update({ + where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: busy.id } }, + data: { claimedAt: new Date() }, + }); + + await calendarSync.processOutbox(); + + expect(createsFor('Abandoned claim')).toHaveLength(1); + expect((await entryFor(abandoned.id)).status).toBe('SYNCED'); + expect(createsFor('Busy claim')).toHaveLength(0); + expect((await entryFor(busy.id)).status).toBe('PENDING'); + + // The busy worker finishes - its live claim would otherwise keep + // getStatus().running true for the other tests. + await prisma.calendarSyncOutboxEntry.update({ + where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: busy.id } }, + data: { claimedAt: null }, + }); + }); + + it('retries a FAILED entry on "Jetzt synchronisieren", not on the cron tick', async () => { + const task = await queueTask('Failed before'); + await prisma.calendarSyncOutboxEntry.update({ + where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id } }, + data: { status: 'FAILED', attempts: 5, lastError: 'Graph down' }, + }); + + await calendarSync.processOutbox(); + expect(createsFor('Failed before')).toHaveLength(0); + expect((await entryFor(task.id)).status).toBe('FAILED'); + + await calendarSync.runNow(); + + expect(createsFor('Failed before')).toHaveLength(1); + const entry = await entryFor(task.id); + expect(entry.status).toBe('SYNCED'); + expect(entry.attempts).toBe(0); + expect(entry.lastError).toBeNull(); + }); + + describe('API', () => { + const auth = () => ({ Authorization: `Bearer ${token}` }); + + it('answers "Jetzt synchronisieren" with 202 while the pass still runs', async () => { + const task = await queueTask('Started via API'); + const title = `Started via API ${suffix}`; + let releaseCreate: () => void = () => undefined; + jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => { + createdEvents.push(input.title); + if (input.title === title) { + await new Promise((resolve) => { + releaseCreate = resolve; + }); + } + eventCounter += 1; + return `event-${suffix}-${eventCounter}`; + }); + + const response = await request(app.getHttpServer()) + .post('/api/v1/calendar/sync/run-now') + .set(auth()) + .expect(202); + + expect((response.body as { running: boolean }).running).toBe(true); + await eventually(() => createsFor('Started via API').length === 1); + expect((await entryFor(task.id)).status).toBe('PENDING'); + const whileRunning = await request(app.getHttpServer()) + .get('/api/v1/calendar/sync/status') + .set(auth()) + .expect(200); + expect((whileRunning.body as { running: boolean }).running).toBe(true); + + releaseCreate(); + await eventually(async () => !(await calendarSync.getStatus()).running); + + expect((await entryFor(task.id)).status).toBe('SYNCED'); + }, 15_000); + + it('rejects a group id that is not an Entra object id with 400', async () => { + const response = await request(app.getHttpServer()) + .patch('/api/v1/calendar/sync/settings') + .set(auth()) + .send({ groupId: '../users/admin@example.com' }) + .expect(400); + + expect(JSON.stringify(response.body)).toContain('Gruppen-ID'); + }); + + // PATCH null rule: the switches cannot hold null - 400, not a 500. + it('rejects null on a switch with 400', async () => { + await request(app.getHttpServer()) + .patch('/api/v1/calendar/sync/settings') + .set(auth()) + .send({ enabled: null }) + .expect(400); + }); + }); +}); diff --git a/ApiCore-Backend/test/colonies.e2e-spec.ts b/ApiCore-Backend/test/colonies.e2e-spec.ts index 9a325c2..dd9e4f5 100644 --- a/ApiCore-Backend/test/colonies.e2e-spec.ts +++ b/ApiCore-Backend/test/colonies.e2e-spec.ts @@ -213,4 +213,21 @@ describe('Colonies (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .expect(409); }); + + it('rejects reactivating a weak colony, which never ended', async () => { + await prisma.colony.update({ + where: { id: colonyId }, + data: { status: 'WEAK' }, + }); + + await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/reactivate`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(409); + + const colony = await prisma.colony.findUniqueOrThrow({ + where: { id: colonyId }, + }); + expect(colony.status).toBe('WEAK'); + }); }); diff --git a/ApiCore-Backend/test/dashboard.e2e-spec.ts b/ApiCore-Backend/test/dashboard.e2e-spec.ts index b38effd..07a127e 100644 --- a/ApiCore-Backend/test/dashboard.e2e-spec.ts +++ b/ApiCore-Backend/test/dashboard.e2e-spec.ts @@ -26,6 +26,7 @@ describe('Dashboard (e2e)', () => { let readonlyToken: string; let apiaryId: string; let colonyId: string; + let queenId: string; beforeAll(async () => { app = await createTestApp(); @@ -56,10 +57,34 @@ describe('Dashboard (e2e)', () => { }, }); colonyId = colony.id; + + // The WEAK colony is still a living colony: its overdue swarm check and + // its old queen have to reach the alerts like those of an ACTIVE one. + const queen = await prisma.queen.create({ + data: { + code: `Q-${Date.now()}`, + hatchYear: new Date().getFullYear() - 4, + }, + }); + queenId = queen.id; + await prisma.colony.update({ + where: { id: colonyId }, + data: { currentQueenId: queenId }, + }); + await prisma.swarmControlCheck.create({ + data: { + colonyId, + userId: readonlyId, + date: new Date('2026-01-01'), + nextCheckDate: new Date('2026-01-08'), + }, + }); }); afterAll(async () => { + await prisma.swarmControlCheck.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.queen.deleteMany({ where: { id: queenId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: readonlyId } }); await prisma.auditLog.deleteMany({ where: { userId: readonlyId } }); @@ -89,4 +114,17 @@ describe('Dashboard (e2e)', () => { ), ).toBe(true); }); + + it('raises swarm-check and old-queen alerts for a weak colony', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/dashboard/alerts') + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const types = (response.body as AlertBody[]) + .filter((alert) => alert.relatedId === colonyId) + .map((alert) => alert.type); + expect(types).toEqual( + expect.arrayContaining(['OVERDUE_SWARM_CHECK', 'OLD_QUEEN']), + ); + }); }); diff --git a/ApiCore-Backend/test/honey-batches.e2e-spec.ts b/ApiCore-Backend/test/honey-batches.e2e-spec.ts index ec2359f..d7fbf7b 100644 --- a/ApiCore-Backend/test/honey-batches.e2e-spec.ts +++ b/ApiCore-Backend/test/honey-batches.e2e-spec.ts @@ -30,11 +30,15 @@ describe('Honey batches (e2e)', () => { let prisma: PrismaService; const beekeeperEmail = `honey-batches-e2e-beekeeper-${Date.now()}@example.com`; const staffEmail = `honey-batches-e2e-staff-${Date.now()}@example.com`; + const adminEmail = `honey-batches-e2e-admin-${Date.now()}@example.com`; const password = 'SuperSecret123!'; let beekeeperId: string; let staffId: string; let beekeeperToken: string; let staffToken: string; + // Correcting a batch (PATCH) is an ADMIN permission. + let adminId: string; + let adminToken: string; let apiaryId: string; let colonyId: string; let harvestId: string; @@ -66,8 +70,18 @@ describe('Honey batches (e2e)', () => { }, }); staffId = staff.id; + const admin = await prisma.user.create({ + data: { + email: adminEmail, + passwordHash, + name: 'Admin', + role: { connect: { key: 'ADMIN' } }, + }, + }); + adminId = admin.id; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; staffToken = (await login(app, staffEmail, password)).accessToken; + adminToken = (await login(app, adminEmail, password)).accessToken; const apiary = await prisma.apiary.create({ data: { name: `Stand ${Date.now()}` }, @@ -121,13 +135,13 @@ describe('Honey batches (e2e)', () => { await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ - where: { userId: { in: [beekeeperId, staffId] } }, + where: { userId: { in: [beekeeperId, staffId, adminId] } }, }); await prisma.auditLog.deleteMany({ - where: { userId: { in: [beekeeperId, staffId] } }, + where: { userId: { in: [beekeeperId, staffId, adminId] } }, }); await prisma.user.deleteMany({ - where: { id: { in: [beekeeperId, staffId] } }, + where: { id: { in: [beekeeperId, staffId, adminId] } }, }); await app.close(); }); @@ -207,4 +221,38 @@ describe('Honey batches (e2e)', () => { expect(relocations[1].toStorageContainerId).toBe(containerAId); expect(relocations[1].fromStorageContainerId).toBeNull(); }); + + it('removes the container on null without recording a relocation', async () => { + const response = await request(app.getHttpServer()) + .patch(`/api/v1/honey-batches/${honeyBatchId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ storageContainerId: null }) + .expect(200); + expect((response.body as HoneyBatchBody).storageContainerId).toBeNull(); + + const relocations = await prisma.honeyBatchRelocation.count({ + where: { honeyBatchId }, + }); + expect(relocations).toBe(2); + const auditEntry = await prisma.auditLog.findFirst({ + where: { + entityType: 'HoneyBatch', + entityId: honeyBatchId, + action: 'UPDATE', + userId: adminId, + }, + orderBy: { timestamp: 'desc' }, + }); + expect(auditEntry?.afterData).toEqual( + expect.objectContaining({ storageContainerId: null }), + ); + }); + + it('rejects null for the required batch number', () => { + return request(app.getHttpServer()) + .patch(`/api/v1/honey-batches/${honeyBatchId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ batchNumber: null }) + .expect(400); + }); }); diff --git a/ApiCore-Backend/test/inspections.e2e-spec.ts b/ApiCore-Backend/test/inspections.e2e-spec.ts index 7b02e4a..6b76eb1 100644 --- a/ApiCore-Backend/test/inspections.e2e-spec.ts +++ b/ApiCore-Backend/test/inspections.e2e-spec.ts @@ -111,6 +111,16 @@ describe('Inspections (e2e)', () => { .expect(201); inspectionId = (response.body as InspectionBody).id; expect(inspectionId).toEqual(expect.any(String)); + + const auditEntry = await prisma.auditLog.findFirst({ + where: { + entityType: 'Inspection', + entityId: inspectionId, + action: 'CREATE', + userId: staffId, + }, + }); + expect(auditEntry).not.toBeNull(); }); it('lists inspections for the colony', async () => { diff --git a/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts b/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts new file mode 100644 index 0000000..070ad48 --- /dev/null +++ b/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts @@ -0,0 +1,218 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { NotificationsService } from '../src/notifications/notifications.service'; +import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service'; +import { AutomationService } from '../src/automation/automation.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +/** + * Side effects that follow a committed write (automation rules, calendar + * sync, webhooks) must never turn that write into an error response: the + * offline queue would send the request again and store the record twice - + * for treatments that is a duplicate entry in the Bestandsbuch. + */ +describe('Post-commit side effects (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const email = `post-commit-e2e-${suffix}@example.com`; + const password = 'SuperSecret123!'; + let token: string; + let apiaryId: string; + + const server = () => app.getHttpServer(); + const auth = () => ({ Authorization: `Bearer ${token}` }); + + async function createColony(code: string) { + return prisma.colony.create({ + data: { + code: `${code}-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + } + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: 'Post Commit', + role: { connect: { key: 'ADMIN' } }, + }, + }); + token = (await login(app, email, password)).accessToken; + apiaryId = ( + await prisma.apiary.create({ data: { name: `Post Commit ${suffix}` } }) + ).id; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('when a side effect fails', () => { + beforeEach(() => { + // Automation: the task is created, then the warning fails. + jest + .spyOn(app.get(NotificationsService), 'record') + .mockRejectedValue(new Error('notifications down')); + // Calendar sync: enqueue cannot read its settings. + jest + .spyOn(app.get(CalendarSyncSettingsService), 'get') + .mockRejectedValue(new Error('calendar settings down')); + // Webhooks: the subscriptions cannot be loaded. + jest + .spyOn(prisma.webhookSubscription, 'findMany') + .mockRejectedValue(new Error('webhook subscriptions down')); + }); + + it('still answers 201 for an inspection and replays it on a retry', async () => { + const colony = await createColony('PC-INSP'); + const key = `post-commit-${suffix}-inspection`; + const body = { + colonyId: colony.id, + date: '2026-07-01T00:00:00.000Z', + queenSeen: false, + eggsPresent: false, + }; + + const first = await request(server()) + .post('/api/v1/inspections') + .set(auth()) + .set('Idempotency-Key', key) + .send(body) + .expect(201); + const retry = await request(server()) + .post('/api/v1/inspections') + .set(auth()) + .set('Idempotency-Key', key) + .send(body) + .expect(201); + + expect((retry.body as { id: string }).id).toBe( + (first.body as { id: string }).id, + ); + expect( + await prisma.inspection.count({ where: { colonyId: colony.id } }), + ).toBe(1); + // The rule still did its main job before the warning failed. + expect( + await prisma.task.count({ + where: { + relatedId: colony.id, + sourceAutomationTrigger: 'QUEENLESS_NO_EGGS', + }, + }), + ).toBe(1); + }); + + it('still answers 201 for a treatment when the rule evaluation fails', async () => { + jest + .spyOn(prisma.automationRule, 'upsert') + .mockRejectedValue(new Error('automation rules down')); + const colony = await createColony('PC-TREAT'); + const key = `post-commit-${suffix}-treatment`; + const body = { + colonyId: colony.id, + date: '2026-08-01T00:00:00.000Z', + product: 'Oxalsäure', + }; + + const first = await request(server()) + .post('/api/v1/treatments') + .set(auth()) + .set('Idempotency-Key', key) + .send(body) + .expect(201); + const retry = await request(server()) + .post('/api/v1/treatments') + .set(auth()) + .set('Idempotency-Key', key) + .send(body) + .expect(201); + + expect((retry.body as { id: string }).id).toBe( + (first.body as { id: string }).id, + ); + expect( + await prisma.treatment.count({ where: { colonyId: colony.id } }), + ).toBe(1); + }); + }); + + describe('automation tasks', () => { + it('leaves one open task when several triggers for the same colony arrive at once', async () => { + const automation = app.get(AutomationService); + // The same object crossing the threshold on several pods/requests at + // once: every evaluation sees "no open task yet" unless checking and + // creating are serialised. + const item = await prisma.stockItem.create({ + data: { + name: `Race ${suffix}`, + category: 'CONSUMABLE', + unit: 'Stück', + quantity: 1, + minThreshold: 5, + }, + }); + + await Promise.all( + Array.from({ length: 8 }, () => automation.evaluateStockItem(item)), + ); + + expect( + await prisma.task.count({ + where: { + relatedId: item.id, + sourceAutomationTrigger: 'STOCK_ITEM_LOW', + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + }), + ).toBe(1); + }); + + it('leaves one open task when several measurements for the same colony arrive at once', async () => { + const colony = await createColony('PC-RACE-HTTP'); + const measurement = { + colonyId: colony.id, + date: '2026-08-15T00:00:00.000Z', + method: 'NATURAL_MITE_FALL', + miteCount: 20, + // One day on the board: 20 mites per day, above the default 10. + durationMinutes: 24 * 60, + }; + + await Promise.all( + Array.from({ length: 6 }, () => + request(server()) + .post('/api/v1/varroa-measurements') + .set(auth()) + .send(measurement) + .expect(201), + ), + ); + + expect( + await prisma.task.count({ + where: { + relatedId: colony.id, + sourceAutomationTrigger: 'VARROA_THRESHOLD', + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + }), + ).toBe(1); + }); + }); +}); diff --git a/ApiCore-Backend/test/swarm-control.e2e-spec.ts b/ApiCore-Backend/test/swarm-control.e2e-spec.ts index 5f3c58e..9247f29 100644 --- a/ApiCore-Backend/test/swarm-control.e2e-spec.ts +++ b/ApiCore-Backend/test/swarm-control.e2e-spec.ts @@ -10,6 +10,7 @@ interface SwarmControlCheckBody { id: string; colonyId: string; queenCellsCapped: boolean; + nextCheckDate: string | null; } describe('Swarm control checks (e2e)', () => { @@ -129,4 +130,33 @@ describe('Swarm control checks (e2e)', () => { true, ); }); + + it('clears the next check date on null and audits the edit', async () => { + const response = await request(app.getHttpServer()) + .patch(`/api/v1/swarm-control-checks/${checkId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ nextCheckDate: null }) + .expect(200); + expect((response.body as SwarmControlCheckBody).nextCheckDate).toBeNull(); + const auditEntry = await prisma.auditLog.findFirst({ + where: { + entityType: 'SwarmControlCheck', + entityId: checkId, + action: 'UPDATE', + userId: beekeeperId, + }, + orderBy: { timestamp: 'desc' }, + }); + expect(auditEntry?.afterData).toEqual( + expect.objectContaining({ nextCheckDate: null }), + ); + }); + + it('rejects null for a field that cannot be empty', () => { + return request(app.getHttpServer()) + .patch(`/api/v1/swarm-control-checks/${checkId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ swarmMood: null }) + .expect(400); + }); }); diff --git a/ApiCore-Backend/test/task-templates.e2e-spec.ts b/ApiCore-Backend/test/task-templates.e2e-spec.ts index 14a76e0..91b1229 100644 --- a/ApiCore-Backend/test/task-templates.e2e-spec.ts +++ b/ApiCore-Backend/test/task-templates.e2e-spec.ts @@ -10,6 +10,9 @@ interface TaskTemplateBody { id: string; nextDueDate: string; active: boolean; + recurrenceType: string; + recurrenceDayOfMonth: number | null; + intervalDays: number | null; } describe('TaskTemplates (e2e)', () => { @@ -96,6 +99,42 @@ describe('TaskTemplates (e2e)', () => { expect(template.nextDueDate.getTime()).toBeGreaterThan(yesterday.getTime()); }); + it('rejects a recurrence emptied of a field it needs', () => { + return request(app.getHttpServer()) + .patch(`/api/v1/task-templates/${templateId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ recurrenceType: 'MONTHLY', recurrenceDayOfMonth: null }) + .expect(400); + }); + + it('clears the interval when switching to a monthly recurrence', async () => { + const response = await request(app.getHttpServer()) + .patch(`/api/v1/task-templates/${templateId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + recurrenceType: 'MONTHLY', + recurrenceDayOfMonth: 5, + intervalDays: null, + }) + .expect(200); + const body = response.body as TaskTemplateBody; + expect(body.recurrenceType).toBe('MONTHLY'); + expect(body.recurrenceDayOfMonth).toBe(5); + expect(body.intervalDays).toBeNull(); + const auditEntry = await prisma.auditLog.findFirst({ + where: { + entityType: 'TaskTemplate', + entityId: templateId, + action: 'UPDATE', + userId: beekeeperId, + }, + orderBy: { timestamp: 'desc' }, + }); + expect(auditEntry?.afterData).toEqual( + expect.objectContaining({ intervalDays: null }), + ); + }); + it('deactivates a template', async () => { const response = await request(app.getHttpServer()) .post(`/api/v1/task-templates/${templateId}/deactivate`) diff --git a/ApiCore-Backend/test/tasks.e2e-spec.ts b/ApiCore-Backend/test/tasks.e2e-spec.ts index dc8161f..36598df 100644 --- a/ApiCore-Backend/test/tasks.e2e-spec.ts +++ b/ApiCore-Backend/test/tasks.e2e-spec.ts @@ -10,6 +10,7 @@ interface TaskBody { id: string; status: string; completedAt: string | null; + dueDate: string | null; } describe('Tasks (e2e)', () => { @@ -95,6 +96,41 @@ describe('Tasks (e2e)', () => { expect(tasks.some((entry) => entry.id === taskId)).toBe(true); }); + it('clears the due date on null and audits the edit', async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/tasks/${taskId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ dueDate: '2026-08-01T00:00:00.000Z' }) + .expect(200); + + const response = await request(app.getHttpServer()) + .patch(`/api/v1/tasks/${taskId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ dueDate: null }) + .expect(200); + expect((response.body as TaskBody).dueDate).toBeNull(); + const auditEntry = await prisma.auditLog.findFirst({ + where: { + entityType: 'Task', + entityId: taskId, + action: 'UPDATE', + userId: staffId, + }, + orderBy: { timestamp: 'desc' }, + }); + expect(auditEntry?.afterData).toEqual( + expect.objectContaining({ dueDate: null }), + ); + }); + + it('rejects null for the required title', () => { + return request(app.getHttpServer()) + .patch(`/api/v1/tasks/${taskId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ title: null }) + .expect(400); + }); + it('completes a task', async () => { const response = await request(app.getHttpServer()) .post(`/api/v1/tasks/${taskId}/complete`) diff --git a/ApiCore-Backend/test/treatments.e2e-spec.ts b/ApiCore-Backend/test/treatments.e2e-spec.ts index 3c948f3..2b53074 100644 --- a/ApiCore-Backend/test/treatments.e2e-spec.ts +++ b/ApiCore-Backend/test/treatments.e2e-spec.ts @@ -9,6 +9,14 @@ import { login } from './utils/api'; interface TreatmentBody { id: string; colonyId: string; + startDate: string | null; + endDate: string | null; +} + +interface WithdrawalStatusBody { + colonyId: string; + activeUntil: string; + product: string; } describe('Treatments (e2e)', () => { @@ -116,4 +124,134 @@ describe('Treatments (e2e)', () => { const treatments = response.body as TreatmentBody[]; expect(treatments.some((entry) => entry.id === treatmentId)).toBe(true); }); + + it('clears start and end date on null and audits the edit', async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/treatments/${treatmentId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ startDate: '2026-07-01', endDate: '2026-07-14' }) + .expect(200); + + const response = await request(app.getHttpServer()) + .patch(`/api/v1/treatments/${treatmentId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ startDate: null, endDate: null }) + .expect(200); + const treatment = response.body as TreatmentBody; + expect(treatment.startDate).toBeNull(); + expect(treatment.endDate).toBeNull(); + const auditEntry = await prisma.auditLog.findFirst({ + where: { + entityType: 'Treatment', + entityId: treatmentId, + action: 'UPDATE', + userId: beekeeperId, + }, + orderBy: { timestamp: 'desc' }, + }); + expect(auditEntry?.afterData).toEqual( + expect.objectContaining({ startDate: null, endDate: null }), + ); + }); + + it.each(['date', 'product'])('rejects null for the required %s', (field) => + request(app.getHttpServer()) + .patch(`/api/v1/treatments/${treatmentId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ [field]: null }) + .expect(400), + ); + + describe('withdrawal status for a harvest date', () => { + async function withdrawalStatusAt(at?: string) { + const response = await request(app.getHttpServer()) + .get('/api/v1/treatments/withdrawal-status') + .query( + at === undefined + ? { colonyIds: colonyId } + : { colonyIds: colonyId, at }, + ) + .set('Authorization', `Bearer ${staffToken}`) + .expect(200); + return response.body as WithdrawalStatusBody[]; + } + + beforeAll(async () => { + // Ended 2026-07-01 with 14 days withdrawal: harvestable from 2026-07-15. + await request(app.getHttpServer()) + .post('/api/v1/treatments') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + colonyId, + date: '2026-06-20', + endDate: '2026-07-01', + product: 'Oxalsäure', + withdrawalPeriodDays: 14, + }) + .expect(201); + }); + + it('reports the window as active on a harvest date inside it', async () => { + await expect(withdrawalStatusAt('2026-07-10')).resolves.toEqual([ + { + colonyId, + activeUntil: '2026-07-15T00:00:00.000Z', + product: 'Oxalsäure', + }, + ]); + }); + + it('accepts a full ISO timestamp', async () => { + await expect( + withdrawalStatusAt('2026-07-14T23:59:59.000Z'), + ).resolves.toHaveLength(1); + }); + + it('reports nothing once the window has ended by the harvest date', async () => { + await expect(withdrawalStatusAt('2026-07-15')).resolves.toEqual([]); + }); + + it('ignores a treatment that starts after the harvest date', async () => { + await expect(withdrawalStatusAt('2026-06-01')).resolves.toEqual([]); + }); + + it('evaluates against now without a date', async () => { + await expect(withdrawalStatusAt()).resolves.toEqual([]); + }); + + it('rejects asking for more than 200 colonies at once', async () => { + const ids = Array.from( + { length: 201 }, + (_, index) => `cl${index.toString().padStart(23, '0')}`, + ); + const response = await request(app.getHttpServer()) + .get('/api/v1/treatments/withdrawal-status') + .query({ colonyIds: ids.join(',') }) + .set('Authorization', `Bearer ${staffToken}`) + .expect(400); + expect(JSON.stringify(response.body)).toContain( + 'colonyIds darf höchstens 200 Volk-IDs enthalten', + ); + }); + + it.each(["colony'a", 'colony;drop', 'c'.repeat(65)])( + 'rejects the invalid colony id %s', + (badId) => + request(app.getHttpServer()) + .get('/api/v1/treatments/withdrawal-status') + .query({ colonyIds: `${colonyId},${badId}` }) + .set('Authorization', `Bearer ${staffToken}`) + .expect(400), + ); + + it.each(['gestern', '2026-02-30', '20260715', '2026-07-15T10Z', '2026'])( + 'rejects the invalid date %s', + (at) => + request(app.getHttpServer()) + .get('/api/v1/treatments/withdrawal-status') + .query({ colonyIds: colonyId, at }) + .set('Authorization', `Bearer ${staffToken}`) + .expect(400), + ); + }); }); diff --git a/ApiCore-Backend/test/varroa.e2e-spec.ts b/ApiCore-Backend/test/varroa.e2e-spec.ts index 45a8947..76f6dad 100644 --- a/ApiCore-Backend/test/varroa.e2e-spec.ts +++ b/ApiCore-Backend/test/varroa.e2e-spec.ts @@ -9,6 +9,7 @@ import { login } from './utils/api'; interface VarroaMeasurementBody { id: string; colonyId: string; + calculatedValue: number | null; } describe('Varroa measurements (e2e)', () => { @@ -105,8 +106,27 @@ describe('Varroa measurements (e2e)', () => { durationMinutes: 4320, }) .expect(201); - measurementId = (response.body as VarroaMeasurementBody).id; + const created = response.body as VarroaMeasurementBody; + measurementId = created.id; expect(measurementId).toEqual(expect.any(String)); + expect(created.calculatedValue).toBe(4); + }); + + it('recomputes mites/day when the count is corrected', async () => { + // The edit dialog sends the whole form, the stored rate included. + const response = await request(app.getHttpServer()) + .patch(`/api/v1/varroa-measurements/${measurementId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + method: 'NATURAL_MITE_FALL', + miteCount: 30, + durationMinutes: 4320, + calculatedValue: 4, + }) + .expect(200); + expect((response.body as VarroaMeasurementBody).calculatedValue).toBe(10); }); it('lists measurements for the colony', async () => { diff --git a/ApiCore-Backend/test/webhooks.e2e-spec.ts b/ApiCore-Backend/test/webhooks.e2e-spec.ts index cbb032c..f8f6b2c 100644 --- a/ApiCore-Backend/test/webhooks.e2e-spec.ts +++ b/ApiCore-Backend/test/webhooks.e2e-spec.ts @@ -177,6 +177,16 @@ describe('Webhooks (e2e)', () => { expect(receivedRequests).toHaveLength(0); }); + it.each(['url', 'events', 'active'])( + 'rejects null for the required %s', + (field) => + request(app.getHttpServer()) + .patch(`/api/v1/webhooks/${subscriptionId}`) + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ [field]: null }) + .expect(400), + ); + it('lets an admin delete a webhook subscription', () => { return request(app.getHttpServer()) .delete(`/api/v1/webhooks/${subscriptionId}`) diff --git a/ApiCore-Frontend/Dockerfile b/ApiCore-Frontend/Dockerfile index f84b787..a709446 100644 --- a/ApiCore-Frontend/Dockerfile +++ b/ApiCore-Frontend/Dockerfile @@ -6,6 +6,9 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npx next typegen +# Qualitaets-Gate: ein Image entsteht nur, wenn Lint, Typecheck und +# Unit-Tests gruen sind. +RUN npm run lint && npx tsc --noEmit && npm test RUN npm run build FROM node:22-alpine AS runtime diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.spec.tsx new file mode 100644 index 0000000..2dd0968 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.spec.tsx @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ApiaryEditCard } from "./apiary-edit-card"; +import { updateApiary } from "@/lib/apiaries/api"; +import type { Apiary } from "@/lib/apiaries/types"; + +vi.mock("@/lib/apiaries/api", () => ({ + updateApiary: vi.fn(), + deactivateApiary: vi.fn(), + reactivateApiary: vi.fn(), +})); + +const apiary: Apiary = { + id: "a-1", + name: "Obstwiese", + description: "Hinter der Scheune", + location: "Altenmünster", + latitude: 48.47, + longitude: 10.6, + address: "Hauptstr. 1", + status: "ACTIVE", + notes: "Schlüssel beim Nachbarn", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deactivatedAt: null, +}; + +describe("ApiaryEditCard", () => { + beforeEach(() => { + vi.mocked(updateApiary).mockReset(); + vi.mocked(updateApiary).mockResolvedValue(apiary); + }); + + it("sends emptied optional fields as null so they are cleared, not kept", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + + for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen"]) { + await userEvent.clear(screen.getByLabelText(new RegExp(`^${label}`))); + } + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect(updateApiary).toHaveBeenCalledWith("a-1", { + name: "Obstwiese", + description: null, + location: null, + latitude: null, + longitude: null, + address: null, + notes: null, + }); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx index 24a8819..46f36ea 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx @@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { deactivateApiary, reactivateApiary, updateApiary } from "@/lib/apiaries/api"; import { Apiary } from "@/lib/apiaries/types"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { errorMessage } from "@/lib/forms/error-message"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -29,12 +30,12 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) { mutationFn: () => updateApiary(apiary.id, { name, - description: description || undefined, - location: location || undefined, - latitude: latitude ? Number(latitude) : undefined, - longitude: longitude ? Number(longitude) : undefined, - address: address || undefined, - notes: notes || undefined, + description: emptyToNull(description), + location: emptyToNull(location), + latitude: emptyToNull(latitude, Number), + longitude: emptyToNull(longitude, Number), + address: emptyToNull(address), + notes: emptyToNull(notes), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx index 4b906b1..a048297 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/weather-card.tsx @@ -7,6 +7,7 @@ import { TrachtprognoseRating } from "@/lib/weather/types"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ErrorState, LoadingState } from "@/components/query-state"; +import { todayLocalIsoDate } from "@/lib/dates"; const RATING_LABELS: Record = { GUT: "Gute Tracht", @@ -74,7 +75,7 @@ export function WeatherCard({ const trachtprognose = trachtQuery.data; const weather = weatherQuery.data; - const today = new Date().toISOString().slice(0, 10); + const today = todayLocalIsoDate(); const forecastDays = (weather?.daily ?? []).filter((day) => day.date >= today).slice(0, 7); const todaySuitability = forecastDays[0]?.inspectionSuitable ?? null; diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts b/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts index f5c59d3..26751f2 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/apiaries/new/apiary-form-values.ts @@ -1,4 +1,5 @@ import type { ApiaryInput } from "@/lib/apiaries/types"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface ApiaryFormValues { @@ -35,18 +36,13 @@ export function formatCoordinate(value: number): string { return value.toFixed(6); } -const textOrUndefined = (value: string) => - value.trim() ? value.trim() : undefined; -const numberOrUndefined = (value: string) => - value.trim() ? Number(value) : undefined; - export function toApiaryInput(values: ApiaryFormValues): ApiaryInput { return { name: values.name.trim(), - location: textOrUndefined(values.location), - latitude: numberOrUndefined(values.latitude), - longitude: numberOrUndefined(values.longitude), - address: textOrUndefined(values.address), - notes: textOrUndefined(values.notes), + location: emptyToNull(values.location), + latitude: emptyToNull(values.latitude, Number), + longitude: emptyToNull(values.longitude, Number), + address: emptyToNull(values.address), + notes: emptyToNull(values.notes), }; } diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx index 690c0f4..a316a1b 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/new/page.spec.tsx @@ -63,11 +63,11 @@ describe("NewApiaryPage", () => { expect(vi.mocked(createApiary).mock.calls[0][0]).toEqual({ name: "Hausgarten", - location: undefined, - latitude: undefined, - longitude: undefined, + location: null, + latitude: null, + longitude: null, address: "Dorfstraße 1", - notes: undefined, + notes: null, }); expect(push).toHaveBeenCalledWith("/apiaries/apiary-3"); }); diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx index 69adbea..e1eee22 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/bottling-edit-dialog.tsx @@ -11,13 +11,8 @@ import { fetchProducts } from "@/lib/products/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RecordSelect } from "@/components/form/record-select"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { Dialog, DialogContent, @@ -25,13 +20,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; - -/** - * Sentinel for "no product": the select needs a selectable item to clear the - * assignment again, and an empty string would be read as "nothing chosen yet" - * and render the placeholder instead. - */ -const NO_PRODUCT = "__none__"; +import { toApiDate } from "@/lib/dates"; export function BottlingEditDialog({ bottlingBatch, @@ -83,9 +72,8 @@ function BottlingEditForm({ const [jarSize, setJarSize] = useState(String(bottlingBatch.jarSize)); const [jarCount, setJarCount] = useState(String(bottlingBatch.jarCount)); const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber); - const [productId, setProductId] = useState( - bottlingBatch.productId ?? NO_PRODUCT, - ); + // "" = "Kein Produkt" - choosing it clears the assignment again. + const [productId, setProductId] = useState(bottlingBatch.productId ?? ""); const [error, setError] = useState(null); const { data: honeyBatches } = useQuery({ @@ -127,12 +115,12 @@ function BottlingEditForm({ mutationFn: () => updateBottlingBatch(bottlingBatch.id, { honeyBatchId, - date: new Date(date).toISOString(), - bestBeforeDate: new Date(bestBeforeDate).toISOString(), + date: toApiDate(date), + bestBeforeDate: toApiDate(bestBeforeDate), jarSize: Number(jarSize), jarCount: Number(jarCount), lotNumber, - productId: productId === NO_PRODUCT ? null : productId, + productId: emptyToNull(productId), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] }); @@ -157,21 +145,14 @@ function BottlingEditForm({
- + onChange={setHoneyBatchId} + items={honeyBatchOptions} + getLabel={(honeyBatch) => honeyBatch.batchNumber} + placeholder="Charge wählen" + />
@@ -221,22 +202,14 @@ function BottlingEditForm({
- + onChange={setProductId} + items={productOptions} + getLabel={(product) => product.name} + noneLabel="Kein Produkt" + />
diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx index 6b3e50f..03fa8ec 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx @@ -9,13 +9,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RecordSelect } from "@/components/form/record-select"; export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }) { const [templateId, setTemplateId] = useState(""); @@ -57,21 +51,14 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }

) : ( <> - + onChange={setTemplateId} + items={activeTemplates} + getLabel={(template) => template.name} + placeholder="Vorlage wählen" + />
{eventsQuery.isLoading ? : null} @@ -254,10 +231,10 @@ export default function CalendarPage() { ) : ( daysOfMonthWithEvents.map((day) => ( )) )} @@ -275,7 +252,7 @@ export default function CalendarPage() {
))} {gridDays.map((day) => { - const key = toDateKey(day); + const key = toLocalIsoDate(day); const dayEvents = eventsByDay.get(key) ?? []; const isCurrentMonth = day.getMonth() === month.getMonth(); const isToday = key === todayKey; diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx index 093f20a..38a43ac 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ColonyActions } from "./colony-actions"; import { fetchColonies, mergeColony } from "@/lib/colonies/api"; -import type { Colony } from "@/lib/colonies/types"; +import type { Colony, ColonyStatus } from "@/lib/colonies/types"; vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn(), @@ -21,7 +21,7 @@ vi.mock("@/lib/queens/api", () => ({ })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); -function colony(id: string, code: string): Colony { +function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colony { return { id, code, @@ -29,7 +29,7 @@ function colony(id: string, code: string): Colony { hiveType: null, originText: null, colonyType: "PRODUCTION_COLONY", - status: "ACTIVE", + status, strength: null, broodStatus: null, feedStatus: null, @@ -45,13 +45,13 @@ function colony(id: string, code: string): Colony { const target = colony("colony-1", "V-01"); const other = colony("colony-2", "V-02"); -function renderActions() { +function renderActions(subject: Colony = target) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); return render( - + , ); } @@ -102,3 +102,41 @@ describe("ColonyActions – merge", () => { expect(mergeColony).not.toHaveBeenCalled(); }); }); + +describe("ColonyActions – colonies that need attention", () => { + beforeEach(() => { + vi.mocked(fetchColonies).mockResolvedValue([ + target, + other, + colony("colony-3", "V-03", "QUEENLESS"), + colony("colony-4", "V-04", "DISSOLVED"), + ]); + }); + + it("lets a weak colony be moved, split, merged and dissolved instead of only reactivated", () => { + renderActions(colony("colony-5", "V-05", "WEAK")); + + for (const name of ["Umsetzen", "Ableger bilden", "Vereinigen", "Auflösen"]) { + expect(screen.getByRole("button", { name })).toBeEnabled(); + } + expect(screen.queryByRole("button", { name: "Reaktivieren" })).not.toBeInTheDocument(); + }); + + it("only offers reactivation for a colony that has ended", () => { + renderActions(colony("colony-4", "V-04", "DISSOLVED")); + + expect(screen.getByRole("button", { name: "Reaktivieren" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Umsetzen" })).not.toBeInTheDocument(); + }); + + it("offers a queenless colony for merging - and no ended one", async () => { + renderActions(); + + await userEvent.click(screen.getByRole("button", { name: "Vereinigen" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("combobox")); + const options = await screen.findAllByRole("option"); + + expect(options.map((option) => option.textContent)).toEqual(["V-02", "V-03 (Weisellos)"]); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx index b4954b6..8877842 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx @@ -12,6 +12,7 @@ import { } from "@/lib/colonies/api"; import { Colony, ColonyType } from "@/lib/colonies/types"; import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; +import { isLivingColony } from "@/lib/colonies/status"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; import { assignQueenColony, fetchQueens } from "@/lib/queens/api"; @@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { Field } from "@/components/form/field"; +import { LivingColonySelectField } from "@/components/colony-select-field"; import { Dialog, DialogContent, @@ -29,13 +31,8 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; +import { RecordSelect } from "@/components/form/record-select"; function invalidateColony( queryClient: ReturnType, @@ -95,7 +92,7 @@ function MoveDialog({ colony }: { colony: Colony }) { + } @@ -118,18 +115,14 @@ function MoveDialog({ colony }: { colony: Colony }) { > {({ id }) => ( - + apiary.name} + placeholder="Bienenstand wählen" + /> )} @@ -177,7 +170,7 @@ function SplitDialog({ colony }: { colony: Colony }) { + } @@ -208,21 +201,13 @@ function SplitDialog({ colony }: { colony: Colony }) { {({ id }) => ( - + onChange={setColonyType} + values={COLONY_TYPES} + labels={COLONY_TYPE_LABELS} + /> )} @@ -259,7 +244,7 @@ function AssignQueenDialog({ colony }: { colony: Colony }) { + } @@ -278,20 +263,14 @@ function AssignQueenDialog({ colony }: { colony: Colony }) { > {({ id }) => ( - + queen.status === "ACTIVE")} + getLabel={(queen) => queen.code} + placeholder="Königin wählen" + /> )} @@ -315,10 +294,10 @@ function MergeDialog({ colony }: { colony: Colony }) { const [mergedColonyId, setMergedColonyId] = useState(""); const [error, setError] = useState(null); const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); - const candidates = (colonies ?? []).filter( - (candidate) => candidate.id !== colony.id && candidate.status === "ACTIVE", + const merged = (colonies ?? []).find( + (candidate) => + candidate.id === mergedColonyId && candidate.id !== colony.id && isLivingColony(candidate), ); - const merged = candidates.find((candidate) => candidate.id === mergedColonyId); const mutation = useMutation({ mutationFn: () => mergeColony(colony.id, { mergedColonyIds: [mergedColonyId] }), @@ -338,7 +317,7 @@ function MergeDialog({ colony }: { colony: Colony }) { + } @@ -351,28 +330,17 @@ function MergeDialog({ colony }: { colony: Colony }) {
- - {({ id }) => ( - - )} - + { + setMergedColonyId(colonyId); + setError(null); + }} + /> - {colony.status === "ACTIVE" ? ( + {isLivingColony(colony) ? ( <> diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.spec.tsx new file mode 100644 index 0000000..761ccc8 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.spec.tsx @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ColonyEditCard } from "./colony-edit-card"; +import { updateColony } from "@/lib/colonies/api"; +import type { Colony } from "@/lib/colonies/types"; + +vi.mock("@/lib/colonies/api", () => ({ updateColony: vi.fn() })); +vi.mock("@/lib/apiaries/api", () => ({ + fetchApiaries: vi.fn().mockResolvedValue([{ id: "apiary-1", name: "Obstwiese" }]), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const colony: Colony = { + id: "colony-1", + code: "V-01", + apiaryId: "apiary-1", + hiveType: "Zander", + originText: "Schwarm 2025", + colonyType: "PRODUCTION_COLONY", + status: "ACTIVE", + strength: 7, + broodStatus: "GOOD", + feedStatus: "AVERAGE", + healthStatus: "CRITICAL", + notes: "sanft", + currentQueenId: null, + createdAt: "", + updatedAt: "", + deactivatedAt: null, +}; + +function renderCard() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +async function choose(trigger: RegExp, option: string) { + await userEvent.click(screen.getByRole("combobox", { name: trigger })); + await userEvent.click(await screen.findByRole("option", { name: option })); +} + +describe("ColonyEditCard", () => { + beforeEach(() => { + vi.mocked(updateColony).mockReset(); + vi.mocked(updateColony).mockResolvedValue(colony); + }); + + it("clears emptied fields by sending null instead of keeping the old value", async () => { + renderCard(); + + await userEvent.clear(screen.getByLabelText("Beutentyp")); + await userEvent.clear(screen.getByLabelText("Herkunft")); + await userEvent.clear(screen.getByLabelText("Notizen")); + await choose(/Volksstärke/, "Nicht erfasst"); + await choose(/Gesundheitsstatus/, "Nicht erfasst"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateColony).mock.calls[0]; + expect(input).toMatchObject({ + hiveType: null, + originText: null, + notes: null, + strength: null, + healthStatus: null, + broodStatus: "GOOD", + feedStatus: "AVERAGE", + }); + }); + + it("names the apiary in its select, not by its internal id", async () => { + renderCard(); + + const apiary = screen.getByRole("combobox", { name: "Bienenstand" }); + await waitFor(() => expect(apiary).toHaveTextContent("Obstwiese")); + expect(apiary).not.toHaveTextContent("apiary-1"); + }); + + it("names every choice by its label and sends the chosen values", async () => { + renderCard(); + + expect(screen.getByRole("combobox", { name: "Brutstatus" })).toHaveTextContent("Gut"); + expect(screen.getByRole("combobox", { name: "Volksstärke" })).toHaveTextContent("7"); + await choose(/^Status/, "Weisellos"); + await choose(/^Volktyp/, "Ableger"); + await choose(/Brutstatus/, "Kritisch"); + await choose(/Volksstärke/, "9"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateColony).mock.calls[0]; + expect(input).toMatchObject({ + status: "QUEENLESS", + colonyType: "NUCLEUS_COLONY", + broodStatus: "CRITICAL", + strength: 9, + }); + }); + + it("shows each condition as its coloured badge, in the field and in the list", async () => { + renderCard(); + + const brood = screen.getByRole("combobox", { name: "Brutstatus" }); + expect(brood.querySelector('[data-slot="badge"]')?.className).toContain("condition-good"); + await userEvent.click(brood); + const critical = await screen.findByRole("option", { name: "Kritisch" }); + expect(critical.querySelector('[data-slot="badge"]')?.className).toContain( + "condition-critical", + ); + expect( + (await screen.findByRole("option", { name: "Nicht erfasst" })).querySelector( + '[data-slot="badge"]', + ), + ).toBeNull(); + }); + + it("offers only the living statuses for free choice", async () => { + renderCard(); + + await userEvent.click(screen.getByRole("combobox", { name: /^Status/ })); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "Aktiv", + "Schwach", + "Auffällig", + "Weisellos", + ]); + }); + + it("keeps untouched values and trims text", async () => { + renderCard(); + + await userEvent.type(screen.getByLabelText("Beutentyp"), " "); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateColony).mock.calls[0]; + expect(input).toMatchObject({ + hiveType: "Zander", + originText: "Schwarm 2025", + strength: 7, + healthStatus: "CRITICAL", + notes: "sanft", + }); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx index dfe97ee..cedf4ac 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx @@ -12,23 +12,19 @@ import { COLONY_CONDITION_TONE_CLASS, COLONY_EDITABLE_STATUSES, COLONY_STATUS_LABELS, - COLONY_STRENGTH_OPTIONS, + COLONY_STRENGTH_LABELS, COLONY_TYPES, COLONY_TYPE_LABELS, } from "@/lib/colonies/labels"; import { errorMessage } from "@/lib/forms/error-message"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RecordSelect } from "@/components/form/record-select"; +import { EnumSelect } from "@/components/form/enum-select"; import { Card, CardContent, @@ -36,6 +32,9 @@ import { CardTitle, } from "@/components/ui/card"; +/** The choice that clears an optional rating - sent as null. */ +const NOT_RECORDED_LABEL = "Nicht erfasst"; + function ConditionSelect({ id, label, @@ -44,32 +43,25 @@ function ConditionSelect({ }: { id: string; label: string; - value: ColonyCondition | undefined; - onChange: (value: ColonyCondition) => void; + value: ColonyCondition | null; + onChange: (value: ColonyCondition | null) => void; }) { return (
- + onChange(next || null)} + renderOption={(condition) => ( + + {COLONY_CONDITION_LABELS[condition]} + + )} + />
); } @@ -83,14 +75,10 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { const [hiveType, setHiveType] = useState(colony.hiveType ?? ""); const [originText, setOriginText] = useState(colony.originText ?? ""); const [strength, setStrength] = useState(colony.strength?.toString() ?? ""); - const [broodStatus, setBroodStatus] = useState( - colony.broodStatus ?? undefined, - ); - const [feedStatus, setFeedStatus] = useState( - colony.feedStatus ?? undefined, - ); - const [healthStatus, setHealthStatus] = useState( - colony.healthStatus ?? undefined, + const [broodStatus, setBroodStatus] = useState(colony.broodStatus); + const [feedStatus, setFeedStatus] = useState(colony.feedStatus); + const [healthStatus, setHealthStatus] = useState( + colony.healthStatus, ); const [notes, setNotes] = useState(colony.notes ?? ""); @@ -106,13 +94,14 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { code, status, colonyType, - hiveType: hiveType || undefined, - originText: originText || undefined, - strength: strength ? Number(strength) : undefined, + // Emptied fields go out as null - omitted, the old value would stay. + hiveType: emptyToNull(hiveType), + originText: emptyToNull(originText), + strength: emptyToNull(strength, Number), broodStatus, feedStatus, healthStatus, - notes: notes || undefined, + notes: emptyToNull(notes), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["colonies"] }); @@ -138,21 +127,15 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
- + // A colony always stands somewhere: there is no "none" to pick. + onChange={(value) => setApiaryId(value || apiaryId)} + items={apiaries} + getLabel={(apiary) => apiary.name} + placeholder="Bienenstand wählen" + />

Eine Korrektur wird wie ein Umsetzen in der Standort-Historie festgehalten. @@ -169,36 +152,23 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {

- +
- + onChange={setColonyType} + />
@@ -218,18 +188,13 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
- +
updateFeeding(feeding.id, { colonyId, - date: new Date(date).toISOString(), + date: toApiDate(date), feedType, - product: product || undefined, - amount: amount ? Number(amount) : undefined, + product: emptyToNull(product), + // Required (checked in handleSubmit) - never cleared. + amount: Number(amount), unit, - batchNumber: batchNumber || undefined, - notes: notes || undefined, + batchNumber: emptyToNull(batchNumber), + notes: emptyToNull(notes), }), onSuccess: () => { // After re-filing, both the colony it left and the one it joined @@ -129,21 +126,13 @@ export function FeedingEditDialog({ feeding }: { feeding: Feeding }) { {({ id }) => ( - + onChange={setFeedType} + values={FEED_TYPES} + labels={FEED_TYPE_LABELS} + /> )}
diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.spec.tsx index 40addc0..adc9ddb 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.spec.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { InspectionEditDialog } from "./inspection-edit-dialog"; @@ -25,7 +25,7 @@ const inspection: Inspection = { cappedBroodPresent: true, eggsPresent: true, queenSeen: false, - queenCellsPresent: false, + queenCellsPresent: null, swarmMood: null, gentleness: 2, combBehavior: null, @@ -63,11 +63,8 @@ describe("InspectionEditDialog", () => { vi.mocked(updateInspection).mockResolvedValue(inspection); await openDialog(); - // Order: Brutbild, Sanftmut, Wabensitz, Schwarmstimmung, Futter. - const gentleness = screen.getAllByRole("radiogroup")[1]; - await userEvent.click( - gentleness.querySelector('[aria-label="5 von 5"]') as HTMLElement, - ); + const gentleness = screen.getByRole("radiogroup", { name: /^Sanftmut/ }); + await userEvent.click(within(gentleness).getByRole("radio", { name: "5 von 5" })); await userEvent.click(screen.getByRole("button", { name: "Speichern" })); const [id, input] = vi.mocked(updateInspection).mock.calls[0]; @@ -80,6 +77,62 @@ describe("InspectionEditDialog", () => { }); }); + it("shows stored answers and keeps 'not checked' as not checked", async () => { + await openDialog(); + const checked = (group: string) => + within(screen.getByRole("radiogroup", { name: group })) + .getAllByRole("radio") + .find((radio) => radio.getAttribute("aria-checked") === "true")?.textContent; + + expect(checked("Stifte vorhanden")).toBe("Ja"); + expect(checked("Königin gesehen")).toBe("Nein"); + expect(checked("Weiselzellen")).toBe("Nicht geprüft"); + }); + + it("clears an observation back to not checked by sending null", async () => { + vi.mocked(updateInspection).mockResolvedValue(inspection); + await openDialog(); + + await userEvent.click( + within(screen.getByRole("radiogroup", { name: "Stifte vorhanden" })).getByRole("radio", { + name: "Nicht geprüft", + }), + ); + await userEvent.click( + within(screen.getByRole("radiogroup", { name: /^Futter/ })).getByRole("radio", { + name: "Nicht geprüft", + }), + ); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateInspection).mock.calls[0]; + expect(input).toMatchObject({ + eggsPresent: null, + queenSeen: false, + queenCellsPresent: null, + feedStatus: null, + }); + }); + + it("clears an emptied number and an un-tapped rating by sending null", async () => { + vi.mocked(updateInspection).mockResolvedValue(inspection); + await openDialog(); + + await userEvent.clear(screen.getByLabelText(/^Volksstärke/)); + const gentleness = screen.getByRole("radiogroup", { name: /^Sanftmut/ }); + await userEvent.click(within(gentleness).getByRole("radio", { name: "2 von 5" })); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateInspection).mock.calls[0]; + expect(input).toMatchObject({ + colonyStrength: null, + gentleness: null, + broodPattern: 3, + weather: null, + notes: null, + }); + }); + it("shows a missing date next to the field and does not save", async () => { await openDialog(); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx index b6ecd8c..6fa1bba 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx @@ -11,7 +11,8 @@ import { ColonySelectField } from "@/components/colony-select-field"; import { Field } from "@/components/form/field"; import { FormSection } from "@/components/form/form-section"; import { RatingScale } from "@/components/form/rating-scale"; -import { ToggleChip } from "@/components/form/toggle-chip"; +import { ChoiceGroup } from "@/components/form/choice-group"; +import { TriStateField } from "@/components/form/tri-state-field"; import { Dialog, DialogContent, @@ -19,10 +20,9 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; import { FEED_STATUS_OPTIONS, - FeedStatus, + INSPECTION_OBSERVATIONS, InspectionFormValues, toInspectionInput, } from "../../inspections/new/inspection-form-values"; @@ -42,17 +42,18 @@ function valuesFromInspection(inspection: Inspection): InspectionFormValues { colonyStrength: inspection.colonyStrength?.toString() ?? "", broodAmount: inspection.broodAmount ?? "", broodPattern: inspection.broodPattern, - openBroodPresent: inspection.openBroodPresent ?? false, - cappedBroodPresent: inspection.cappedBroodPresent ?? false, - queenSeen: inspection.queenSeen ?? false, - eggsPresent: inspection.eggsPresent ?? true, - queenCellsPresent: inspection.queenCellsPresent ?? false, + // null = "Nicht geprüft" stays null; it must not become a "Nein". + openBroodPresent: inspection.openBroodPresent, + cappedBroodPresent: inspection.cappedBroodPresent, + queenSeen: inspection.queenSeen, + eggsPresent: inspection.eggsPresent, + queenCellsPresent: inspection.queenCellsPresent, swarmMood: inspection.swarmMood, gentleness: inspection.gentleness, combBehavior: inspection.combBehavior, // Kept verbatim even if it is not one of the three known values, so // saving without touching it never changes it. - feedStatus: (inspection.feedStatus ?? "ausreichend") as FeedStatus, + feedStatus: inspection.feedStatus, honeySuperStatus: inspection.honeySuperStatus ?? "", healthIssues: inspection.healthIssues ?? "", varroaIssues: inspection.varroaIssues ?? "", @@ -158,35 +159,22 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection }) - -
- set("queenSeen", checked)} - /> - set("eggsPresent", checked)} - /> - set("openBroodPresent", checked)} - /> - set("cappedBroodPresent", checked)} - /> - set("queenCellsPresent", checked)} - /> + +
+ {INSPECTION_OBSERVATIONS.map((observation) => ( + set(observation.key, answer)} + /> + ))}
- + {({ id, labelId, describedBy }) => ( {RATINGS.map((rating) => ( - + {({ id, labelId }) => ( -
- {FEED_STATUS_OPTIONS.map((option) => { - const selected = values.feedStatus === option.value; - return ( - - ); - })} -
+ + {({ id, labelId }) => ( + set("feedStatus", feedStatus)} + className="grid-cols-2 sm:grid-cols-4" + /> + )} +
({ fetchColonies: vi.fn() })); +vi.mock("@/lib/swarm-control/api", () => ({ updateSwarmControlCheck: vi.fn() })); + +const check: SwarmControlCheck = { + id: "s-1", + colonyId: "colony-1", + userId: "u-1", + date: "2026-05-10T00:00:00.000Z", + queenCellsFound: 3, + queenCellsCapped: false, + swarmMood: "LOW", + actionTaken: "NONE", + notes: "Spielnäpfchen", + nextCheckDate: "2026-05-17T00:00:00.000Z", + createdAt: "2026-05-10T00:00:00.000Z", +}; + +describe("SwarmControlEditDialog", () => { + beforeEach(() => { + vi.mocked(fetchColonies).mockResolvedValue([ + { id: "colony-1", code: "V-01", status: "ACTIVE" }, + ] as never); + vi.mocked(updateSwarmControlCheck).mockReset(); + vi.mocked(updateSwarmControlCheck).mockResolvedValue(check); + }); + + it("sends an emptied count, next check date and notes as null", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" })); + await screen.findByRole("dialog"); + + await userEvent.clear(screen.getByLabelText(/^Weiselzellen gefunden/)); + await userEvent.clear(screen.getByLabelText(/^Nächste Kontrolle/)); + await userEvent.clear(screen.getByLabelText(/^Notizen/)); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateSwarmControlCheck).mock.calls[0]; + expect(input).toMatchObject({ + queenCellsFound: null, + nextCheckDate: null, + notes: null, + }); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx index 97654d3..8aee524 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx @@ -5,16 +5,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { updateSwarmControlCheck } from "@/lib/swarm-control/api"; import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labels"; import { SwarmControlAction, SwarmControlCheck, SwarmMood } from "@/lib/swarm-control/types"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { errorMessage } from "@/lib/forms/error-message"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; import { ColonySelectField } from "@/components/colony-select-field"; import { Field } from "@/components/form/field"; import { FormSection } from "@/components/form/form-section"; @@ -32,6 +27,7 @@ import { EditTriggerButton, hasAnyValue, } from "./edit-dialog-parts"; +import { toApiDate } from "@/lib/dates"; export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) { const queryClient = useQueryClient(); @@ -51,13 +47,13 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) mutationFn: () => updateSwarmControlCheck(check.id, { colonyId, - date: new Date(date).toISOString(), - queenCellsFound: queenCellsFound ? Number(queenCellsFound) : undefined, + date: toApiDate(date), + queenCellsFound: emptyToNull(queenCellsFound, Number), queenCellsCapped, swarmMood, actionTaken, - nextCheckDate: nextCheckDate ? new Date(nextCheckDate).toISOString() : undefined, - notes: notes || undefined, + nextCheckDate: emptyToNull(nextCheckDate, toApiDate), + notes: emptyToNull(notes), }), onSuccess: () => { // After re-filing, both the colony it left and the one it joined @@ -138,21 +134,12 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) /> {({ id }) => ( - + onChange={setSwarmMood} + labels={SWARM_MOOD_LABELS} + /> )} @@ -160,23 +147,12 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck }) {({ id }) => ( - + onChange={setActionTaken} + labels={SWARM_ACTION_LABELS} + /> )} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx index ab82bdf..195b58c 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx @@ -4,6 +4,7 @@ import { FormEvent, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { updateTreatment } from "@/lib/treatments/api"; import { Treatment } from "@/lib/treatments/types"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { errorMessage } from "@/lib/forms/error-message"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -23,6 +24,7 @@ import { EditTriggerButton, hasAnyValue, } from "./edit-dialog-parts"; +import { toApiDate } from "@/lib/dates"; interface FieldErrors { date?: string; @@ -56,20 +58,20 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { mutationFn: () => updateTreatment(treatment.id, { colonyId, - date: new Date(date).toISOString(), - startDate: startDate ? new Date(startDate).toISOString() : undefined, - endDate: endDate ? new Date(endDate).toISOString() : undefined, + date: toApiDate(date), + startDate: emptyToNull(startDate, toApiDate), + endDate: emptyToNull(endDate, toApiDate), product, - activeIngredient: activeIngredient || undefined, - amount: amount || undefined, - method: method || undefined, - reason: reason || undefined, - batchNumber: batchNumber || undefined, - notes: notes || undefined, - withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined, - supplier: supplier.trim(), - purchaseProof: purchaseProof.trim(), - prescribingVet: prescribingVet.trim(), + activeIngredient: emptyToNull(activeIngredient), + amount: emptyToNull(amount), + method: emptyToNull(method), + reason: emptyToNull(reason), + batchNumber: emptyToNull(batchNumber), + notes: emptyToNull(notes), + withdrawalPeriodDays: emptyToNull(withdrawalPeriodDays, Number), + supplier: emptyToNull(supplier), + purchaseProof: emptyToNull(purchaseProof), + prescribingVet: emptyToNull(prescribingVet), }), onSuccess: () => { // After re-filing, both the colony it left and the one it joined diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx new file mode 100644 index 0000000..fe0a65e --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { VarroaEditDialog } from "./varroa-edit-dialog"; +import { updateVarroaMeasurement } from "@/lib/varroa/api"; +import { fetchColonies } from "@/lib/colonies/api"; +import type { VarroaMeasurement } from "@/lib/varroa/types"; + +vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); +vi.mock("@/lib/varroa/api", () => ({ updateVarroaMeasurement: vi.fn() })); + +const measurement: VarroaMeasurement = { + id: "v-1", + colonyId: "colony-1", + userId: "u-1", + date: "2026-06-01T00:00:00.000Z", + method: "NATURAL_MITE_FALL", + durationMinutes: 4320, + miteCount: 12, + calculatedValue: 4, + assessment: "mittel", + notes: "Windel verrutscht", + createdAt: "2026-06-01T00:00:00.000Z", +}; + +async function openDialog() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" })); + return screen.findByRole("dialog"); +} + +describe("VarroaEditDialog", () => { + beforeEach(() => { + vi.mocked(fetchColonies).mockResolvedValue([ + { id: "colony-1", code: "V-01", status: "ACTIVE" }, + ] as never); + vi.mocked(updateVarroaMeasurement).mockReset(); + vi.mocked(updateVarroaMeasurement).mockResolvedValue(measurement); + }); + + it("sends emptied optional fields as null so a wrong value can be removed", async () => { + await openDialog(); + + for (const label of ["Milbenzahl", "Zeitraum (Minuten)", "Milben pro Tag", "Einschätzung", "Notizen"]) { + await userEvent.clear(screen.getByLabelText(new RegExp(`^${label.replace(/[()]/g, "\\$&")}`))); + } + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0]; + expect(input).toMatchObject({ + miteCount: null, + durationMinutes: null, + calculatedValue: null, + assessment: null, + notes: null, + }); + }); + + it("keeps filled-in values, a count of zero included", async () => { + await openDialog(); + + const miteCount = screen.getByLabelText(/^Milbenzahl/); + await userEvent.clear(miteCount); + await userEvent.type(miteCount, "0"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0]; + expect(input).toMatchObject({ + miteCount: 0, + durationMinutes: 4320, + assessment: "mittel", + notes: "Windel verrutscht", + }); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx index 0a81b76..02e3318 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx @@ -5,16 +5,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { updateVarroaMeasurement } from "@/lib/varroa/api"; import { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types"; import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { errorMessage } from "@/lib/forms/error-message"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; import { ColonySelectField } from "@/components/colony-select-field"; import { Field } from "@/components/form/field"; import { FormSection } from "@/components/form/form-section"; @@ -31,6 +26,7 @@ import { EditTriggerButton, hasAnyValue, } from "./edit-dialog-parts"; +import { toApiDate } from "@/lib/dates"; export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) { const queryClient = useQueryClient(); @@ -54,13 +50,13 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme mutationFn: () => updateVarroaMeasurement(measurement.id, { colonyId, - date: new Date(date).toISOString(), + date: toApiDate(date), method, - miteCount: miteCount ? Number(miteCount) : undefined, - durationMinutes: durationMinutes ? Number(durationMinutes) : undefined, - calculatedValue: calculatedValue ? Number(calculatedValue) : undefined, - assessment: assessment || undefined, - notes: notes || undefined, + miteCount: emptyToNull(miteCount, Number), + durationMinutes: emptyToNull(durationMinutes, Number), + calculatedValue: emptyToNull(calculatedValue, Number), + assessment: emptyToNull(assessment), + notes: emptyToNull(notes), }), onSuccess: () => { // After re-filing, both the colony it left and the one it joined @@ -122,21 +118,13 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme {({ id }) => ( - + onChange={setMethod} + values={VARROA_METHODS} + labels={VARROA_METHOD_LABELS} + /> )}
diff --git a/ApiCore-Frontend/src/app/(app)/colonies/new/colony-form-values.ts b/ApiCore-Frontend/src/app/(app)/colonies/new/colony-form-values.ts index 80b7ad6..7a4fdbe 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/new/colony-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/colonies/new/colony-form-values.ts @@ -1,4 +1,5 @@ import type { CreateColonyInput, ColonyType } from "@/lib/colonies/types"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface ColonyFormValues { @@ -29,15 +30,13 @@ export function validateColony(values: ColonyFormValues): ColonyFormErrors { return errors; } -const textOrUndefined = (value: string) => - value.trim() ? value.trim() : undefined; - export function toColonyInput(values: ColonyFormValues): CreateColonyInput { return { code: values.code.trim(), apiaryId: values.apiaryId, colonyType: values.colonyType, - hiveType: textOrUndefined(values.hiveType), - originText: textOrUndefined(values.originText), + // Not filled in = null, the same convention as every edit form. + hiveType: emptyToNull(values.hiveType), + originText: emptyToNull(values.originText), }; } diff --git a/ApiCore-Frontend/src/app/(app)/colonies/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/new/page.spec.tsx index 11a617b..208097c 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/new/page.spec.tsx @@ -57,8 +57,9 @@ describe("NewColonyPage", () => { code: "V-07", apiaryId: "apiary-1", colonyType: "PRODUCTION_COLONY", - hiveType: undefined, - originText: undefined, + // Not filled in = null, the same convention as every edit form. + hiveType: null, + originText: null, }); expect(push).toHaveBeenCalledWith("/colonies/colony-9"); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx index 1752b26..b7a3248 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx @@ -6,20 +6,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Layers } from "lucide-react"; import { createColony } from "@/lib/colonies/api"; -import { ColonyType } from "@/lib/colonies/types"; import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; import { fetchApiaries } from "@/lib/apiaries/api"; import { useFormDraft } from "@/lib/forms/use-form-draft"; import { errorMessage } from "@/lib/forms/error-message"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; +import { RecordSelect } from "@/components/form/record-select"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; @@ -149,61 +143,30 @@ export default function NewColonyPage() { } > {({ id, describedBy, invalid }) => ( - + onChange={(value) => set("apiaryId", value)} + items={apiaries} + getLabel={(apiary) => apiary.name} + placeholder={ + apiariesLoading ? "Bienenstände werden geladen…" : "Bienenstand wählen" + } + /> )} {({ id }) => ( - + onChange={(value) => set("colonyType", value)} + values={COLONY_TYPES} + labels={COLONY_TYPE_LABELS} + /> )} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/page.tsx index cda028b..4c05eed 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/page.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { fetchColonies } from "@/lib/colonies/api"; import { Colony, ColonyStatus } from "@/lib/colonies/types"; import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; +import { ENDED_COLONY_STATUSES, LIVING_COLONY_STATUSES } from "@/lib/colonies/status"; import { exportColoniesCsv } from "@/lib/export/api"; import { statusTone } from "@/lib/status-tone"; import { Button } from "@/components/ui/button"; @@ -23,16 +24,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -const ACTIVE_STATUS_OPTIONS: ColonyStatus[] = ["ACTIVE", "WEAK", "CONSPICUOUS", "QUEENLESS"]; -const HISTORY_STATUS_OPTIONS: ColonyStatus[] = ["DISSOLVED", "SOLD", "DECEASED", "MERGED"]; +import { EnumSelect } from "@/components/form/enum-select"; export default function ColoniesPage() { const [tab, setTab] = useState("active"); @@ -53,7 +45,7 @@ export default function ColoniesPage() { const historyColonies = colonies.filter((colony) => colony.deactivatedAt !== null); const bucketColonies = tab === "active" ? activeColonies : historyColonies; const visibleColonies = status ? bucketColonies.filter((colony) => colony.status === status) : bucketColonies; - const statusOptions = tab === "active" ? ACTIVE_STATUS_OPTIONS : HISTORY_STATUS_OPTIONS; + const statusOptions = tab === "active" ? LIVING_COLONY_STATUSES : ENDED_COLONY_STATUSES; return (
@@ -95,24 +87,15 @@ export default function ColoniesPage() { activeCount={activeColonies.length} historyCount={historyColonies.length} /> - +
diff --git a/ApiCore-Frontend/src/app/(app)/default-dates.spec.ts b/ApiCore-Frontend/src/app/(app)/default-dates.spec.ts new file mode 100644 index 0000000..4dec87a --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/default-dates.spec.ts @@ -0,0 +1,45 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { initialBottlingValues } from "./bottling/new/bottling-form-values"; +import { initialExtractionValues } from "./extractions/new/extraction-form-values"; +import { initialFeedingValues } from "./feedings/new/feeding-form-values"; +import { initialHarvestValues } from "./harvests/new/harvest-form-values"; +import { initialInspectionValues } from "./inspections/new/inspection-form-values"; +import { initialSwarmControlValues } from "./swarm-control/new/swarm-control-form-values"; +import { initialTreatmentValues } from "./treatments/new/treatment-form-values"; +import { initialVarroaValues } from "./varroa/new/varroa-form-values"; + +/** + * Every capture form prefills the beekeeper's local "today". At 00:30 in + * Berlin the UTC date is still yesterday - that must not leak into a form. + */ +describe("default date of every capture form", () => { + const originalTz = process.env.TZ; + + beforeAll(() => { + process.env.TZ = "Europe/Berlin"; + }); + + afterAll(() => { + process.env.TZ = originalTz; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each([ + ["Abfüllung", () => initialBottlingValues("hb-1").date], + ["Schleuderung", () => initialExtractionValues(null).date], + ["Fütterung", () => initialFeedingValues("c-1").date], + ["Ernte", () => initialHarvestValues("a-1").date], + ["Durchsicht", () => initialInspectionValues("c-1").date], + ["Schwarmkontrolle", () => initialSwarmControlValues("c-1").date], + ["Behandlung", () => initialTreatmentValues("c-1").date], + ["Varroa", () => initialVarroaValues("c-1").date], + ])("%s starts on the local day shortly after midnight", (_name, defaultDate) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-11T22:30:00.000Z")); // 00:30 in Berlin + + expect(defaultDate()).toBe("2026-10-12"); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/equipment/[id]/equipment-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/equipment/[id]/equipment-edit-dialog.tsx index 19a062e..1f7ec48 100644 --- a/ApiCore-Frontend/src/app/(app)/equipment/[id]/equipment-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/equipment/[id]/equipment-edit-dialog.tsx @@ -5,6 +5,7 @@ import { Pencil } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateEquipment } from "@/lib/equipment/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { Equipment, EquipmentCondition } from "@/lib/equipment/types"; import { EQUIPMENT_CONDITIONS, @@ -13,13 +14,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; import { Dialog, DialogContent, @@ -27,6 +22,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { toApiDate } from "@/lib/dates"; /** * Corrects every field of an inventory item. Only the condition used to be @@ -63,14 +59,10 @@ function EquipmentEditForm({ updateEquipment(equipment.id, { inventoryNumber, name, - manufacturer: manufacturer || undefined, - location: location || undefined, - purchaseDate: purchaseDate - ? new Date(purchaseDate).toISOString() - : undefined, - maintenanceDate: maintenanceDate - ? new Date(maintenanceDate).toISOString() - : undefined, + manufacturer: emptyToNull(manufacturer), + location: emptyToNull(location), + purchaseDate: emptyToNull(purchaseDate, toApiDate), + maintenanceDate: emptyToNull(maintenanceDate, toApiDate), condition, }), onSuccess: () => { @@ -152,23 +144,13 @@ function EquipmentEditForm({
- + onChange={setCondition} + values={EQUIPMENT_CONDITIONS} + labels={EQUIPMENT_CONDITION_LABELS} + />
{error ? (

diff --git a/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx index 5582a81..17d1847 100644 --- a/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/equipment/[id]/page.tsx @@ -8,13 +8,7 @@ import { fetchEquipment, updateEquipment } from "@/lib/equipment/api"; import { EquipmentEditDialog } from "./equipment-edit-dialog"; import { EquipmentCondition } from "@/lib/equipment/types"; import { EQUIPMENT_CONDITIONS, EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header"; import { errorMessage } from "@/lib/forms/error-message"; @@ -80,23 +74,14 @@ export default function EquipmentDetailPage() { ) : null}

Zustand ändern - + onChange={(condition) => updateMutation.mutate(condition)} + values={EQUIPMENT_CONDITIONS} + labels={EQUIPMENT_CONDITION_LABELS} + />
diff --git a/ApiCore-Frontend/src/app/(app)/equipment/new/equipment-form-values.ts b/ApiCore-Frontend/src/app/(app)/equipment/new/equipment-form-values.ts index 5b266b6..cfeea94 100644 --- a/ApiCore-Frontend/src/app/(app)/equipment/new/equipment-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/equipment/new/equipment-form-values.ts @@ -1,4 +1,6 @@ import type { CreateEquipmentInput } from "@/lib/equipment/types"; +import { toApiDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface EquipmentFormValues { @@ -37,20 +39,15 @@ export function validateEquipment( return errors; } -const textOrUndefined = (value: string) => - value.trim() ? value.trim() : undefined; -const dateOrUndefined = (value: string) => - value ? new Date(value).toISOString() : undefined; - export function toEquipmentInput( values: EquipmentFormValues, ): CreateEquipmentInput { return { inventoryNumber: values.inventoryNumber.trim(), name: values.name.trim(), - manufacturer: textOrUndefined(values.manufacturer), - purchaseDate: dateOrUndefined(values.purchaseDate), - location: textOrUndefined(values.location), - maintenanceDate: dateOrUndefined(values.maintenanceDate), + manufacturer: emptyToNull(values.manufacturer), + purchaseDate: emptyToNull(values.purchaseDate, toApiDate), + location: emptyToNull(values.location), + maintenanceDate: emptyToNull(values.maintenanceDate, toApiDate), }; } diff --git a/ApiCore-Frontend/src/app/(app)/equipment/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/equipment/new/page.spec.tsx index b8071c7..ce4ba4a 100644 --- a/ApiCore-Frontend/src/app/(app)/equipment/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/equipment/new/page.spec.tsx @@ -51,10 +51,10 @@ describe("NewEquipmentPage", () => { expect(vi.mocked(createEquipment).mock.calls[0][0]).toEqual({ inventoryNumber: "INV-7", name: "Schleuder", - manufacturer: undefined, - purchaseDate: undefined, - location: undefined, - maintenanceDate: undefined, + manufacturer: null, + purchaseDate: null, + location: null, + maintenanceDate: null, }); expect(push).toHaveBeenCalledWith("/equipment/eq-1"); }); diff --git a/ApiCore-Frontend/src/app/(app)/extractions/[id]/extraction-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/extractions/[id]/extraction-edit-dialog.tsx index 74472c2..192e393 100644 --- a/ApiCore-Frontend/src/app/(app)/extractions/[id]/extraction-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/extractions/[id]/extraction-edit-dialog.tsx @@ -5,6 +5,7 @@ import { Pencil } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateExtraction } from "@/lib/extractions/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { Extraction } from "@/lib/extractions/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -18,6 +19,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { toApiDate } from "@/lib/dates"; export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) { const queryClient = useQueryClient(); @@ -36,12 +38,12 @@ export function ExtractionEditDialog({ extraction }: { extraction: Extraction }) mutationFn: () => updateExtraction(extraction.id, { harvestIds, - date: new Date(date).toISOString(), - amount: amount ? Number(amount) : undefined, - room: room || undefined, - waterContent: waterContent ? Number(waterContent) : undefined, - temperature: temperature ? Number(temperature) : undefined, - notes: notes || undefined, + date: toApiDate(date), + amount: emptyToNull(amount, Number), + room: emptyToNull(room), + waterContent: emptyToNull(waterContent, Number), + temperature: emptyToNull(temperature, Number), + notes: emptyToNull(notes), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["extractions", extraction.id] }); diff --git a/ApiCore-Frontend/src/app/(app)/extractions/new/extraction-form-values.ts b/ApiCore-Frontend/src/app/(app)/extractions/new/extraction-form-values.ts index 2fdcf5c..ebf9ad2 100644 --- a/ApiCore-Frontend/src/app/(app)/extractions/new/extraction-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/extractions/new/extraction-form-values.ts @@ -1,4 +1,6 @@ import type { createExtraction } from "@/lib/extractions/api"; +import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface ExtractionFormValues { @@ -10,15 +12,11 @@ export interface ExtractionFormValues { notes: string; } -function todayIsoDate(): string { - return new Date().toISOString().slice(0, 10); -} - export function initialExtractionValues( preselectedHarvestId: string | null, ): ExtractionFormValues { return { - date: todayIsoDate(), + date: todayLocalIsoDate(), harvestIds: preselectedHarvestId ? [preselectedHarvestId] : [], amount: "", room: "", @@ -27,20 +25,15 @@ export function initialExtractionValues( }; } -const textOrUndefined = (value: string) => - value.trim() ? value.trim() : undefined; -const numberOrUndefined = (value: string) => - value.trim() ? Number(value) : undefined; - export function toExtractionInput( values: ExtractionFormValues, ): Parameters[0] { return { - date: new Date(values.date).toISOString(), + date: toApiDate(values.date), harvestIds: values.harvestIds, - amount: numberOrUndefined(values.amount), - room: textOrUndefined(values.room), - waterContent: numberOrUndefined(values.waterContent), - notes: textOrUndefined(values.notes), + amount: emptyToNull(values.amount, Number), + room: emptyToNull(values.room), + waterContent: emptyToNull(values.waterContent, Number), + notes: emptyToNull(values.notes), }; } diff --git a/ApiCore-Frontend/src/app/(app)/extractions/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/extractions/new/page.spec.tsx index 95d8cb4..8fd32e0 100644 --- a/ApiCore-Frontend/src/app/(app)/extractions/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/extractions/new/page.spec.tsx @@ -63,7 +63,7 @@ describe("NewExtractionPage", () => { amount: 18.5, waterContent: 17, }); - expect(input.room).toBeUndefined(); + expect(input.room).toBeNull(); expect(push).toHaveBeenCalledWith("/extractions/e-1"); }); diff --git a/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx b/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx index d5bba1f..80bc23c 100644 --- a/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/extractions/new/page.tsx @@ -158,6 +158,7 @@ export default function NewExtractionPage() { - value.trim() ? value.trim() : undefined; - export function toFeedingInput( values: FeedingFormValues, ): Parameters[0] { return { colonyId: values.colonyId, - date: new Date(values.date).toISOString(), + date: toApiDate(values.date), feedType: values.feedType, amount: Number(values.amount), unit: values.unit, - product: textOrUndefined(values.product), - batchNumber: textOrUndefined(values.batchNumber), - notes: textOrUndefined(values.notes), + product: emptyToNull(values.product), + batchNumber: emptyToNull(values.batchNumber), + notes: emptyToNull(values.notes), }; } diff --git a/ApiCore-Frontend/src/app/(app)/feedings/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/feedings/new/page.spec.tsx index 942e7f3..313881d 100644 --- a/ApiCore-Frontend/src/app/(app)/feedings/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/feedings/new/page.spec.tsx @@ -71,7 +71,7 @@ describe("NewFeedingPage", () => { amount: 2.5, unit: "kg", }); - expect(input.product).toBeUndefined(); + expect(input.product).toBeNull(); expect(options?.idempotencyKey).toBeTruthy(); expect(push).toHaveBeenCalledWith("/colonies/colony-1"); }); diff --git a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx index 5d0cb93..07bd43f 100644 --- a/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/feedings/new/page.tsx @@ -2,10 +2,9 @@ import { FormEvent, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Utensils } from "lucide-react"; -import { fetchColonies } from "@/lib/colonies/api"; import { createFeeding } from "@/lib/feedings/api"; import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels"; import { ApiError } from "@/lib/auth/api-client"; @@ -16,20 +15,14 @@ import { errorMessage } from "@/lib/forms/error-message"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; +import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; +import { LivingColonySelectField } from "@/components/colony-select-field"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; -import { cn } from "@/lib/utils"; import { FEEDING_UNIT_OPTIONS, FeedingFormValues, @@ -54,71 +47,13 @@ function validate(values: FeedingFormValues): FieldErrors { return errors; } -interface ChoiceGroupProps { - id: string; - labelledBy: string; - options: { value: T; label: string }[]; - value: T; - onChange: (value: T) => void; - className?: string; -} - -/** One large button per option: a single tap with gloves, no dropdown. */ -function ChoiceGroup({ - id, - labelledBy, - options, - value, - onChange, - className, -}: ChoiceGroupProps) { - return ( -
- {options.map((option) => { - const selected = value === option.value; - return ( - - ); - })} -
- ); -} - -const FEED_TYPE_OPTIONS = FEED_TYPES.map((type) => ({ - value: type, - label: FEED_TYPE_LABELS[type], -})); +const FEED_TYPE_OPTIONS = choiceOptions(FEED_TYPE_LABELS, FEED_TYPES); export default function NewFeedingPage() { const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const { data: colonies, isLoading: coloniesLoading } = useQuery({ - queryKey: ["colonies"], - queryFn: () => fetchColonies(), - }); - const activeColonies = - colonies?.filter((colony) => colony.status === "ACTIVE") ?? []; const [values, setValues] = useState(() => initialFeedingValues(searchParams.get("colonyId") ?? ""), @@ -221,46 +156,12 @@ export default function NewFeedingPage() { noValidate > - set("colonyId", colonyId)} error={fieldErrors.colonyId} - hint={ - !coloniesLoading && activeColonies.length === 0 - ? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen." - : undefined - } - > - {({ describedBy, invalid }) => ( - - )} - + /> {({ id, describedBy, invalid }) => ( )} - + {({ id, labelId }) => ( )} - + {({ id, labelId }) => ( { void queryClient.invalidateQueries({ queryKey: ["harvests"] }); @@ -113,21 +109,14 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
- + onChange={setApiaryId} + items={apiaries} + getLabel={(apiary) => apiary.name} + placeholder="Bienenstand wählen" + />
diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/harvest-form-values.ts b/ApiCore-Frontend/src/app/(app)/harvests/new/harvest-form-values.ts index a9320cf..34d2fcd 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/harvest-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/harvest-form-values.ts @@ -1,4 +1,6 @@ import type { createHarvest } from "@/lib/harvests/api"; +import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface HarvestFormValues { @@ -12,14 +14,10 @@ export interface HarvestFormValues { notes: string; } -function todayIsoDate(): string { - return new Date().toISOString().slice(0, 10); -} - export function initialHarvestValues(apiaryId: string): HarvestFormValues { return { apiaryId, - date: todayIsoDate(), + date: todayLocalIsoDate(), colonyIds: [], honeyType: "", honeySuperCount: "", @@ -29,22 +27,17 @@ export function initialHarvestValues(apiaryId: string): HarvestFormValues { }; } -const textOrUndefined = (value: string) => - value.trim() ? value.trim() : undefined; -const numberOrUndefined = (value: string) => - value.trim() ? Number(value) : undefined; - export function toHarvestInput( values: HarvestFormValues, ): Parameters[0] { return { apiaryId: values.apiaryId, - date: new Date(values.date).toISOString(), + date: toApiDate(values.date), colonyIds: values.colonyIds, - honeyType: textOrUndefined(values.honeyType), - honeySuperCount: numberOrUndefined(values.honeySuperCount), - grossWeight: numberOrUndefined(values.grossWeight), - netWeight: numberOrUndefined(values.netWeight), - notes: textOrUndefined(values.notes), + honeyType: emptyToNull(values.honeyType), + honeySuperCount: emptyToNull(values.honeySuperCount, Number), + grossWeight: emptyToNull(values.grossWeight, Number), + netWeight: emptyToNull(values.netWeight, Number), + notes: emptyToNull(values.notes), }; } diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx index ce7708b..4f0c497 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.spec.tsx @@ -70,11 +70,60 @@ describe("NewHarvestPage", () => { grossWeight: 30.5, netWeight: 22, }); - expect(input.honeyType).toBeUndefined(); + expect(input.honeyType).toBeNull(); expect(options?.idempotencyKey).toBeTruthy(); expect(push).toHaveBeenCalledWith("/harvests/h-1"); }); + it("offers every living colony of the apiary, weak and queenless ones included", async () => { + vi.mocked(fetchColonies).mockResolvedValue([ + { id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" }, + { id: "colony-3", code: "V-03", status: "QUEENLESS", apiaryId: "apiary-1" }, + { id: "colony-4", code: "V-04", status: "WEAK", apiaryId: "apiary-1" }, + { id: "colony-5", code: "V-05", status: "DISSOLVED", apiaryId: "apiary-1" }, + { id: "colony-6", code: "V-06", status: "ACTIVE", apiaryId: "apiary-2" }, + ] as never); + renderPage(); + + await screen.findByRole("checkbox", { name: "V-01" }); + expect( + screen.getAllByRole("checkbox").map((chip) => chip.textContent), + ).toEqual(["V-01", "V-03 (Weisellos)", "V-04 (Schwach)"]); + }); + + it("checks the withdrawal period at the harvest date and again when it changes", async () => { + vi.mocked(fetchWithdrawalStatus).mockImplementation(async (_ids, at) => + at === "2026-07-01" + ? [{ colonyId: "colony-2", activeUntil: "2026-07-10T00:00:00.000Z", product: "Oxuvar" }] + : [], + ); + renderPage(); + await screen.findByRole("checkbox", { name: "V-01" }); + const date = screen.getByLabelText(/^Datum/) as HTMLInputElement; + expect(fetchWithdrawalStatus).toHaveBeenLastCalledWith( + ["colony-1", "colony-2"], + date.value, + ); + + await userEvent.clear(date); + await userEvent.type(date, "2026-07-01"); + + expect(await screen.findByText(/Wartezeit \(Oxuvar\) bis 10\.7\.2026/)).toBeInTheDocument(); + expect(fetchWithdrawalStatus).toHaveBeenLastCalledWith( + ["colony-1", "colony-2"], + "2026-07-01", + ); + }); + + it("says so when the withdrawal periods could not be checked", async () => { + vi.mocked(fetchWithdrawalStatus).mockRejectedValue(new Error("offline")); + renderPage(); + + expect( + await screen.findByText(/Wartezeiten konnten nicht geprüft werden/), + ).toBeInTheDocument(); + }); + it("shows an inline error and focuses the colonies when none is chosen", async () => { renderPage(); await screen.findByRole("checkbox", { name: "V-01" }); diff --git a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx index d58791d..0e2932a 100644 --- a/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/harvests/new/page.tsx @@ -2,13 +2,21 @@ import { FormEvent, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { toast } from "sonner"; import { Boxes, AlertTriangle } from "lucide-react"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; +import { colonyOptionLabel } from "@/lib/colonies/labels"; +import { isLivingColony } from "@/lib/colonies/status"; import { createHarvest } from "@/lib/harvests/api"; import { fetchWithdrawalStatus } from "@/lib/treatments/api"; +import { todayLocalIsoDate } from "@/lib/dates"; import { ApiError } from "@/lib/auth/api-client"; import { queueHarvest } from "@/lib/offline/db"; import { newIdempotencyKey } from "@/lib/offline/idempotency"; @@ -17,13 +25,7 @@ import { errorMessage } from "@/lib/forms/error-message"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RecordSelect } from "@/components/form/record-select"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; @@ -41,6 +43,7 @@ type RequiredKey = "apiaryId" | "date" | "colonyIds"; type FieldErrors = Partial>; const REQUIRED_KEYS: RequiredKey[] = ["apiaryId", "date", "colonyIds"]; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; function validate(values: HarvestFormValues): FieldErrors { const errors: FieldErrors = {}; @@ -75,17 +78,21 @@ export default function NewHarvestPage() { const draft = useFormDraft("harvest-new", values, dirty); const apiaryColonies = (colonies ?? []).filter( - (colony) => - colony.apiaryId === values.apiaryId && colony.status === "ACTIVE", + (colony) => colony.apiaryId === values.apiaryId && isLivingColony(colony), ); const apiaryColonyIds = apiaryColonies.map((colony) => colony.id); - const { data: withdrawalStatuses } = useQuery({ - queryKey: ["treatments", "withdrawal-status", apiaryColonyIds], - queryFn: () => fetchWithdrawalStatus(apiaryColonyIds), + // Checked at the harvest date, not "now": honey taken on a past or future + // day is affected by the withdrawal windows of that day. An empty or + // unfinished date falls back to today rather than hiding every warning. + const withdrawalAt = ISO_DATE.test(values.date) ? values.date : todayLocalIsoDate(); + const withdrawalQuery = useQuery({ + queryKey: ["treatments", "withdrawal-status", apiaryColonyIds, withdrawalAt], + queryFn: () => fetchWithdrawalStatus(apiaryColonyIds, withdrawalAt), + placeholderData: keepPreviousData, enabled: apiaryColonyIds.length > 0, }); const withdrawalByColony = new Map( - (withdrawalStatuses ?? []).map((status) => [status.colonyId, status]), + (withdrawalQuery.data ?? []).map((status) => [status.colonyId, status]), ); function set( @@ -190,38 +197,25 @@ export default function NewHarvestPage() { > - {({ describedBy, invalid }) => ( - + onChange={selectApiary} + items={apiaries} + getLabel={(apiary) => apiary.name} + placeholder={ + apiariesLoading ? "Bienenstände werden geladen…" : "Bienenstand wählen" + } + /> )} @@ -244,6 +238,7 @@ export default function NewHarvestPage() { toggleColony(colony.id, checked) @@ -293,6 +288,13 @@ export default function NewHarvestPage() {
)}
+ {withdrawalQuery.isError ? ( +

+ + Wartezeiten konnten nicht geprüft werden. Vor der Ernte die Behandlungen der + Völker selbst ansehen. +

+ ) : null}
diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/add-measurement-dialog.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/add-measurement-dialog.tsx index 7c86ad6..6535ba2 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/add-measurement-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/add-measurement-dialog.tsx @@ -3,6 +3,7 @@ import { FormEvent, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { addHoneyMeasurement } from "@/lib/honey-batches/api"; +import type { CreateHoneyMeasurementInput } from "@/lib/honey-batches/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -14,21 +15,19 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; - -function todayIsoDate(): string { - return new Date().toISOString().slice(0, 10); -} +import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [waterContent, setWaterContent] = useState(""); - const [measuredAt, setMeasuredAt] = useState(todayIsoDate()); + const [measuredAt, setMeasuredAt] = useState(todayLocalIsoDate()); const [device, setDevice] = useState(""); const [error, setError] = useState(null); const mutation = useMutation({ - mutationFn: (input: { waterContent: number; measuredAt: string; device?: string }) => + mutationFn: (input: CreateHoneyMeasurementInput) => addHoneyMeasurement(honeyBatchId, input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatchId] }); @@ -51,8 +50,8 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) } mutation.mutate({ waterContent: Number(waterContent), - measuredAt: new Date(measuredAt).toISOString(), - device: device || undefined, + measuredAt: toApiDate(measuredAt), + device: emptyToNull(device), }); } diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/edit-measurement-dialog.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/edit-measurement-dialog.tsx index c931ee0..f73d62d 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/edit-measurement-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/edit-measurement-dialog.tsx @@ -5,6 +5,7 @@ import { Pencil } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateHoneyMeasurement } from "@/lib/honey-batches/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { HoneyMeasurement } from "@/lib/honey-batches/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -17,6 +18,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { toApiDate } from "@/lib/dates"; /** * Corrects a water-content reading. The figure decides whether a batch may be @@ -50,10 +52,10 @@ function EditMeasurementForm({ mutationFn: () => updateHoneyMeasurement(measurement.id, { waterContent: Number(waterContent), - measuredAt: new Date(measuredAt).toISOString(), - device: device || undefined, - temperature: temperature ? Number(temperature) : undefined, - notes: notes || undefined, + measuredAt: toApiDate(measuredAt), + device: emptyToNull(device), + temperature: emptyToNull(temperature, Number), + notes: emptyToNull(notes), }), onSuccess: () => { void queryClient.invalidateQueries({ diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/extraction-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/extraction-edit-dialog.tsx index 7a63a36..63b6a62 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/extraction-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/extraction-edit-dialog.tsx @@ -5,6 +5,7 @@ import { Pencil } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { fetchExtraction, updateExtraction } from "@/lib/extractions/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { Extraction } from "@/lib/extractions/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -18,6 +19,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { toApiDate } from "@/lib/dates"; function ExtractionEditForm({ extraction, @@ -47,12 +49,12 @@ function ExtractionEditForm({ mutationFn: () => updateExtraction(extraction.id, { harvestIds, - date: new Date(date).toISOString(), - amount: amount ? Number(amount) : undefined, - room: room || undefined, - waterContent: waterContent ? Number(waterContent) : undefined, - temperature: temperature ? Number(temperature) : undefined, - notes: notes || undefined, + date: toApiDate(date), + amount: emptyToNull(amount, Number), + room: emptyToNull(room), + waterContent: emptyToNull(waterContent, Number), + temperature: emptyToNull(temperature, Number), + notes: emptyToNull(notes), }), onSuccess: () => { void queryClient.invalidateQueries({ diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-container-allocations-card.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-container-allocations-card.tsx index ee458a6..9c85f16 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-container-allocations-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-container-allocations-card.tsx @@ -16,13 +16,8 @@ import { Label } from "@/components/ui/label"; import { EmptyState } from "@/components/empty-state"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { Boxes } from "lucide-react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RecordSelect } from "@/components/form/record-select"; +import { storageContainerOptionLabel } from "@/lib/storage/labels"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; // Additive to HoneyBatchStorageCard's single-container "Umlagern" flow above @@ -148,22 +143,14 @@ export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch:
- + onChange={setStorageContainerId} + items={containers} + getLabel={storageContainerOptionLabel} + placeholder="Lagerbehälter wählen" + />
diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-edit-dialog.tsx index 2acc5a4..5c87c3c 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-edit-dialog.tsx @@ -5,6 +5,7 @@ import { Pencil } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateHoneyBatch } from "@/lib/honey-batches/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types"; import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels"; import { fetchExtractions } from "@/lib/extractions/api"; @@ -12,13 +13,9 @@ import { fetchStorageContainers } from "@/lib/storage/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; +import { RecordSelect } from "@/components/form/record-select"; +import { storageContainerOptionLabel } from "@/lib/storage/labels"; import { Dialog, DialogContent, @@ -56,10 +53,10 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch }) batchNumber, extractionId, status, - storageContainerId: storageContainerId || undefined, - honeyType: honeyType || undefined, - amount: amount ? Number(amount) : undefined, - waterContent: waterContent ? Number(waterContent) : undefined, + storageContainerId: emptyToNull(storageContainerId), + honeyType: emptyToNull(honeyType), + amount: emptyToNull(amount, Number), + waterContent: emptyToNull(waterContent, Number), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatch.id] }); @@ -100,41 +97,29 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
- + onChange={setExtractionId} + items={extractions} + getLabel={(extraction) => + `${new Date(extraction.date).toLocaleDateString("de-DE")}${ + extraction.amount !== null ? ` · ${extraction.amount} kg` : "" + }` + } + placeholder="Schleuderung wählen" + />
- + onChange={setStorageContainerId} + items={containers} + getLabel={storageContainerOptionLabel} + placeholder="Behälter wählen" + />

Eine Korrektur wird wie eine Umlagerung in der Lagerhistorie festgehalten. @@ -142,23 +127,12 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })

- + onChange={setStatus} + labels={HONEY_BATCH_STATUS_LABELS} + />
diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-storage-card.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-storage-card.tsx index 680f76b..49268b4 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-storage-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-storage-card.tsx @@ -4,19 +4,14 @@ import { FormEvent, useState } from "react"; import { History } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchHoneyBatchRelocations, relocateHoneyBatch } from "@/lib/honey-batches/api"; -import { HoneyBatch } from "@/lib/honey-batches/types"; +import { HoneyBatch, RelocateHoneyBatchInput } from "@/lib/honey-batches/types"; import { fetchStorageContainers } from "@/lib/storage/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { EmptyState } from "@/components/empty-state"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RecordSelect } from "@/components/form/record-select"; +import { storageContainerOptionLabel } from "@/lib/storage/labels"; import { Dialog, DialogContent, @@ -26,6 +21,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }) { const queryClient = useQueryClient(); @@ -45,7 +41,7 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch } }); const mutation = useMutation({ - mutationFn: (input: { storageContainerId: string; notes?: string }) => + mutationFn: (input: RelocateHoneyBatchInput) => relocateHoneyBatch(honeyBatch.id, input), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["honey-batches", honeyBatch.id] }); @@ -69,7 +65,7 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch } setError("Bitte einen Lagerbehälter auswählen."); return; } - mutation.mutate({ storageContainerId, notes: notes || undefined }); + mutation.mutate({ storageContainerId, notes: emptyToNull(notes) }); } return ( @@ -85,22 +81,14 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
- + onChange={setStorageContainerId} + items={containers} + getLabel={storageContainerOptionLabel} + placeholder="Lagerbehälter wählen" + />
diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/create-honey-batch-dialog.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/create-honey-batch-dialog.tsx index c9bcfb5..07505bd 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/create-honey-batch-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/create-honey-batch-dialog.tsx @@ -15,13 +15,8 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RecordSelect } from "@/components/form/record-select"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; export function CreateHoneyBatchDialog() { const queryClient = useQueryClient(); @@ -61,10 +56,11 @@ export function CreateHoneyBatchDialog() { return; } mutation.mutate({ - batchNumber: batchNumber || undefined, + // Empty: the server generates the number (automation format). + batchNumber: emptyToNull(batchNumber), extractionId, - honeyType: honeyType || undefined, - amount: amount ? Number(amount) : undefined, + honeyType: emptyToNull(honeyType), + amount: emptyToNull(amount, Number), }); } @@ -87,21 +83,18 @@ export function CreateHoneyBatchDialog() {
- + + `${new Date(extraction.date).toLocaleDateString("de-DE")} · ${extraction.harvests + .flatMap((entry) => entry.harvest.colonies.map((c) => c.colony.code)) + .join(", ")}` + } + placeholder="Schleuderung wählen" + />
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts new file mode 100644 index 0000000..ea1648b --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { initialInspectionValues, toInspectionInput } from "./inspection-form-values"; + +const OBSERVATIONS = [ + "queenSeen", + "eggsPresent", + "openBroodPresent", + "cappedBroodPresent", + "queenCellsPresent", +] as const; + +describe("inspection form values", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts every observation and the feed status as not checked", () => { + const values = initialInspectionValues("colony-1"); + + for (const key of OBSERVATIONS) { + expect(values[key]).toBeNull(); + } + expect(values.feedStatus).toBeNull(); + }); + + it("does not turn an untouched observation into a finding", () => { + const input = toInspectionInput(initialInspectionValues("colony-1")); + + for (const key of OBSERVATIONS) { + expect(input[key]).toBeNull(); + } + expect(input.feedStatus).toBeNull(); + }); + + it("sends an explicit no as false - the automation reacts only to that", () => { + const input = toInspectionInput({ + ...initialInspectionValues("colony-1"), + queenSeen: false, + eggsPresent: false, + queenCellsPresent: true, + feedStatus: "kritisch", + }); + + expect(input).toMatchObject({ + queenSeen: false, + eggsPresent: false, + queenCellsPresent: true, + feedStatus: "kritisch", + }); + }); + + it("sends emptied text, numbers and ratings as null so an edit can clear them", () => { + const input = toInspectionInput({ + ...initialInspectionValues("colony-1"), + weather: " ", + temperature: "", + colonyStrength: "", + notes: "", + gentleness: null, + }); + + expect(input).toMatchObject({ + weather: null, + temperature: null, + colonyStrength: null, + broodPattern: null, + gentleness: null, + honeySuperStatus: null, + measures: null, + notes: null, + }); + }); + + it("keeps filled-in values, zero included, and trims text", () => { + const input = toInspectionInput({ + ...initialInspectionValues("colony-1"), + weather: " sonnig ", + temperature: "0", + colonyStrength: "8", + swarmMood: 2, + }); + + expect(input).toMatchObject({ weather: "sonnig", temperature: 0, colonyStrength: 8, swarmMood: 2 }); + }); + + it("keeps 'not checked' through a draft or the offline queue (JSON)", () => { + const values = initialInspectionValues("colony-1"); + + const restoredDraft = JSON.parse(JSON.stringify(values)); + const queuedInput = JSON.parse(JSON.stringify(toInspectionInput(values))); + + expect(restoredDraft.queenSeen).toBeNull(); + expect(restoredDraft.feedStatus).toBeNull(); + expect(queuedInput).toHaveProperty("eggsPresent", null); + expect(queuedInput).toHaveProperty("feedStatus", null); + }); + + it("prefills today's local date, not the UTC date", () => { + const originalTz = process.env.TZ; + process.env.TZ = "Europe/Berlin"; + try { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-11T22:30:00.000Z")); // 00:30 in Berlin + + expect(initialInspectionValues("colony-1").date).toBe("2026-10-12"); + } finally { + process.env.TZ = originalTz; + } + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts index c9744c9..4f4308e 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts @@ -1,14 +1,32 @@ +import type { ChoiceOption } from "@/components/form/choice-group"; import type { createInspection } from "@/lib/inspections/api"; +import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; export type FeedStatus = "ausreichend" | "niedrig" | "kritisch"; -export const FEED_STATUS_OPTIONS: { value: FeedStatus; label: string }[] = [ +/** The three feed levels plus "Nicht geprüft" (null), which is the default. */ +export const FEED_STATUS_OPTIONS: readonly ChoiceOption[] = [ { value: "ausreichend", label: "Ausreichend" }, { value: "niedrig", label: "Niedrig" }, { value: "kritisch", label: "Kritisch" }, + { value: null, label: "Nicht geprüft" }, ]; -/** Everything the form holds, as plain values that survive a draft save. */ +/** The yes/no observations of an inspection, in the order they are asked. */ +export const INSPECTION_OBSERVATIONS = [ + { key: "queenSeen", label: "Königin gesehen" }, + { key: "eggsPresent", label: "Stifte vorhanden" }, + { key: "openBroodPresent", label: "Offene Brut" }, + { key: "cappedBroodPresent", label: "Verdeckelte Brut" }, + { key: "queenCellsPresent", label: "Weiselzellen" }, +] as const satisfies readonly { key: keyof InspectionFormValues; label: string }[]; + +/** + * Everything the form holds, as plain values that survive a draft save. + * Yes/no observations and the feed status are null until the beekeeper + * answers them: "not checked" must never be saved as a finding. + */ export interface InspectionFormValues { colonyId: string; date: string; @@ -17,15 +35,20 @@ export interface InspectionFormValues { colonyStrength: string; broodAmount: string; broodPattern: number | null; - openBroodPresent: boolean; - cappedBroodPresent: boolean; - queenSeen: boolean; - eggsPresent: boolean; - queenCellsPresent: boolean; + openBroodPresent: boolean | null; + cappedBroodPresent: boolean | null; + queenSeen: boolean | null; + eggsPresent: boolean | null; + queenCellsPresent: boolean | null; swarmMood: number | null; gentleness: number | null; combBehavior: number | null; - feedStatus: FeedStatus; + /** + * One of FEED_STATUS_OPTIONS, or null for "Nicht geprüft" - or an older + * free-text value, kept verbatim so saving without touching it changes + * nothing. + */ + feedStatus: string | null; honeySuperStatus: string; healthIssues: string; varroaIssues: string; @@ -33,30 +56,26 @@ export interface InspectionFormValues { notes: string; } -function todayIsoDate(): string { - return new Date().toISOString().slice(0, 10); -} - export function initialInspectionValues( colonyId: string, ): InspectionFormValues { return { colonyId, - date: todayIsoDate(), + date: todayLocalIsoDate(), weather: "", temperature: "", colonyStrength: "", broodAmount: "", broodPattern: null, - openBroodPresent: false, - cappedBroodPresent: false, - queenSeen: false, - eggsPresent: true, - queenCellsPresent: false, + openBroodPresent: null, + cappedBroodPresent: null, + queenSeen: null, + eggsPresent: null, + queenCellsPresent: null, swarmMood: null, gentleness: null, combBehavior: null, - feedStatus: "ausreichend", + feedStatus: null, honeySuperStatus: "", healthIssues: "", varroaIssues: "", @@ -65,35 +84,36 @@ export function initialInspectionValues( }; } -const textOrUndefined = (value: string) => - value.trim() ? value.trim() : undefined; -const numberOrUndefined = (value: string) => - value.trim() ? Number(value) : undefined; - +/** + * Everything not filled in goes out as null, not omitted: on an edit, null is + * what clears an earlier entry (an omitted field would stay unchanged), and + * "Nicht geprüft" must never turn into a "Ja"/"Nein". Create accepts the + * same nulls, so one mapping serves both. + */ export function toInspectionInput( values: InspectionFormValues, ): Parameters[0] { return { colonyId: values.colonyId, - date: new Date(values.date).toISOString(), - weather: textOrUndefined(values.weather), - temperature: numberOrUndefined(values.temperature), - colonyStrength: numberOrUndefined(values.colonyStrength), - broodAmount: textOrUndefined(values.broodAmount), - broodPattern: values.broodPattern ?? undefined, + date: toApiDate(values.date), + weather: emptyToNull(values.weather), + temperature: emptyToNull(values.temperature, Number), + colonyStrength: emptyToNull(values.colonyStrength, Number), + broodAmount: emptyToNull(values.broodAmount), + broodPattern: values.broodPattern ?? null, openBroodPresent: values.openBroodPresent, cappedBroodPresent: values.cappedBroodPresent, queenSeen: values.queenSeen, eggsPresent: values.eggsPresent, queenCellsPresent: values.queenCellsPresent, - swarmMood: values.swarmMood ?? undefined, - gentleness: values.gentleness ?? undefined, - combBehavior: values.combBehavior ?? undefined, + swarmMood: values.swarmMood ?? null, + gentleness: values.gentleness ?? null, + combBehavior: values.combBehavior ?? null, feedStatus: values.feedStatus, - honeySuperStatus: textOrUndefined(values.honeySuperStatus), - healthIssues: textOrUndefined(values.healthIssues), - varroaIssues: textOrUndefined(values.varroaIssues), - measures: textOrUndefined(values.measures), - notes: textOrUndefined(values.notes), + honeySuperStatus: emptyToNull(values.honeySuperStatus), + healthIssues: emptyToNull(values.healthIssues), + varroaIssues: emptyToNull(values.varroaIssues), + measures: emptyToNull(values.measures), + notes: emptyToNull(values.notes), }; } diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx index 602eae4..c1dcc56 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.spec.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import NewInspectionPage from "./page"; @@ -53,13 +53,12 @@ describe("NewInspectionPage", () => { renderPage(); await userEvent.click( - screen.getByRole("checkbox", { name: "Königin gesehen" }), - ); - // Order: Brutbild, Sanftmut, Wabensitz, Schwarmstimmung, Futter. - const gentleness = screen.getAllByRole("radiogroup")[1]; - await userEvent.click( - gentleness.querySelector('[aria-label="4 von 5"]') as HTMLElement, + within(screen.getByRole("radiogroup", { name: "Königin gesehen" })).getByRole("radio", { + name: "Ja", + }), ); + const gentleness = screen.getByRole("radiogroup", { name: /^Sanftmut/ }); + await userEvent.click(within(gentleness).getByRole("radio", { name: "4 von 5" })); await userEvent.click( screen.getByRole("button", { name: "Durchsicht speichern" }), ); @@ -73,13 +72,71 @@ describe("NewInspectionPage", () => { expect(push).toHaveBeenCalledWith("/colonies/colony-1"); }); - it("warns when neither queen nor eggs were seen", async () => { + it("saves untouched observations and feed as not checked instead of as findings", async () => { + vi.mocked(createInspection).mockResolvedValue({ + id: "i-1", + colonyId: "colony-1", + } as never); renderPage(); + + expect( + within(screen.getByRole("radiogroup", { name: "Stifte vorhanden" })).getByRole("radio", { + name: "Nicht geprüft", + }), + ).toHaveAttribute("aria-checked", "true"); await userEvent.click( - screen.getByRole("checkbox", { name: "Stifte vorhanden" }), + screen.getByRole("button", { name: "Durchsicht speichern" }), ); + + const [input] = vi.mocked(createInspection).mock.calls[0]; + expect(input).toMatchObject({ + queenSeen: null, + eggsPresent: null, + openBroodPresent: null, + cappedBroodPresent: null, + queenCellsPresent: null, + feedStatus: null, + }); + }); + + it("warns only when queen and eggs were both explicitly not seen", async () => { + renderPage(); + const answer = (group: string, choice: string) => + userEvent.click( + within(screen.getByRole("radiogroup", { name: group })).getByRole("radio", { + name: choice, + }), + ); + + expect(screen.queryByText(/Weder Königin noch Stifte gesehen/)).not.toBeInTheDocument(); + await answer("Königin gesehen", "Nein"); + expect(screen.queryByText(/Weder Königin noch Stifte gesehen/)).not.toBeInTheDocument(); + await answer("Stifte vorhanden", "Nein"); expect( screen.getByText(/Weder Königin noch Stifte gesehen/), ).toBeInTheDocument(); }); + + it("shows a missing date next to the field and does not save", async () => { + renderPage(); + + await userEvent.clear(screen.getByLabelText(/^Datum/)); + await userEvent.click( + screen.getByRole("button", { name: "Durchsicht speichern" }), + ); + + expect(screen.getByText("Bitte ein Datum angeben.")).toBeInTheDocument(); + expect(screen.getByLabelText(/^Datum/)).toHaveFocus(); + expect(createInspection).not.toHaveBeenCalled(); + }); + + it("labels only real form controls - groups are named, not label-for'd", () => { + const { container } = renderPage(); + + for (const label of container.querySelectorAll("label[for]")) { + const target = document.getElementById(label.getAttribute("for") ?? ""); + expect(target, `label "${label.textContent}"`).not.toBeNull(); + expect(["INPUT", "SELECT", "TEXTAREA", "BUTTON"]).toContain(target?.tagName); + } + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx index 527c684..7636897 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx @@ -2,10 +2,9 @@ import { FormEvent, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { AlertTriangle, ClipboardCheck } from "lucide-react"; -import { fetchColonies } from "@/lib/colonies/api"; import { createInspection } from "@/lib/inspections/api"; import { ApiError } from "@/lib/auth/api-client"; import { queueInspection } from "@/lib/offline/db"; @@ -15,24 +14,19 @@ import { errorMessage } from "@/lib/forms/error-message"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; +import { LivingColonySelectField } from "@/components/colony-select-field"; import { FormSection } from "@/components/form/form-section"; import { RatingScale } from "@/components/form/rating-scale"; -import { ToggleChip } from "@/components/form/toggle-chip"; +import { ChoiceGroup } from "@/components/form/choice-group"; +import { TriStateField } from "@/components/form/tri-state-field"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; -import { cn } from "@/lib/utils"; import { FEED_STATUS_OPTIONS, + INSPECTION_OBSERVATIONS, InspectionFormValues, initialInspectionValues, toInspectionInput, @@ -43,18 +37,13 @@ export default function NewInspectionPage() { const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const { data: colonies, isLoading: coloniesLoading } = useQuery({ - queryKey: ["colonies"], - queryFn: () => fetchColonies(), - }); - const activeColonies = - colonies?.filter((colony) => colony.status === "ACTIVE") ?? []; const [values, setValues] = useState(() => initialInspectionValues(searchParams.get("colonyId") ?? ""), ); const [dirty, setDirty] = useState(false); const [colonyError, setColonyError] = useState(null); + const [dateError, setDateError] = useState(null); const [submitError, setSubmitError] = useState(null); const draft = useFormDraft("inspection-new", values, dirty); @@ -65,6 +54,7 @@ export default function NewInspectionPage() { setValues((current) => ({ ...current, [key]: value })); setDirty(true); if (key === "colonyId") setColonyError(null); + if (key === "date") setDateError(null); } // One key per opened form: a double tap or an offline retry of the @@ -109,6 +99,11 @@ export default function NewInspectionPage() { document.getElementById("inspection-colony")?.focus(); return; } + if (!values.date) { + setDateError("Bitte ein Datum angeben."); + document.getElementById("inspection-date")?.focus(); + return; + } mutation.mutate(toInspectionInput(values)); } @@ -120,7 +115,8 @@ export default function NewInspectionPage() { } } - const noQueenSigns = !values.queenSeen && !values.eggsPresent; + // Same rule as the backend automation: only an explicit "Nein" counts. + const noQueenSigns = values.queenSeen === false && values.eggsPresent === false; return (
@@ -145,52 +141,21 @@ export default function NewInspectionPage() { noValidate > - set("colonyId", colonyId)} error={colonyError} - hint={ - !coloniesLoading && activeColonies.length === 0 - ? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen." - : undefined - } - > - {({ describedBy, invalid }) => ( - - )} - - - {({ id }) => ( + /> + + {({ id, describedBy, invalid }) => ( set("date", event.target.value)} /> @@ -201,34 +166,17 @@ export default function NewInspectionPage() { -
- set("queenSeen", checked)} - /> - set("eggsPresent", checked)} - /> - set("openBroodPresent", checked)} - /> - set("cappedBroodPresent", checked)} - /> - set("queenCellsPresent", checked)} - /> +
+ {INSPECTION_OBSERVATIONS.map((observation) => ( + set(observation.key, answer)} + /> + ))}
{noQueenSigns ? (

@@ -240,7 +188,7 @@ export default function NewInspectionPage() { eine Kontrolle als Aufgabe vorgeschlagen.

) : null} - + {({ id, labelId, describedBy }) => ( )} - + {({ id, labelId }) => ( )} - + {({ id, labelId }) => ( )} - + {({ id, labelId }) => ( -
- {FEED_STATUS_OPTIONS.map((option) => { - const selected = values.feedStatus === option.value; - return ( - - ); - })} -
+ + {({ id, labelId }) => ( + set("feedStatus", feedStatus)} + className="grid-cols-2 sm:grid-cols-4" + /> + )} +
({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + usePathname: () => "/inspections/new", +})); + +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ + user: { + id: "u1", + email: "imker@example.com", + name: "Test Imker", + roleKey: "admin", + roleName: "Admin", + permissions: [], + mfaEnabled: false, + hasAvatar: false, + }, + isLoading: false, + logout: vi.fn(), + }), +})); + +vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null })); +vi.mock("@/components/offline-sync-badge", () => ({ OfflineSyncBadge: () => null })); +vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null })); + +// A box with overflow auto/scroll/hidden is a scroll container. Any such +// ancestor of the page content captures `position: sticky` (StickySubmit), +// and since the page itself scrolls on the window, the bar would never stick. +const SCROLL_CONTAINER_CLASS = /(^|\s)([a-z]+:)*overflow-(x-|y-)?(auto|scroll|hidden)(\s|$)/; + +function expectNoScrollContainerAbove(element: HTMLElement) { + for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) { + expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS); + } +} + +describe("AppLayout", () => { + it("does not wrap the page content in its own scroll container", () => { + render( + +

Seiteninhalt

+
, + ); + + expectNoScrollContainerAbove(screen.getByText("Seiteninhalt")); + }); + + it("keeps every ancestor of a form's save bar, including the Card, free of scroll containers", () => { + render( + +
+ + + + + + + + + +
+
, + ); + + const saveBar = screen.getByRole("button", { name: "Speichern" }).parentElement!; + expectNoScrollContainerAbove(saveBar); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index 2098542..be24db1 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -36,8 +36,11 @@ export default function AppLayout({ children }: { children: ReactNode }) { return null; } + // The page scrolls on the window. No ancestor of the page content may be a + // scroll container (overflow auto/scroll/hidden): it would capture + // `position: sticky` and the StickySubmit save bar would never stick. return ( -
+
@@ -82,7 +85,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
-
+
{children}
diff --git a/ApiCore-Frontend/src/app/(app)/production-batches/[id]/edit-production-batch-dialog.tsx b/ApiCore-Frontend/src/app/(app)/production-batches/[id]/edit-production-batch-dialog.tsx index f649a07..dcdf1ab 100644 --- a/ApiCore-Frontend/src/app/(app)/production-batches/[id]/edit-production-batch-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/production-batches/[id]/edit-production-batch-dialog.tsx @@ -5,6 +5,7 @@ import { Pencil } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateProductionBatch } from "@/lib/production-batches/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { ProductionBatch } from "@/lib/production-batches/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -17,6 +18,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { toApiDate } from "@/lib/dates"; /** * Corrects a production batch entered with a typo. The article stays fixed: @@ -73,12 +75,10 @@ function EditForm({ mutationFn: () => updateProductionBatch(batch.id, { lotNumber: lotNumber.trim(), - date: new Date(date).toISOString(), + date: toApiDate(date), quantity: Number(quantity), - bestBeforeDate: bestBeforeDate - ? new Date(bestBeforeDate).toISOString() - : null, - notes: notes.trim(), + bestBeforeDate: emptyToNull(bestBeforeDate, toApiDate), + notes: emptyToNull(notes), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["production-batches"] }); diff --git a/ApiCore-Frontend/src/app/(app)/production-batches/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.spec.tsx new file mode 100644 index 0000000..0ac3353 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.spec.tsx @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import NewProductionBatchPage from "./page"; +import { fetchRawMaterialLots } from "@/lib/raw-materials/api"; +import { fetchProducts } from "@/lib/products/api"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock("@/lib/raw-materials/api", () => ({ fetchRawMaterialLots: vi.fn() })); +vi.mock("@/lib/products/api", () => ({ fetchProducts: vi.fn() })); +vi.mock("@/lib/production-batches/api", () => ({ createProductionBatch: vi.fn() })); + +const LOT = { + id: "lot-1", + kind: "BEESWAX", + lotNumber: "W-01", + date: "2026-06-01T00:00:00.000Z", + quantity: 5000, + apiaryId: null, + harvestId: null, + notes: null, + createdAt: "2026-06-01T00:00:00.000Z", + remainingCount: 5000, + apiary: null, + harvest: null, + usedIn: [], +}; + +function gramsInputs() { + return screen.getAllByLabelText("Gramm") as HTMLInputElement[]; +} + +describe("NewProductionBatchPage – consumed raw materials", () => { + beforeEach(() => { + vi.mocked(fetchRawMaterialLots).mockResolvedValue([LOT] as never); + vi.mocked(fetchProducts).mockResolvedValue([]); + }); + + it("removing a middle row keeps the other rows with their own fields and values", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + const addRow = await screen.findByRole("button", { name: "+ Rohstoff hinzufügen" }); + // Enabled once the raw material lots have loaded. + await vi.waitFor(() => expect(addRow).toBeEnabled()); + for (let row = 0; row < 3; row += 1) { + await userEvent.click(addRow); + } + const [first, middle, last] = gramsInputs(); + await userEvent.type(first, "100"); + await userEvent.type(middle, "200"); + await userEvent.type(last, "300"); + + await userEvent.click(screen.getByRole("button", { name: "Rohstoff 2 entfernen" })); + + expect(gramsInputs().map((input) => input.value)).toEqual(["100", "300"]); + // The third row's own input survives - not the middle one refilled. + expect(last).toBeInTheDocument(); + expect(middle).not.toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx index 273472a..dae69fe 100644 --- a/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormEvent, useState } from "react"; +import { FormEvent, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Factory } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -18,16 +18,15 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RecordSelect } from "@/components/form/record-select"; +import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; -function today(): string { - return new Date().toISOString().slice(0, 10); +interface InputRow { + /** Stable client-side identity of the row (React key, field ids). */ + key: string; + rawMaterialLotId: string; + grams: string; } export default function NewProductionBatchPage() { @@ -35,26 +34,31 @@ export default function NewProductionBatchPage() { const queryClient = useQueryClient(); const [productId, setProductId] = useState(""); const [lotNumber, setLotNumber] = useState(""); - const [date, setDate] = useState(today()); + const [date, setDate] = useState(todayLocalIsoDate()); const [quantity, setQuantity] = useState(""); const [bestBeforeDate, setBestBeforeDate] = useState(""); const [notes, setNotes] = useState(""); - // Verbrauchte Rohstoffe: je Zeile eine Rohstoff-Charge und Gramm. - const [inputs, setInputs] = useState< - { rawMaterialLotId: string; grams: string }[] - >([]); + // Verbrauchte Rohstoffe: je Zeile eine Rohstoff-Charge und Gramm. Each row + // carries its own key, so removing one keeps the others' fields (and their + // select/focus state) attached to the right row - an index would shift them. + const [inputs, setInputs] = useState([]); + const nextRowKey = useRef(0); const { data: rawLots } = useQuery({ queryKey: ["raw-material-lots"], queryFn: () => fetchRawMaterialLots(), }); const availableLots = (rawLots ?? []).filter((lot) => lot.remainingCount > 0); - const updateInput = ( - index: number, - change: Partial<{ rawMaterialLotId: string; grams: string }>, - ) => + const updateInput = (key: string, change: Partial>) => setInputs((current) => - current.map((row, i) => (i === index ? { ...row, ...change } : row)), + current.map((row) => (row.key === key ? { ...row, ...change } : row)), ); + const addInput = () => { + const key = `row-${nextRowKey.current}`; + nextRowKey.current += 1; + setInputs((current) => [...current, { key, rawMaterialLotId: "", grams: "" }]); + }; + const removeInput = (key: string) => + setInputs((current) => current.filter((row) => row.key !== key)); const [error, setError] = useState(null); const { data: products } = useQuery({ @@ -64,19 +68,17 @@ export default function NewProductionBatchPage() { const manufactured = (products ?? []).filter( (product) => product.kind === "MANUFACTURED", ); - const selected = manufactured.find((product) => product.id === productId); const mutation = useMutation({ mutationFn: () => createProductionBatch({ productId, - lotNumber: lotNumber.trim() || undefined, - date: new Date(date).toISOString(), + // Empty: the server generates the number (automation format). + lotNumber: emptyToNull(lotNumber), + date: toApiDate(date), quantity: Number(quantity), - bestBeforeDate: bestBeforeDate - ? new Date(bestBeforeDate).toISOString() - : undefined, - notes: notes.trim() || undefined, + bestBeforeDate: emptyToNull(bestBeforeDate, toApiDate), + notes: emptyToNull(notes), inputs: inputs .filter((row) => row.rawMaterialLotId && Number(row.grams) > 0) .map((row) => ({ @@ -122,23 +124,14 @@ export default function NewProductionBatchPage() { >
- + onChange={setProductId} + items={products ? manufactured : undefined} + getLabel={(product) => product.name} + placeholder="Artikel wählen" + /> {products && manufactured.length === 0 ? (

Noch kein Artikel der Art „Hergestellte Ware“. @@ -203,40 +196,24 @@ export default function NewProductionBatchPage() { (candidate) => candidate.id === row.rawMaterialLotId, ); return ( -

+
- - + placeholder="Charge wählen" + />
- + - updateInput(index, { grams: event.target.value }) + updateInput(row.key, { grams: event.target.value }) } />
@@ -252,11 +229,8 @@ export default function NewProductionBatchPage() { type="button" variant="outline" size="sm" - onClick={() => - setInputs((current) => - current.filter((_, i) => i !== index), - ) - } + aria-label={`Rohstoff ${index + 1} entfernen`} + onClick={() => removeInput(row.key)} > Entfernen @@ -269,12 +243,7 @@ export default function NewProductionBatchPage() { size="sm" className="self-start" disabled={availableLots.length === 0} - onClick={() => - setInputs((current) => [ - ...current, - { rawMaterialLotId: "", grams: "" }, - ]) - } + onClick={addInput} > + Rohstoff hinzufügen diff --git a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx index 266faa3..de0f256 100644 --- a/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/products/edit-product-dialog.tsx @@ -5,6 +5,7 @@ import { Pencil } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateProduct } from "@/lib/products/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { Product, ProductStatus, ShopCategory } from "@/lib/products/types"; import { PRODUCT_KIND_LABELS, @@ -15,13 +16,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; import { Dialog, DialogContent, @@ -47,11 +42,11 @@ export function EditProductDialog({ product }: { product: Product }) { mutationFn: () => updateProduct(product.id, { name, - description: description || undefined, - sku: sku || undefined, - packagingSize: packagingSize ? Number(packagingSize) : undefined, - weight: weight ? Number(weight) : undefined, - salesUnit: salesUnit || undefined, + description: emptyToNull(description), + sku: emptyToNull(sku), + packagingSize: emptyToNull(packagingSize, Number), + weight: emptyToNull(weight, Number), + salesUnit: emptyToNull(salesUnit), status, shopCategory, }), @@ -131,45 +126,21 @@ export function EditProductDialog({ product }: { product: Product }) {
- + onChange={setStatus} + labels={PRODUCT_STATUS_LABELS} + />
- + onChange={setShopCategory} + labels={SHOP_CATEGORY_LABELS} + />

Art: {PRODUCT_KIND_LABELS[product.kind]} - nicht änderbar, sobald Chargen existieren.

diff --git a/ApiCore-Frontend/src/app/(app)/products/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/products/new/page.spec.tsx index 68e88af..41f6779 100644 --- a/ApiCore-Frontend/src/app/(app)/products/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/products/new/page.spec.tsx @@ -54,11 +54,11 @@ describe("NewProductPage", () => { expect(vi.mocked(createProduct).mock.calls[0][0]).toEqual({ name: "Frühtracht", - description: undefined, + description: null, sku: "FT-500", packagingSize: 500, - weight: undefined, - salesUnit: undefined, + weight: null, + salesUnit: null, kind: "BOTTLED_HONEY", shopCategory: "HONIG", }); diff --git a/ApiCore-Frontend/src/app/(app)/products/new/page.tsx b/ApiCore-Frontend/src/app/(app)/products/new/page.tsx index 4baf06f..fca5a1e 100644 --- a/ApiCore-Frontend/src/app/(app)/products/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/products/new/page.tsx @@ -17,15 +17,8 @@ import { Field } from "@/components/form/field"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; import { PRODUCT_KIND_LABELS, SHOP_CATEGORY_LABELS } from "@/lib/products/labels"; -import type { ProductKind, ShopCategory } from "@/lib/products/types"; import { ProductFormErrors, ProductFormValues, @@ -135,44 +128,24 @@ export default function NewProductPage() { hint="Honig wird abgefüllt; Kerzen, Tinkturen und Salben bekommen Herstellungschargen" > {({ id, describedBy }) => ( - + onChange={(kind) => set("kind", kind)} + labels={PRODUCT_KIND_LABELS} + /> )} {({ id, describedBy }) => ( - + onChange={(shopCategory) => set("shopCategory", shopCategory)} + labels={SHOP_CATEGORY_LABELS} + /> )} diff --git a/ApiCore-Frontend/src/app/(app)/products/new/product-form-values.ts b/ApiCore-Frontend/src/app/(app)/products/new/product-form-values.ts index a21ec15..536d885 100644 --- a/ApiCore-Frontend/src/app/(app)/products/new/product-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/products/new/product-form-values.ts @@ -3,6 +3,7 @@ import type { ProductKind, ShopCategory, } from "@/lib/products/types"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface ProductFormValues { @@ -38,19 +39,14 @@ export function validateProduct(values: ProductFormValues): ProductFormErrors { return errors; } -const textOrUndefined = (value: string) => - value.trim() ? value.trim() : undefined; -const numberOrUndefined = (value: string) => - value.trim() ? Number(value) : undefined; - export function toProductInput(values: ProductFormValues): CreateProductInput { return { name: values.name.trim(), - description: textOrUndefined(values.description), - sku: textOrUndefined(values.sku), - packagingSize: numberOrUndefined(values.packagingSize), - weight: numberOrUndefined(values.weight), - salesUnit: textOrUndefined(values.salesUnit), + description: emptyToNull(values.description), + sku: emptyToNull(values.sku), + packagingSize: emptyToNull(values.packagingSize, Number), + weight: emptyToNull(values.weight, Number), + salesUnit: emptyToNull(values.salesUnit), kind: values.kind, shopCategory: values.shopCategory, }; diff --git a/ApiCore-Frontend/src/app/(app)/queens/[id]/assign-colony-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/queens/[id]/assign-colony-dialog.spec.tsx new file mode 100644 index 0000000..515f816 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/queens/[id]/assign-colony-dialog.spec.tsx @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { AssignColonyDialog } from "./assign-colony-dialog"; +import { fetchColonies } from "@/lib/colonies/api"; +import { assignQueenColony } from "@/lib/queens/api"; + +vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); +vi.mock("@/lib/queens/api", () => ({ assignQueenColony: vi.fn() })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +function renderDialog() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("AssignColonyDialog", () => { + beforeEach(() => { + vi.mocked(fetchColonies).mockResolvedValue([ + { id: "colony-1", code: "V-01", status: "ACTIVE" }, + { id: "colony-2", code: "V-02", status: "QUEENLESS" }, + { id: "colony-3", code: "V-03", status: "MERGED" }, + ] as never); + vi.mocked(assignQueenColony).mockReset(); + vi.mocked(assignQueenColony).mockResolvedValue({} as never); + }); + + it("offers the queenless colony - the one that needs a queen most", async () => { + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: "Volk zuweisen" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("combobox")); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual(["V-01", "V-02 (Weisellos)"]); + + await userEvent.click(screen.getByRole("option", { name: "V-02 (Weisellos)" })); + await userEvent.click(within(dialog).getByRole("button", { name: "Zuweisen" })); + + expect(assignQueenColony).toHaveBeenCalledWith("queen-1", { colonyId: "colony-2" }); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/queens/[id]/assign-colony-dialog.tsx b/ApiCore-Frontend/src/app/(app)/queens/[id]/assign-colony-dialog.tsx index 1036f24..7f844bf 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/[id]/assign-colony-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/[id]/assign-colony-dialog.tsx @@ -9,7 +9,7 @@ import { QUEEN_UNAVAILABLE_STATUSES } from "@/lib/queens/labels"; import { QueenStatus } from "@/lib/queens/types"; import { fetchColonies } from "@/lib/colonies/api"; import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; +import { LivingColonySelectField } from "@/components/colony-select-field"; import { Dialog, DialogContent, @@ -18,13 +18,6 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; export function AssignColonyDialog({ queenId, @@ -72,23 +65,11 @@ export function AssignColonyDialog({ mutation.mutate(); }} > -
- - -
+ - ); - })} -
+ labelledBy={labelId} + describedBy={describedBy} + options={MARKING_COLOR_CHOICES} + value={values.markingColor} + clearValue={""} + onChange={(next) => set("markingColor", next)} + className="grid-cols-3 sm:grid-cols-5" + /> )} {({ id }) => ( - + set("motherQueenId", value)} + items={queens} + getLabel={(queen) => queen.code} + noneLabel="Keine Mutter hinterlegt" + /> )} diff --git a/ApiCore-Frontend/src/app/(app)/queens/new/queen-form-values.ts b/ApiCore-Frontend/src/app/(app)/queens/new/queen-form-values.ts index 6b63c20..9b0d9f2 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/new/queen-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/queens/new/queen-form-values.ts @@ -1,5 +1,6 @@ import type { createQueen } from "@/lib/queens/api"; import { suggestMarkingColorForYear } from "@/lib/queens/marking-color"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface QueenFormValues { @@ -53,19 +54,16 @@ export function withHatchYear( }; } -const textOrUndefined = (value: string) => - value.trim() ? value.trim() : undefined; - export function toQueenInput( values: QueenFormValues, ): Parameters[0] { return { code: values.code.trim(), - hatchYear: values.hatchYear.trim() ? Number(values.hatchYear) : undefined, - breed: textOrUndefined(values.breed), - origin: textOrUndefined(values.origin), - markingColor: textOrUndefined(values.markingColor), - markingNumber: textOrUndefined(values.markingNumber), - motherQueenId: values.motherQueenId || undefined, + hatchYear: emptyToNull(values.hatchYear, Number), + breed: emptyToNull(values.breed), + origin: emptyToNull(values.origin), + markingColor: emptyToNull(values.markingColor), + markingNumber: emptyToNull(values.markingNumber), + motherQueenId: emptyToNull(values.motherQueenId), }; } diff --git a/ApiCore-Frontend/src/app/(app)/queens/page.tsx b/ApiCore-Frontend/src/app/(app)/queens/page.tsx index 0e43d37..fc7fbf0 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/page.tsx @@ -14,13 +14,7 @@ import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; import { QueryState } from "@/components/query-state"; import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; export default function QueensPage() { const [tab, setTab] = useState("active"); @@ -61,24 +55,15 @@ export default function QueensPage() { activeCount={activeQueens.length} historyCount={historyQueens.length} /> - +
diff --git a/ApiCore-Frontend/src/app/(app)/quick-add-menu.spec.tsx b/ApiCore-Frontend/src/app/(app)/quick-add-menu.spec.tsx new file mode 100644 index 0000000..4b0a1e8 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/quick-add-menu.spec.tsx @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QuickAddMenu } from "./quick-add-menu"; + +let mockPathname = "/dashboard"; +vi.mock("next/navigation", () => ({ + usePathname: () => mockPathname, +})); + +describe("QuickAddMenu", () => { + beforeEach(() => { + mockPathname = "/dashboard"; + }); + + it.each(["/dashboard", "/colonies", "/colonies/abc", "/news", "/tasks/renewal"])( + "offers the quick-add button on %s", + (pathname) => { + mockPathname = pathname; + + render(); + + expect(screen.getByRole("button", { name: "Neu" })).toBeInTheDocument(); + }, + ); + + it.each([ + "/inspections/new", + "/settings/label-templates/new", + "/colonies/abc/edit", + "/colonies/new/", + ])("hides the quick-add button on the form route %s so it cannot cover the save bar", (pathname) => { + mockPathname = pathname; + + render(); + + expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/quick-add-menu.tsx b/ApiCore-Frontend/src/app/(app)/quick-add-menu.tsx index 30105c1..7827761 100644 --- a/ApiCore-Frontend/src/app/(app)/quick-add-menu.tsx +++ b/ApiCore-Frontend/src/app/(app)/quick-add-menu.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -20,14 +21,29 @@ const QUICK_ACTIONS = [ { label: "Ernte", href: "/harvests/new" }, ]; +// Create and edit forms end in a save bar (StickySubmit) right where this +// button floats. Deciding by route rather than letting StickySubmit register +// itself in a context is known on the first render (no flash, no layout +// shift), needs no client state in StickySubmit and also covers form routes +// without a sticky bar. Mid-form, jumping to another new form is no quick +// action anyway. +const FORM_ROUTE = /\/(new|edit)(\/|$)/; + export function QuickAddMenu() { + const pathname = usePathname(); + + if (FORM_ROUTE.test(pathname)) { + return null; + } + return ( } diff --git a/ApiCore-Frontend/src/app/(app)/raw-materials/new/page.tsx b/ApiCore-Frontend/src/app/(app)/raw-materials/new/page.tsx index 2290415..93a2967 100644 --- a/ApiCore-Frontend/src/app/(app)/raw-materials/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/raw-materials/new/page.tsx @@ -15,24 +15,20 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -const NO_APIARY = "__none__"; +import { EnumSelect } from "@/components/form/enum-select"; +import { RecordSelect } from "@/components/form/record-select"; +import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; export default function NewRawMaterialPage() { const router = useRouter(); const queryClient = useQueryClient(); const [kind, setKind] = useState("BEESWAX"); const [lotNumber, setLotNumber] = useState(""); - const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [date, setDate] = useState(todayLocalIsoDate); const [quantity, setQuantity] = useState(""); - const [apiaryId, setApiaryId] = useState(NO_APIARY); + // "" = "Kein Stand". + const [apiaryId, setApiaryId] = useState(""); const [notes, setNotes] = useState(""); const [error, setError] = useState(null); @@ -40,17 +36,17 @@ export default function NewRawMaterialPage() { queryKey: ["apiaries"], queryFn: () => fetchApiaries(), }); - const selectedApiary = apiaries?.find((apiary) => apiary.id === apiaryId); const mutation = useMutation({ mutationFn: () => createRawMaterialLot({ kind, - lotNumber: lotNumber.trim() || undefined, - date: new Date(date).toISOString(), + // Empty: the server generates the number (automation format). + lotNumber: emptyToNull(lotNumber), + date: toApiDate(date), quantity: Number(quantity), - apiaryId: apiaryId === NO_APIARY ? undefined : apiaryId, - notes: notes.trim() || undefined, + apiaryId: emptyToNull(apiaryId), + notes: emptyToNull(notes), }), onSuccess: (lot) => { void queryClient.invalidateQueries({ queryKey: ["raw-material-lots"] }); @@ -86,25 +82,12 @@ export default function NewRawMaterialPage() { >
- + onChange={setKind} + labels={RAW_MATERIAL_KIND_LABELS} + />
@@ -143,24 +126,14 @@ export default function NewRawMaterialPage() {
- + onChange={setApiaryId} + items={apiaries} + getLabel={(apiary) => apiary.name} + noneLabel="Kein Stand" + />
diff --git a/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx index 0e9e0db..2c157b1 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/audit-log/page.tsx @@ -5,7 +5,8 @@ import { History } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { fetchAuditLog } from "@/lib/audit-log/api"; import { - AUDIT_ENTITY_TYPE_OPTIONS, + AUDIT_ENTITY_TYPE_FILTER_VALUES, + AUDIT_ENTITY_TYPE_LABELS, auditActionLabel, auditEntityTypeLabel, } from "@/lib/audit-log/labels"; @@ -15,15 +16,8 @@ import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { QueryState } from "@/components/query-state"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; -const ALL = "ALL"; export default function AuditLogPage() { const [entityType, setEntityType] = useState(""); @@ -73,24 +67,14 @@ export default function AuditLogPage() { - +
- + onChange={setRecurrenceType} + labels={RECURRENCE_TYPE_LABELS} + />
{recurrenceType === "YEARLY" ? (
- + onChange={setRecurrenceMonth} + values={MONTHS} + labels={MONTH_LABELS} + />
) : null} {recurrenceType === "YEARLY" || recurrenceType === "MONTHLY" ? ( diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx index 8910e2b..28be066 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx @@ -4,6 +4,7 @@ import { FormEvent, useState } from "react"; import { Pencil } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { updateTaskTemplate } from "@/lib/task-templates/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types"; import { TaskPriority } from "@/lib/tasks/types"; import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels"; @@ -12,13 +13,14 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { EnumSelect } from "@/components/form/enum-select"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + MONTH_LABELS, + MONTHS, + RECURRENCE_TYPE_LABELS, + toMonth, +} from "@/lib/task-templates/labels"; +import { RecordSelect } from "@/components/form/record-select"; import { Dialog, DialogContent, @@ -28,31 +30,6 @@ import { DialogTrigger, } from "@/components/ui/dialog"; -/** Sentinel for "nobody assigned" - an empty select value renders as the - * placeholder instead of a choosable entry. */ -const NO_ASSIGNEE = "__none__"; - -const RECURRENCE_LABELS: Record = { - YEARLY: "Jährlich", - MONTHLY: "Monatlich", - INTERVAL_DAYS: "Alle X Tage", -}; - -const MONTH_LABELS = [ - "Januar", - "Februar", - "März", - "April", - "Mai", - "Juni", - "Juli", - "August", - "September", - "Oktober", - "November", - "Dezember", -]; - export function EditTaskTemplateDialog({ template }: { template: TaskTemplate }) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); @@ -61,9 +38,7 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate }) const [recurrenceType, setRecurrenceType] = useState( template.recurrenceType, ); - const [recurrenceMonth, setRecurrenceMonth] = useState( - template.recurrenceMonth?.toString() ?? "1", - ); + const [recurrenceMonth, setRecurrenceMonth] = useState(toMonth(template.recurrenceMonth)); const [recurrenceDayOfMonth, setRecurrenceDayOfMonth] = useState( template.recurrenceDayOfMonth?.toString() ?? "1", ); @@ -71,9 +46,8 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate }) const [priority, setPriority] = useState( template.priority ?? "NORMAL", ); - const [assignedUserId, setAssignedUserId] = useState( - template.assignedUserId ?? NO_ASSIGNEE, - ); + // "" = "Niemand". + const [assignedUserId, setAssignedUserId] = useState(template.assignedUserId ?? ""); const [active, setActive] = useState(template.active); const [error, setError] = useState(null); @@ -89,7 +63,7 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate }) mutationFn: () => updateTaskTemplate(template.id, { title, - description: description || undefined, + description: emptyToNull(description), recurrenceType, recurrenceMonth: recurrenceType === "YEARLY" ? Number(recurrenceMonth) : undefined, recurrenceDayOfMonth: @@ -98,8 +72,7 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate }) : undefined, intervalDays: recurrenceType === "INTERVAL_DAYS" ? Number(intervalDays) : undefined, priority, - assignedUserId: - assignedUserId === NO_ASSIGNEE ? undefined : assignedUserId, + assignedUserId: emptyToNull(assignedUserId), active, }), onSuccess: () => { @@ -149,44 +122,23 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
- + onChange={setRecurrenceType} + labels={RECURRENCE_TYPE_LABELS} + />
{recurrenceType === "YEARLY" ? (
- + onChange={setRecurrenceMonth} + values={MONTHS} + labels={MONTH_LABELS} + />
) : null} {recurrenceType === "YEARLY" || recurrenceType === "MONTHLY" ? ( @@ -220,43 +172,25 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate }) ) : null}
- + onChange={setPriority} + values={TASK_PRIORITIES} + labels={TASK_PRIORITY_LABELS} + />
{users ? (
- + onChange={setAssignedUserId} + items={users} + getLabel={(user) => user.name} + noneLabel="Niemand" + />
) : null}
- + onChange={setRoleId} + items={roles ? selectableRoles : undefined} + getLabel={(role) => role.name} + placeholder="Rolle wählen" + />
diff --git a/ApiCore-Frontend/src/app/(app)/settings/users-roles/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/users-roles/page.tsx index d8b9198..3804ca4 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users-roles/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users-roles/page.tsx @@ -22,7 +22,8 @@ function isTab(value: string | null): value is Tab { function UsersRolesPageContent() { const router = useRouter(); const searchParams = useSearchParams(); - const tab: Tab = isTab(searchParams.get("tab")) ? searchParams.get("tab") as Tab : "users"; + const requestedTab = searchParams.get("tab"); + const tab: Tab = isTab(requestedTab) ? requestedTab : "users"; function setTab(next: Tab) { router.replace(`/settings/users-roles?tab=${next}`); diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx index 4021176..e41ce57 100644 --- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx +++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx @@ -191,7 +191,11 @@ export function Sidebar({ user }: { user: AuthUser }) { : NAV_GROUPS; return ( -
- + onChange={setCategory} + values={STOCK_CATEGORIES} + labels={STOCK_CATEGORY_LABELS} + />
diff --git a/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx index c02be36..346112f 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx @@ -14,6 +14,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; export function CreateStorageContainerDialog() { const queryClient = useQueryClient(); @@ -45,9 +46,9 @@ export function CreateStorageContainerDialog() { setError(null); mutation.mutate({ code, - containerType: containerType || undefined, - capacity: capacity ? Number(capacity) : undefined, - location: location || undefined, + containerType: emptyToNull(containerType), + capacity: emptyToNull(capacity, Number), + location: emptyToNull(location), }); } diff --git a/ApiCore-Frontend/src/app/(app)/storage/edit-stock-item-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/edit-stock-item-dialog.tsx index 9cd9785..3c3e2a8 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/edit-stock-item-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/edit-stock-item-dialog.tsx @@ -5,6 +5,7 @@ import { Pencil } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateStockItem } from "@/lib/storage/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { StockCategory, StockItem } from "@/lib/storage/types"; import { STOCK_CATEGORIES, STOCK_CATEGORY_LABELS } from "@/lib/storage/labels"; import { Button } from "@/components/ui/button"; @@ -19,13 +20,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; export function EditStockItemDialog({ item }: { item: StockItem }) { const queryClient = useQueryClient(); @@ -44,8 +39,8 @@ export function EditStockItemDialog({ item }: { item: StockItem }) { category, unit, quantity: Number(quantity), - minThreshold: minThreshold ? Number(minThreshold) : undefined, - notes: notes || undefined, + minThreshold: emptyToNull(minThreshold, Number), + notes: emptyToNull(notes), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["stock-items"] }); @@ -86,21 +81,13 @@ export function EditStockItemDialog({ item }: { item: StockItem }) {
- + onChange={setCategory} + values={STOCK_CATEGORIES} + labels={STOCK_CATEGORY_LABELS} + />
diff --git a/ApiCore-Frontend/src/app/(app)/storage/edit-storage-container-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/edit-storage-container-dialog.tsx index 5c47d30..d1acb4b 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/edit-storage-container-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/edit-storage-container-dialog.tsx @@ -5,6 +5,7 @@ import { Pencil } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { updateStorageContainer } from "@/lib/storage/api"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; import { StorageContainer } from "@/lib/storage/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -30,9 +31,9 @@ export function EditStorageContainerDialog({ container }: { container: StorageCo mutationFn: () => updateStorageContainer(container.id, { code, - containerType: containerType || undefined, - capacity: capacity ? Number(capacity) : undefined, - location: location || undefined, + containerType: emptyToNull(containerType), + capacity: emptyToNull(capacity, Number), + location: emptyToNull(location), }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["storage-containers"] }); diff --git a/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx index 1005af3..810c9ff 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx @@ -17,13 +17,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; export function RecordMovementDialog({ stockItemId }: { stockItemId: string }) { const queryClient = useQueryClient(); @@ -68,21 +62,13 @@ export function RecordMovementDialog({ stockItemId }: { stockItemId: string }) {
- + onChange={setType} + values={STOCK_MOVEMENT_TYPES} + labels={STOCK_MOVEMENT_TYPE_LABELS} + />
diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx index 23ffb2a..dc7f593 100644 --- a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.spec.tsx @@ -74,7 +74,7 @@ describe("NewSwarmControlCheckPage", () => { swarmMood: "HIGH", actionTaken: "ARTIFICIAL_SWARM", }); - expect(input.nextCheckDate).toBeUndefined(); + expect(input.nextCheckDate).toBeNull(); expect(options?.idempotencyKey).toBeTruthy(); expect(push).toHaveBeenCalledWith("/colonies/colony-1"); }); diff --git a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx index df60de1..0a62f9b 100644 --- a/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/swarm-control/new/page.tsx @@ -2,35 +2,27 @@ import { FormEvent, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Sprout } from "lucide-react"; -import { fetchColonies } from "@/lib/colonies/api"; import { createSwarmControlCheck } from "@/lib/swarm-control/api"; import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS, } from "@/lib/swarm-control/labels"; -import { SwarmControlAction, SwarmMood } from "@/lib/swarm-control/types"; import { ApiError } from "@/lib/auth/api-client"; import { queueSwarmControlCheck } from "@/lib/offline/db"; import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { useFormDraft } from "@/lib/forms/use-form-draft"; import { errorMessage } from "@/lib/forms/error-message"; -import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; +import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; +import { LivingColonySelectField } from "@/components/colony-select-field"; import { FormSection } from "@/components/form/form-section"; import { ToggleChip } from "@/components/form/toggle-chip"; import { StickySubmit } from "@/components/form/sticky-submit"; @@ -45,8 +37,8 @@ type RequiredKey = "colonyId" | "date"; type FieldErrors = Partial>; const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date"]; -const SWARM_MOODS = Object.keys(SWARM_MOOD_LABELS) as SwarmMood[]; -const SWARM_ACTIONS = Object.keys(SWARM_ACTION_LABELS) as SwarmControlAction[]; +const SWARM_MOOD_OPTIONS = choiceOptions(SWARM_MOOD_LABELS); +const SWARM_ACTION_OPTIONS = choiceOptions(SWARM_ACTION_LABELS); function validate(values: SwarmControlFormValues): FieldErrors { const errors: FieldErrors = {}; @@ -55,27 +47,11 @@ function validate(values: SwarmControlFormValues): FieldErrors { return errors; } -function tileClass(selected: boolean) { - return cn( - "min-h-11 rounded-lg border px-2 text-sm motion-safe:transition-colors", - selected - ? "border-primary bg-primary font-medium text-primary-foreground" - : "border-border bg-background hover:bg-muted", - ); -} - export default function NewSwarmControlCheckPage() { const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const { data: colonies, isLoading: coloniesLoading } = useQuery({ - queryKey: ["colonies"], - queryFn: () => fetchColonies(), - }); - const activeColonies = - colonies?.filter((colony) => colony.status === "ACTIVE") ?? []; - const [values, setValues] = useState(() => initialSwarmControlValues(searchParams.get("colonyId") ?? ""), ); @@ -175,46 +151,12 @@ export default function NewSwarmControlCheckPage() { noValidate > - set("colonyId", colonyId)} error={fieldErrors.colonyId} - hint={ - !coloniesLoading && activeColonies.length === 0 - ? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen." - : undefined - } - > - {({ describedBy, invalid }) => ( - - )} - + /> {({ id, describedBy, invalid }) => ( {({ id, labelId, describedBy }) => ( -
- {SWARM_MOODS.map((option) => { - const selected = values.swarmMood === option; - return ( - - ); - })} -
+ labelledBy={labelId} + describedBy={describedBy} + options={SWARM_MOOD_OPTIONS} + value={values.swarmMood} + onChange={(next) => set("swarmMood", next)} + className="grid-cols-3" + /> )}
- + {({ id, labelId }) => ( -
- {SWARM_ACTIONS.map((option) => { - const selected = values.actionTaken === option; - return ( - - ); - })} -
+ labelledBy={labelId} + options={SWARM_ACTION_OPTIONS} + value={values.actionTaken} + onChange={(next) => set("actionTaken", next)} + className="grid-cols-2" + /> )}
[0] { return { colonyId: values.colonyId, - date: new Date(values.date).toISOString(), - queenCellsFound: values.queenCellsFound.trim() - ? Number(values.queenCellsFound) - : undefined, + date: toApiDate(values.date), + queenCellsFound: emptyToNull(values.queenCellsFound, Number), queenCellsCapped: values.queenCellsCapped, swarmMood: values.swarmMood, actionTaken: values.actionTaken, - nextCheckDate: values.nextCheckDate - ? new Date(values.nextCheckDate).toISOString() - : undefined, - notes: values.notes.trim() ? values.notes.trim() : undefined, + nextCheckDate: emptyToNull(values.nextCheckDate, toApiDate), + notes: emptyToNull(values.notes), }; } diff --git a/ApiCore-Frontend/src/app/(app)/tasks/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/tasks/new/page.spec.tsx index b74d498..58b397c 100644 --- a/ApiCore-Frontend/src/app/(app)/tasks/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/tasks/new/page.spec.tsx @@ -56,7 +56,7 @@ describe("NewTaskPage", () => { title: "Honigraum aufsetzen", priority: "URGENT", }); - expect(input.dueDate).toBeUndefined(); + expect(input.dueDate).toBeNull(); expect(push).toHaveBeenCalledWith("/tasks"); }); diff --git a/ApiCore-Frontend/src/app/(app)/tasks/new/page.tsx b/ApiCore-Frontend/src/app/(app)/tasks/new/page.tsx index 4dc0f84..cca6391 100644 --- a/ApiCore-Frontend/src/app/(app)/tasks/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/tasks/new/page.tsx @@ -10,20 +10,14 @@ import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels"; import { fetchUsers } from "@/lib/users/api"; import { useFormDraft } from "@/lib/forms/use-form-draft"; import { errorMessage } from "@/lib/forms/error-message"; -import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { RecordSelect } from "@/components/form/record-select"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; +import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; @@ -37,6 +31,7 @@ type RequiredKey = "title"; type FieldErrors = Partial>; const REQUIRED_KEYS: RequiredKey[] = ["title"]; +const TASK_PRIORITY_OPTIONS = choiceOptions(TASK_PRIORITY_LABELS, TASK_PRIORITIES); function validate(values: TaskFormValues): FieldErrors { const errors: FieldErrors = {}; @@ -153,35 +148,16 @@ export default function NewTaskPage() { /> )} - + {({ id, labelId }) => ( -
- {TASK_PRIORITIES.map((option) => { - const selected = values.priority === option; - return ( - - ); - })} -
+ labelledBy={labelId} + options={TASK_PRIORITY_OPTIONS} + value={values.priority} + onChange={(next) => set("priority", next)} + className="grid-cols-2 sm:grid-cols-4" + /> )}
@@ -207,33 +183,14 @@ export default function NewTaskPage() { {users ? ( {({ id }) => ( - + set("assignedUserId", value)} + items={users} + getLabel={(user) => user.name} + noneLabel="Niemand zugewiesen" + /> )} ) : null} diff --git a/ApiCore-Frontend/src/app/(app)/tasks/new/task-form-values.ts b/ApiCore-Frontend/src/app/(app)/tasks/new/task-form-values.ts index a069321..c9f1dc9 100644 --- a/ApiCore-Frontend/src/app/(app)/tasks/new/task-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/tasks/new/task-form-values.ts @@ -1,5 +1,7 @@ import type { createTask } from "@/lib/tasks/api"; import type { TaskPriority } from "@/lib/tasks/types"; +import { toApiDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface TaskFormValues { @@ -25,13 +27,9 @@ export function toTaskInput( ): Parameters[0] { return { title: values.title.trim(), - description: values.description.trim() - ? values.description.trim() - : undefined, + description: emptyToNull(values.description), priority: values.priority, - dueDate: values.dueDate - ? new Date(values.dueDate).toISOString() - : undefined, - assignedUserId: values.assignedUserId || undefined, + dueDate: emptyToNull(values.dueDate, toApiDate), + assignedUserId: emptyToNull(values.assignedUserId), }; } diff --git a/ApiCore-Frontend/src/app/(app)/tasks/page.tsx b/ApiCore-Frontend/src/app/(app)/tasks/page.tsx index f8d0887..e3268b8 100644 --- a/ApiCore-Frontend/src/app/(app)/tasks/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/tasks/page.tsx @@ -14,13 +14,7 @@ import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { EnumSelect } from "@/components/form/enum-select"; import { TaskEditDialog } from "./task-edit-dialog"; const HISTORY_STATUSES: TaskStatus[] = ["DONE", "CANCELLED"]; @@ -76,26 +70,15 @@ export default function TasksPage() { activeCount={activeTasks.length} historyCount={historyTasks.length} /> - + {tab === "active" ? (
- + onChange={setPriority} + values={TASK_PRIORITIES} + labels={TASK_PRIORITY_LABELS} + />
{users ? (
- + user.name} + noneLabel="Niemand zugewiesen" + />
) : null}
- + onChange={setStatus} + values={TASK_STATUSES} + labels={TASK_STATUS_LABELS} + />

Hier lässt sich eine versehentlich erledigte oder abgebrochene Aufgabe wieder öffnen. diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx index b4b6a66..fcabe86 100644 --- a/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/treatments/new/page.spec.tsx @@ -76,7 +76,7 @@ describe("NewTreatmentPage", () => { withdrawalPeriodDays: 0, supplier: "Imkereibedarf Muster", }); - expect(input.startDate).toBeUndefined(); + expect(input.startDate).toBeNull(); expect(options?.idempotencyKey).toBeTruthy(); expect(push).toHaveBeenCalledWith("/colonies/colony-1"); }); diff --git a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx index 5fa0727..8e28a0a 100644 --- a/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/treatments/new/page.tsx @@ -2,10 +2,9 @@ import { FormEvent, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Syringe } from "lucide-react"; -import { fetchColonies } from "@/lib/colonies/api"; import { createTreatment } from "@/lib/treatments/api"; import { ApiError } from "@/lib/auth/api-client"; import { queueTreatment } from "@/lib/offline/db"; @@ -15,16 +14,10 @@ import { errorMessage } from "@/lib/forms/error-message"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; +import { LivingColonySelectField } from "@/components/colony-select-field"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; @@ -52,12 +45,6 @@ export default function NewTreatmentPage() { const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const { data: colonies, isLoading: coloniesLoading } = useQuery({ - queryKey: ["colonies"], - queryFn: () => fetchColonies(), - }); - const activeColonies = - colonies?.filter((colony) => colony.status === "ACTIVE") ?? []; const [values, setValues] = useState(() => initialTreatmentValues(searchParams.get("colonyId") ?? ""), @@ -158,46 +145,12 @@ export default function NewTreatmentPage() { noValidate > - set("colonyId", colonyId)} error={fieldErrors.colonyId} - hint={ - !coloniesLoading && activeColonies.length === 0 - ? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen." - : undefined - } - > - {({ describedBy, invalid }) => ( - - )} - + /> {({ id, describedBy, invalid }) => ( - value.trim() ? value.trim() : undefined; -const dateOrUndefined = (value: string) => - value ? new Date(value).toISOString() : undefined; - export function toTreatmentInput( values: TreatmentFormValues, ): Parameters[0] { return { colonyId: values.colonyId, - date: new Date(values.date).toISOString(), - startDate: dateOrUndefined(values.startDate), - endDate: dateOrUndefined(values.endDate), + date: toApiDate(values.date), + startDate: emptyToNull(values.startDate, toApiDate), + endDate: emptyToNull(values.endDate, toApiDate), product: values.product.trim(), - activeIngredient: textOrUndefined(values.activeIngredient), - amount: textOrUndefined(values.amount), - method: textOrUndefined(values.method), - reason: textOrUndefined(values.reason), - batchNumber: textOrUndefined(values.batchNumber), - notes: textOrUndefined(values.notes), - withdrawalPeriodDays: values.withdrawalPeriodDays.trim() - ? Number(values.withdrawalPeriodDays) - : undefined, - supplier: textOrUndefined(values.supplier), - purchaseProof: textOrUndefined(values.purchaseProof), - prescribingVet: textOrUndefined(values.prescribingVet), + activeIngredient: emptyToNull(values.activeIngredient), + amount: emptyToNull(values.amount), + method: emptyToNull(values.method), + reason: emptyToNull(values.reason), + batchNumber: emptyToNull(values.batchNumber), + notes: emptyToNull(values.notes), + withdrawalPeriodDays: emptyToNull(values.withdrawalPeriodDays, Number), + supplier: emptyToNull(values.supplier), + purchaseProof: emptyToNull(values.purchaseProof), + prescribingVet: emptyToNull(values.prescribingVet), }; } diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx index a543f34..a19187b 100644 --- a/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.spec.tsx @@ -69,7 +69,7 @@ describe("NewVarroaMeasurementPage", () => { miteCount: 12, durationMinutes: 5, }); - expect(input.assessment).toBeUndefined(); + expect(input.assessment).toBeNull(); expect(options?.idempotencyKey).toBeTruthy(); expect(push).toHaveBeenCalledWith("/colonies/colony-1"); }); diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx index e4888d8..aab65df 100644 --- a/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/varroa/new/page.tsx @@ -2,10 +2,9 @@ import { FormEvent, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Bug } from "lucide-react"; -import { fetchColonies } from "@/lib/colonies/api"; import { createVarroaMeasurement } from "@/lib/varroa/api"; import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels"; import { ApiError } from "@/lib/auth/api-client"; @@ -13,20 +12,14 @@ import { queueVarroaMeasurement } from "@/lib/offline/db"; import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { useFormDraft } from "@/lib/forms/use-form-draft"; import { errorMessage } from "@/lib/forms/error-message"; -import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Field } from "@/components/form/field"; +import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; +import { LivingColonySelectField } from "@/components/colony-select-field"; import { FormSection } from "@/components/form/form-section"; import { StickySubmit } from "@/components/form/sticky-submit"; import { DraftBanner } from "@/components/form/draft-banner"; @@ -41,6 +34,7 @@ type RequiredKey = "colonyId" | "date"; type FieldErrors = Partial>; const REQUIRED_KEYS: RequiredKey[] = ["colonyId", "date"]; +const VARROA_METHOD_OPTIONS = choiceOptions(VARROA_METHOD_LABELS, VARROA_METHODS); function validate(values: VarroaFormValues): FieldErrors { const errors: FieldErrors = {}; @@ -54,13 +48,6 @@ export default function NewVarroaMeasurementPage() { const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const { data: colonies, isLoading: coloniesLoading } = useQuery({ - queryKey: ["colonies"], - queryFn: () => fetchColonies(), - }); - const activeColonies = - colonies?.filter((colony) => colony.status === "ACTIVE") ?? []; - const [values, setValues] = useState(() => initialVarroaValues(searchParams.get("colonyId") ?? ""), ); @@ -162,46 +149,12 @@ export default function NewVarroaMeasurementPage() { noValidate > - set("colonyId", colonyId)} error={fieldErrors.colonyId} - hint={ - !coloniesLoading && activeColonies.length === 0 - ? "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen." - : undefined - } - > - {({ describedBy, invalid }) => ( - - )} - + /> {({ id, describedBy, invalid }) => ( - + {({ id, labelId, describedBy }) => ( -

- {VARROA_METHODS.map((option) => { - const selected = values.method === option; - return ( - - ); - })} -
+ labelledBy={labelId} + describedBy={describedBy} + options={VARROA_METHOD_OPTIONS} + value={values.method} + onChange={(next) => set("method", next)} + className="grid-cols-2" + /> )} diff --git a/ApiCore-Frontend/src/app/(app)/varroa/new/varroa-form-values.ts b/ApiCore-Frontend/src/app/(app)/varroa/new/varroa-form-values.ts index 4ae11e1..9a8ab60 100644 --- a/ApiCore-Frontend/src/app/(app)/varroa/new/varroa-form-values.ts +++ b/ApiCore-Frontend/src/app/(app)/varroa/new/varroa-form-values.ts @@ -1,5 +1,7 @@ import type { createVarroaMeasurement } from "@/lib/varroa/api"; import type { VarroaMethod } from "@/lib/varroa/types"; +import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; /** Everything the form holds, as plain values that survive a draft save. */ export interface VarroaFormValues { @@ -24,14 +26,10 @@ export const VARROA_METHOD_HINTS: Record = { "Entnommene Drohnenbrut öffnen und befallene Zellen bzw. Milben zählen.", }; -function todayIsoDate(): string { - return new Date().toISOString().slice(0, 10); -} - export function initialVarroaValues(colonyId: string): VarroaFormValues { return { colonyId, - date: todayIsoDate(), + date: todayLocalIsoDate(), method: "NATURAL_MITE_FALL", miteCount: "", durationMinutes: "", @@ -40,21 +38,16 @@ export function initialVarroaValues(colonyId: string): VarroaFormValues { }; } -const textOrUndefined = (value: string) => - value.trim() ? value.trim() : undefined; -const numberOrUndefined = (value: string) => - value.trim() ? Number(value) : undefined; - export function toVarroaInput( values: VarroaFormValues, ): Parameters[0] { return { colonyId: values.colonyId, - date: new Date(values.date).toISOString(), + date: toApiDate(values.date), method: values.method, - miteCount: numberOrUndefined(values.miteCount), - durationMinutes: numberOrUndefined(values.durationMinutes), - assessment: textOrUndefined(values.assessment), - notes: textOrUndefined(values.notes), + miteCount: emptyToNull(values.miteCount, Number), + durationMinutes: emptyToNull(values.durationMinutes, Number), + assessment: emptyToNull(values.assessment), + notes: emptyToNull(values.notes), }; } diff --git a/ApiCore-Frontend/src/app/(app)/winter/page.tsx b/ApiCore-Frontend/src/app/(app)/winter/page.tsx index 768aa49..44e5b8b 100644 --- a/ApiCore-Frontend/src/app/(app)/winter/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/winter/page.tsx @@ -18,7 +18,6 @@ import { fetchColonies } from "@/lib/colonies/api"; import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api"; import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels"; import { - ColonyLossCause, WinterCheckWithColony, WinterCounts, WinterOutcome, @@ -31,6 +30,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { WinterCheckDialog, WinterCheckTarget } from "./winter-check-dialog"; +import { keysOf } from "@/lib/keys-of"; const DATE = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit" }); @@ -71,7 +71,7 @@ function SeamsHint() { function SummaryCards({ counts }: { counts: WinterCounts }) { // Before the first Auswinterung a rate of "0 %" would read as "no losses". const hasSpringResults = counts.survived + counts.weak + counts.lost > 0; - const causes = (Object.keys(counts.lostByCause) as ColonyLossCause[]).filter( + const causes = keysOf(counts.lostByCause).filter( (cause) => counts.lostByCause[cause] > 0, ); return ( diff --git a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.spec.tsx new file mode 100644 index 0000000..daa38c3 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.spec.tsx @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WinterCheckDialog, type WinterCheckTarget } from "./winter-check-dialog"; +import { createWinterCheck, updateWinterCheck } from "@/lib/winter-checks/api"; +import type { WinterCheck } from "@/lib/winter-checks/types"; + +vi.mock("@/lib/winter-checks/api", () => ({ + createWinterCheck: vi.fn(), + updateWinterCheck: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const existing: WinterCheck = { + id: "w-1", + colonyId: "c-1", + userId: "u-1", + winterYear: 2026, + season: "AUTUMN", + date: "2026-10-01T00:00:00.000Z", + occupiedSeams: 7, + feedStockKg: 18, + queenPresent: true, + outcome: null, + lossCause: null, + notes: null, + createdAt: "", + updatedAt: "", +}; + +function renderDialog(target: Partial = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +const queenGroup = () => screen.getByRole("radiogroup", { name: "Königin/Stifte gesehen" }); + +describe("WinterCheckDialog", () => { + beforeEach(() => { + vi.mocked(createWinterCheck).mockReset().mockResolvedValue(existing); + vi.mocked(updateWinterCheck).mockReset().mockResolvedValue(existing); + }); + + it("asks about the queen with the same Ja / Nein / Nicht geprüft control as the inspection", () => { + renderDialog({ existing }); + + expect(within(queenGroup()).getByRole("radio", { name: "Ja" })).toHaveAttribute( + "aria-checked", + "true", + ); + }); + + it("clears an earlier answer by sending null", async () => { + renderDialog({ existing }); + + await userEvent.click(within(queenGroup()).getByRole("radio", { name: "Nicht geprüft" })); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateWinterCheck).mock.calls[0]; + expect(input).toHaveProperty("queenPresent", null); + }); + + it("clears emptied numbers and the note by sending null", async () => { + renderDialog({ existing: { ...existing, notes: "schwer" } }); + + await userEvent.clear(screen.getByLabelText("Besetzte Wabengassen")); + await userEvent.clear(screen.getByLabelText(/Futtervorrat/)); + await userEvent.clear(screen.getByLabelText("Notiz")); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateWinterCheck).mock.calls[0]; + expect(input).toMatchObject({ + occupiedSeams: null, + feedStockKg: null, + notes: null, + outcome: null, + lossCause: null, + }); + }); + + it("reads a decimal comma as a decimal point", async () => { + renderDialog(); + + await userEvent.type(screen.getByLabelText(/Futtervorrat/), "12,5"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [input] = vi.mocked(createWinterCheck).mock.calls[0]; + expect(input).toMatchObject({ feedStockKg: 12.5, occupiedSeams: null }); + }); + + it("records a lost colony with its loss cause", async () => { + renderDialog({ season: "SPRING" }); + + await userEvent.click(screen.getByRole("button", { name: "Verloren" })); + expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled(); + const cause = screen.getByRole("combobox", { name: "Verlustursache" }); + expect(cause).toHaveTextContent("Ursache wählen"); + await userEvent.click(cause); + await userEvent.click( + await screen.findByRole("option", { name: "Unlösbares Königinnenproblem" }), + ); + expect(cause).toHaveTextContent("Unlösbares Königinnenproblem"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [input] = vi.mocked(createWinterCheck).mock.calls[0]; + expect(input).toMatchObject({ outcome: "LOST", lossCause: "QUEEN_PROBLEM" }); + }); + + it("starts a new check as not checked", async () => { + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [input] = vi.mocked(createWinterCheck).mock.calls[0]; + expect(input).toHaveProperty("queenPresent", null); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx index 1c34496..000436e 100644 --- a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx @@ -21,15 +21,12 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { TriStateField } from "@/components/form/tri-state-field"; +import { EnumSelect } from "@/components/form/enum-select"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; +import { emptyToNull } from "@/lib/forms/empty-to-null"; export interface WinterCheckTarget { colonyId: string; @@ -40,18 +37,9 @@ export interface WinterCheckTarget { existing: WinterCheck | null; } -const QUEEN_OPTIONS = { yes: "Ja", no: "Nein", unknown: "Nicht geprüft" } as const; -type QueenOption = keyof typeof QUEEN_OPTIONS; - -function queenOption(value: boolean | null | undefined): QueenOption { - if (value === true) return "yes"; - if (value === false) return "no"; - return "unknown"; -} - -function toNumber(value: string): number | undefined { - const trimmed = value.trim().replace(",", "."); - return trimmed === "" ? undefined : Number(trimmed); +/** Numbers as typed on a German keyboard: "12,5" means 12.5. */ +function parseDecimal(value: string): number { + return Number(value.replace(",", ".")); } const OUTCOMES: WinterOutcome[] = ["SURVIVED", "WEAK", "LOST"]; @@ -67,11 +55,13 @@ export function WinterCheckDialog({ const existing = target.existing; const isSpring = target.season === "SPRING"; const [date, setDate] = useState( - existing?.date.slice(0, 10) ?? new Date().toISOString().slice(0, 10), + existing?.date.slice(0, 10) ?? todayLocalIsoDate(), ); const [occupiedSeams, setOccupiedSeams] = useState(existing?.occupiedSeams?.toString() ?? ""); const [feedStockKg, setFeedStockKg] = useState(existing?.feedStockKg?.toString() ?? ""); - const [queen, setQueen] = useState(queenOption(existing?.queenPresent)); + const [queenPresent, setQueenPresent] = useState( + existing?.queenPresent ?? null, + ); const [outcome, setOutcome] = useState(existing?.outcome ?? null); const [lossCause, setLossCause] = useState(existing?.lossCause ?? ""); const [markDeceased, setMarkDeceased] = useState(target.isLiving); @@ -87,13 +77,15 @@ export function WinterCheckDialog({ colonyId: target.colonyId, season: target.season, winterYear: target.winterYear, - date: new Date(date).toISOString(), - occupiedSeams: toNumber(occupiedSeams), - feedStockKg: isSpring ? undefined : toNumber(feedStockKg), - queenPresent: queen === "unknown" ? undefined : queen === "yes", - outcome: outcome ?? undefined, - lossCause: isLost && lossCause ? lossCause : undefined, - notes: notes.trim() || undefined, + date: toApiDate(date), + // Empty means null, not omitted: on an edit it clears an earlier entry. + occupiedSeams: emptyToNull(occupiedSeams, parseDecimal), + // Spring checks have no feed stock field - leave whatever is stored. + feedStockKg: isSpring ? undefined : emptyToNull(feedStockKg, parseDecimal), + queenPresent, + outcome, + lossCause: isLost && lossCause ? lossCause : null, + notes: emptyToNull(notes), markColonyDeceased: isLost && target.isLiving ? markDeceased : undefined, }; return existing @@ -155,23 +147,13 @@ export function WinterCheckDialog({
- + onChange={setLossCause} + />

Kategorien wie im COLOSS-Monitoring. Vermutete Gründe (z. B. Varroa) gern in die Notiz.

@@ -234,21 +216,11 @@ export function WinterCheckDialog({ ) : null} {!isLost ? ( -
- - -
+ ) : null}
diff --git a/ApiCore-Frontend/src/app/globals.css b/ApiCore-Frontend/src/app/globals.css index 464c28c..5f06a73 100644 --- a/ApiCore-Frontend/src/app/globals.css +++ b/ApiCore-Frontend/src/app/globals.css @@ -181,6 +181,24 @@ --shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%); } +/* --------------------------------------------------------------------------- + App chrome (see app/(app)/layout.tsx) + The page scrolls on the window. On phones a fixed bottom nav sits over its + lower edge, on iOS above the home indicator (needs viewport-fit=cover). + Content padding, the sticky save bar, the quick-add button and the focus + scroll-padding below all derive from these values, so they stay in step. + ------------------------------------------------------------------------ */ +:root { + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); + --bottom-nav-height: calc(4rem + var(--safe-area-bottom)); + /* Sticky header (3.5rem) plus breathing room. */ + --header-clearance: 4.5rem; + /* StickySubmit with its 48px button (~4.6rem) plus breathing room. */ + --sticky-submit-clearance: 5.5rem; +} + @layer base { * { @apply border-border outline-ring/50; @@ -190,6 +208,14 @@ } html { @apply font-sans; + /* WCAG 2.4.11: a field scrolled into view by focus must not end up under + the sticky header, the bottom nav / quick-add button or the save bar. */ + scroll-padding-top: var(--header-clearance); + scroll-padding-bottom: calc(var(--bottom-nav-height) + var(--sticky-submit-clearance)); + + @variant sm { + scroll-padding-bottom: calc(var(--sticky-submit-clearance) + var(--safe-area-bottom)); + } } h1, h2, diff --git a/ApiCore-Frontend/src/app/layout.tsx b/ApiCore-Frontend/src/app/layout.tsx index 05cf5ae..4d481f8 100644 --- a/ApiCore-Frontend/src/app/layout.tsx +++ b/ApiCore-Frontend/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Space_Grotesk, Inter, IBM_Plex_Mono } from "next/font/google"; import "./globals.css"; import { Providers } from "./providers"; @@ -27,6 +27,14 @@ export const metadata: Metadata = { description: "Digitale Imkereiverwaltung für Wabenhain", }; +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + // Without "cover" iOS reports env(safe-area-inset-*) as 0, and the bottom + // nav and save bar would sit under the home indicator. + viewportFit: "cover", +}; + export default function RootLayout({ children }: LayoutProps<"/">) { return ( ({ fetchColonies: vi.fn() })); + +function colony(id: string, code: string, status: ColonyStatus) { + return { id, code, status, apiaryId: "apiary-1" }; +} + +const COLONIES = [ + colony("c-active", "V-01", "ACTIVE"), + colony("c-weak", "V-02", "WEAK"), + colony("c-queenless", "V-03", "QUEENLESS"), + colony("c-conspicuous", "V-04", "CONSPICUOUS"), + colony("c-dissolved", "V-05", "DISSOLVED"), + colony("c-sold", "V-06", "SOLD"), +]; + +function withClient(ui: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +function LivingHarness({ initial = "" }: { initial?: string }) { + const [value, setValue] = useState(initial); + return ( + + ); +} + +function AllHarness({ initial }: { initial: string }) { + const [value, setValue] = useState(initial); + return ; +} + +async function openOptions() { + await userEvent.click(screen.getByRole("combobox")); + return screen.findAllByRole("option"); +} + +describe("LivingColonySelectField", () => { + beforeEach(() => { + vi.mocked(fetchColonies).mockResolvedValue(COLONIES as never); + }); + + it("offers weak, queenless and conspicuous colonies - not only active ones", async () => { + withClient(); + + const options = await openOptions(); + + expect(options.map((option) => option.textContent)).toEqual([ + "V-01", + "V-02 (Schwach)", + "V-03 (Weisellos)", + "V-04 (Auffällig)", + ]); + }); + + it("shows the chosen colony by its code, not by its internal id", async () => { + withClient(); + + await openOptions(); + await userEvent.click(screen.getByRole("option", { name: "V-03 (Weisellos)" })); + + expect(screen.getByRole("combobox")).toHaveTextContent("V-03 (Weisellos)"); + expect(screen.getByRole("combobox")).not.toHaveTextContent("c-queenless"); + }); + + it("still names an ended colony handed in from a link, without offering it", async () => { + withClient(); + + await waitFor(() => + expect(screen.getByRole("combobox")).toHaveTextContent("V-05 (Aufgelöst)"), + ); + const options = await openOptions(); + expect(options.map((option) => option.textContent)).not.toContain("V-05 (Aufgelöst)"); + }); + + it("says a colony is unavailable once loaded instead of loading forever", async () => { + withClient(); + + await waitFor(() => + expect(screen.getByRole("combobox")).toHaveTextContent("Nicht mehr verfügbar"), + ); + expect(screen.getByRole("combobox")).not.toHaveTextContent("c-unknown"); + }); + + it("says so when the colonies could not be loaded instead of loading forever", async () => { + vi.mocked(fetchColonies).mockRejectedValue(new Error("offline")); + withClient(); + + expect(await screen.findByText(/Völker konnten nicht geladen werden/)).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toHaveTextContent("Volk wählen"); + }); + + it("explains what to do when there is no living colony yet", async () => { + vi.mocked(fetchColonies).mockResolvedValue([colony("c-sold", "V-06", "SOLD")] as never); + withClient(); + + expect( + await screen.findByText(/Noch keine aktiven Völker/), + ).toBeInTheDocument(); + }); +}); + +describe("ColonySelectField", () => { + beforeEach(() => { + vi.mocked(fetchColonies).mockResolvedValue(COLONIES as never); + }); + + it("is a regular form field: labelled, marked required and able to show an error", async () => { + withClient( + undefined} + error="Bitte ein Volk auswählen." + />, + ); + + const trigger = screen.getByLabelText(/^Volk/); + expect(trigger).toHaveAttribute("id", "edit-colony"); + expect(trigger).toHaveAttribute("aria-invalid", "true"); + expect(trigger).toHaveAccessibleDescription("Bitte ein Volk auswählen."); + expect(screen.getByText("Pflicht")).toBeInTheDocument(); + expect(trigger).not.toHaveAttribute("data-field"); + }); + + it("keeps ended colonies selectable and names their real status", async () => { + withClient(); + + await waitFor(() => + expect(screen.getByRole("combobox")).toHaveTextContent("V-06 (Verkauft)"), + ); + const options = await openOptions(); + + expect(options.map((option) => option.textContent)).toEqual([ + "V-01", + "V-02 (Schwach)", + "V-03 (Weisellos)", + "V-04 (Auffällig)", + "V-05 (Aufgelöst)", + "V-06 (Verkauft)", + ]); + }); +}); diff --git a/ApiCore-Frontend/src/components/colony-select-field.tsx b/ApiCore-Frontend/src/components/colony-select-field.tsx index 20fc28d..d407d89 100644 --- a/ApiCore-Frontend/src/components/colony-select-field.tsx +++ b/ApiCore-Frontend/src/components/colony-select-field.tsx @@ -2,20 +2,117 @@ import { useQuery } from "@tanstack/react-query"; import { fetchColonies } from "@/lib/colonies/api"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { colonyOptionLabel } from "@/lib/colonies/labels"; +import { isLivingColony } from "@/lib/colonies/status"; +import type { Colony } from "@/lib/colonies/types"; +import { Field } from "@/components/form/field"; +import { RecordSelect } from "@/components/form/record-select"; + +const PLACEHOLDER = "Volk wählen"; +const LOADING_PLACEHOLDER = "Völker werden geladen…"; +const LOAD_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden."; + +function useColonies() { + return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); +} + +interface ColonyChoicesProps { + id: string; + value: string; + onChange: (colonyId: string) => void; + /** The colonies on offer; `undefined` while loading. */ + options: readonly Colony[] | undefined; + /** Every known colony, so a chosen one is named even if it is not offered. */ + colonies: readonly Colony[] | undefined; + isLoading: boolean; + dataField?: string; + describedBy?: string; + invalid?: boolean; +} + +/** The colony select: a RecordSelect named by code and status ("V-03 (Weisellos)"). */ +function ColonyChoices({ colonies, options, isLoading, ...props }: ColonyChoicesProps) { + return ( + + ); +} + +interface LivingColonySelectFieldProps { + /** Id of the select trigger, used to focus it on a validation error. */ + triggerId: string; + value: string; + onChange: (colonyId: string) => void; + error?: string | null; + label?: string; + /** A colony that must not be offered, e.g. the one another is merged into. */ + excludeId?: string; + /** Shown when there is nothing to choose from. */ + emptyHint?: string; + /** `data-field` of the trigger; the capture forms focus "colonyId" on errors. */ + dataField?: string; +} + +/** + * Picks a colony that still lives in the apiary: the one a new record + * (inspection, varroa count, feeding, treatment, swarm control check) is filed + * under, the colony a queen moves into, the colony merged into another. + * Weak, conspicuous and queenless colonies are included - they need the most + * attention - and none that has ended. A colony handed in by a link that has + * ended since is still named, just not offered. + */ +export function LivingColonySelectField({ + triggerId, + value, + onChange, + error, + label = "Volk", + excludeId, + emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.", + dataField = "colonyId", +}: LivingColonySelectFieldProps) { + const { data: colonies, isLoading, isError } = useColonies(); + const living = colonies?.filter( + (colony) => isLivingColony(colony) && colony.id !== excludeId, + ); + + return ( + + {({ id, describedBy, invalid }) => ( + + )} + + ); +} interface ColonySelectFieldProps { id: string; value: string; onChange: (colonyId: string) => void; label?: string; + error?: string | null; + dataField?: string; } /** @@ -25,37 +122,41 @@ interface ColonySelectFieldProps { * against the wrong colony is a data-entry error rather than a fact of * beekeeping - and the only alternative repair would be deleting the record. * - * Dissolved colonies stay in the list: a record already filed against one has + * Ended colonies stay in the list: a record already filed against one has * to remain visible and selectable, otherwise opening the form would silently * re-file it somewhere else. */ export function ColonySelectField({ - id, + id: triggerId, value, onChange, label = "Volk", + error, + dataField, }: ColonySelectFieldProps) { - const { data: colonies } = useQuery({ - queryKey: ["colonies"], - queryFn: () => fetchColonies(), - }); + const { data: colonies, isLoading, isError } = useColonies(); return ( -
- - -
+ + {({ id, describedBy, invalid }) => ( + + )} + ); } diff --git a/ApiCore-Frontend/src/components/form/choice-group.spec.tsx b/ApiCore-Frontend/src/components/form/choice-group.spec.tsx new file mode 100644 index 0000000..9600162 --- /dev/null +++ b/ApiCore-Frontend/src/components/form/choice-group.spec.tsx @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from "vitest"; +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ChoiceGroup, choiceOptions, type ChoiceOption } from "./choice-group"; + +type Feed = "ausreichend" | "niedrig" | "kritisch"; + +const FEED_OPTIONS: ChoiceOption[] = [ + { value: "ausreichend", label: "Ausreichend" }, + { value: "niedrig", label: "Niedrig" }, + { value: "kritisch", label: "Kritisch" }, + { value: null, label: "Nicht geprüft" }, +]; + +function Harness({ + initial = null, + clearable = false, + onChange, +}: { + initial?: Feed | null; + clearable?: boolean; + onChange?: (value: Feed | null) => void; +}) { + const [value, setValue] = useState(initial); + return ( + { + setValue(next); + onChange?.(next); + }} + /> + ); +} + +describe("ChoiceGroup", () => { + it("is one named radio group with one large radio per option", () => { + render(); + + expect(screen.getByRole("radiogroup", { name: "Futter" })).toBeInTheDocument(); + const radios = screen.getAllByRole("radio"); + expect(radios.map((radio) => radio.textContent)).toEqual([ + "Ausreichend", + "Niedrig", + "Kritisch", + "Nicht geprüft", + ]); + for (const radio of radios) { + expect(radio.className).toContain("min-h-11"); + } + }); + + it("marks the current value - null included - as checked", () => { + render(); + + expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveAttribute( + "aria-checked", + "true", + ); + expect(screen.getByRole("radio", { name: "Niedrig" })).toHaveAttribute( + "aria-checked", + "false", + ); + }); + + it("selects with a single tap", async () => { + const onChange = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("radio", { name: "Kritisch" })); + + expect(onChange).toHaveBeenLastCalledWith("kritisch"); + expect(screen.getByRole("radio", { name: "Kritisch" })).toHaveAttribute( + "aria-checked", + "true", + ); + }); + + it("keeps a tap on the chosen option as is unless the group is clearable", async () => { + const onChange = vi.fn(); + const { unmount } = render(); + await userEvent.click(screen.getByRole("radio", { name: "Niedrig" })); + expect(onChange).toHaveBeenLastCalledWith("niedrig"); + unmount(); + + render(); + await userEvent.click(screen.getByRole("radio", { name: "Niedrig" })); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it("is reached with one tab stop and moved through with the arrow keys", async () => { + const onChange = vi.fn(); + render(); + + await userEvent.tab(); + expect(screen.getByRole("radio", { name: "Kritisch" })).toHaveFocus(); + expect( + screen.getAllByRole("radio").filter((radio) => radio.tabIndex === 0), + ).toHaveLength(1); + + await userEvent.keyboard("{ArrowRight}"); + expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveFocus(); + expect(onChange).toHaveBeenLastCalledWith(null); + + await userEvent.keyboard("{ArrowRight}"); + expect(screen.getByRole("radio", { name: "Ausreichend" })).toHaveFocus(); + expect(onChange).toHaveBeenLastCalledWith("ausreichend"); + + await userEvent.keyboard("{ArrowLeft}"); + expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveFocus(); + + await userEvent.keyboard("{Home}"); + expect(screen.getByRole("radio", { name: "Ausreichend" })).toHaveFocus(); + + await userEvent.keyboard("{End}"); + expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveFocus(); + }); + + it("keeps the visible label inside the spoken name (WCAG 2.5.3)", () => { + render( + undefined} + />, + ); + + const radio = screen.getByRole("radio", { name: "4 von 5" }); + expect(radio).not.toHaveAttribute("aria-label"); + expect(radio).toHaveAccessibleName(expect.stringContaining("4")); + }); + + it("tells apart options whose values only look alike", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const onChange = vi.fn(); + render( + + label="Gemischt" + options={[ + { value: "null", label: "Text null" }, + { value: null, label: "Nichts" }, + { value: "true", label: "Text true" }, + { value: true, label: "Ja" }, + ]} + value={null} + onChange={onChange} + />, + ); + + expect(screen.getAllByRole("radio")).toHaveLength(4); + await userEvent.click(screen.getByRole("radio", { name: "Text null" })); + expect(onChange).toHaveBeenLastCalledWith("null"); + await userEvent.click(screen.getByRole("radio", { name: "Ja" })); + expect(onChange).toHaveBeenLastCalledWith(true); + expect( + consoleError.mock.calls.some((call) => String(call[0]).includes("same key")), + ).toBe(false); + consoleError.mockRestore(); + }); + + it("makes the first option the tab stop while nothing is chosen", () => { + render( + undefined} + />, + ); + + expect(screen.getByRole("radio", { name: "A" })).toHaveAttribute("tabindex", "0"); + expect(screen.getByRole("radio", { name: "B" })).toHaveAttribute("tabindex", "-1"); + }); +}); + +describe("choiceOptions", () => { + const MOOD_LABELS = { NONE: "Keine", LOW: "Leicht", HIGH: "Stark" } as const; + + it("offers every value of a label map in its order - no cast needed", () => { + const options = choiceOptions(MOOD_LABELS); + + expect(options).toEqual([ + { value: "NONE", label: "Keine" }, + { value: "LOW", label: "Leicht" }, + { value: "HIGH", label: "Stark" }, + ]); + }); + + it("offers only the given values, in the given order", () => { + expect(choiceOptions(MOOD_LABELS, ["HIGH", "NONE"])).toEqual([ + { value: "HIGH", label: "Stark" }, + { value: "NONE", label: "Keine" }, + ]); + }); +}); diff --git a/ApiCore-Frontend/src/components/form/choice-group.tsx b/ApiCore-Frontend/src/components/form/choice-group.tsx new file mode 100644 index 0000000..9ebf1d6 --- /dev/null +++ b/ApiCore-Frontend/src/components/form/choice-group.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { KeyboardEvent, useRef } from "react"; +import { keysOf } from "@/lib/keys-of"; +import { cn } from "@/lib/utils"; + +export type ChoiceValue = string | number | boolean | null; + +export interface ChoiceOption { + value: T; + label: string; + /** + * Words only screen readers hear after the label, e.g. "von 5" for "4". + * Appended instead of an aria-label, so the spoken name always contains + * the visible label (voice control users say what they see). + */ + srSuffix?: string; +} + +/** Distinct per value and type: "null" (text) and null must not collide. */ +function optionKey(value: ChoiceValue): string { + return `${value === null ? "null" : typeof value}:${String(value)}`; +} + +/** + * Options from a label map (an enum's labels), named like EnumSelect: all of + * its values in map order, or only `values` in that order. Typed from the + * map, so callers need no cast. + */ +export function choiceOptions( + labels: Readonly>, + values: readonly NoInfer[] = keysOf(labels), +): ChoiceOption[] { + return values.map((value) => ({ value, label: labels[value] })); +} + +interface ChoiceGroupProps { + options: readonly ChoiceOption[]; + value: T; + onChange: (value: T) => void; + id?: string; + /** Id of a visible label; use `label` when there is none. */ + labelledBy?: string; + label?: string; + describedBy?: string; + /** If set, tapping the chosen option again sets this value ("none"). */ + clearValue?: T; + className?: string; + /** Extra classes for every option button, e.g. a larger font. */ + optionClassName?: string; +} + +const KEY_STEPS: Record = { + ArrowRight: 1, + ArrowDown: 1, + ArrowLeft: -1, + ArrowUp: -1, +}; + +function targetIndex(key: string, current: number, count: number): number | null { + if (key === "Home") return 0; + if (key === "End") return count - 1; + const step = KEY_STEPS[key]; + if (step === undefined) return null; + return (current + step + count) % count; +} + +/** + * One large button per option instead of a dropdown: a single tap with + * gloves, and every choice stays visible. A `null` option stands for "not + * answered" (e.g. "Nicht geprüft") and is shown muted when chosen, so the + * observations that were really made stand out. + * + * Accessible as a radio group: one tab stop, arrow keys move and select. + */ +export function ChoiceGroup({ + options, + value, + onChange, + id, + labelledBy, + label, + describedBy, + clearValue, + className, + optionClassName, +}: ChoiceGroupProps) { + const radioRefs = useRef<(HTMLButtonElement | null)[]>([]); + const checkedIndex = options.findIndex((option) => option.value === value); + const tabStop = checkedIndex === -1 ? 0 : checkedIndex; + + function handleKeyDown(event: KeyboardEvent, index: number) { + const next = targetIndex(event.key, index, options.length); + if (next === null) return; + event.preventDefault(); + radioRefs.current[next]?.focus(); + onChange(options[next].value); + } + + return ( +
+ {options.map((option, index) => { + const selected = index === checkedIndex; + const isUnanswered = option.value === null; + return ( + + ); + })} +
+ ); +} diff --git a/ApiCore-Frontend/src/components/form/enum-select.spec.tsx b/ApiCore-Frontend/src/components/form/enum-select.spec.tsx new file mode 100644 index 0000000..6347d93 --- /dev/null +++ b/ApiCore-Frontend/src/components/form/enum-select.spec.tsx @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { EnumSelect } from "./enum-select"; + +type Mood = "NONE" | "LOW" | "HIGH"; + +const MOOD_LABELS: Record = { + NONE: "Keine", + LOW: "Leicht", + HIGH: "Stark", +}; + +function Required({ onChange }: { onChange?: (value: Mood) => void }) { + const [value, setValue] = useState("NONE"); + return ( + <> + + { + setValue(next); + onChange?.(next); + }} + labels={MOOD_LABELS} + /> + + ); +} + +function Filter({ onChange }: { onChange?: (value: Mood | "") => void }) { + const [value, setValue] = useState("HIGH"); + return ( + { + setValue(next); + onChange?.(next); + }} + values={["HIGH", "LOW"]} + labels={MOOD_LABELS} + noneLabel="Alle" + /> + ); +} + +async function optionTexts() { + await userEvent.click(screen.getByRole("combobox")); + return (await screen.findAllByRole("option")).map((option) => option.textContent); +} + +describe("EnumSelect", () => { + it("shows the label of the value, offers every value and reports the typed choice", async () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByRole("combobox", { name: "Schwarmstimmung" })).toHaveTextContent("Keine"); + expect(await optionTexts()).toEqual(["Keine", "Leicht", "Stark"]); + await userEvent.click(screen.getByRole("option", { name: "Stark" })); + + expect(onChange).toHaveBeenCalledWith("HIGH"); + expect(screen.getByRole("combobox")).toHaveTextContent("Stark"); + expect(screen.getByRole("combobox")).not.toHaveTextContent("HIGH"); + }); + + it("passes the field's description and error state to the trigger", () => { + render(); + + const trigger = screen.getByRole("combobox"); + expect(trigger).toHaveAttribute("aria-describedby", "mood-hint"); + expect(trigger).toHaveAttribute("aria-invalid", "true"); + }); + + it("offers only the given values, in their order, after the 'none' choice", async () => { + render(); + + expect(await optionTexts()).toEqual(["Alle", "Stark", "Leicht"]); + }); + + it("reports the 'none' choice as an empty value and names it", async () => { + const onChange = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("combobox", { name: "Nach Stimmung filtern" })); + await userEvent.click(await screen.findByRole("option", { name: "Alle" })); + + expect(onChange).toHaveBeenCalledWith(""); + expect(screen.getByRole("combobox")).toHaveTextContent("Alle"); + }); +}); + +describe("EnumSelect with decorated options", () => { + function Decorated({ onChange }: { onChange?: (value: Mood) => void }) { + const [value, setValue] = useState("LOW"); + return ( + <> + + { + setValue(next); + onChange?.(next); + }} + labels={MOOD_LABELS} + renderOption={(mood) => ( + + ● {MOOD_LABELS[mood]} + + )} + /> + + ); + } + + it("shows the decoration in the trigger and in every option row", async () => { + render(); + + const trigger = screen.getByRole("combobox", { name: "Schwarmstimmung" }); + expect(trigger.querySelector('[data-testid="badge-LOW"]')).not.toBeNull(); + await userEvent.click(trigger); + for (const mood of ["NONE", "LOW", "HIGH"]) { + expect( + (await screen.findAllByTestId(`badge-${mood}`)).length, + ).toBeGreaterThanOrEqual(1); + } + }); + + it("keeps the plain label as the spoken name, not the decoration", async () => { + const onChange = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("combobox", { name: "Schwarmstimmung" })); + const option = await screen.findByRole("option", { name: "Stark" }); + await userEvent.click(option); + + expect(onChange).toHaveBeenCalledWith("HIGH"); + for (const badge of screen.getAllByTestId("badge-HIGH")) { + expect(badge.closest("[aria-hidden='true']")).not.toBeNull(); + } + }); + + it("renders exactly as before without a decoration", async () => { + render(); + + await userEvent.click(screen.getByRole("combobox", { name: "Schwarmstimmung" })); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual(["Keine", "Leicht", "Stark"]); + }); +}); diff --git a/ApiCore-Frontend/src/components/form/enum-select.tsx b/ApiCore-Frontend/src/components/form/enum-select.tsx new file mode 100644 index 0000000..e1a3b07 --- /dev/null +++ b/ApiCore-Frontend/src/components/form/enum-select.tsx @@ -0,0 +1,116 @@ +"use client"; + +import type { ReactNode } from "react"; +import { keysOf } from "@/lib/keys-of"; +import { cn } from "@/lib/utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +/** Base UI needs a real item value for the "none" choice; outside it is "". */ +const NONE_VALUE = "__none__"; + +interface EnumSelectBaseProps { + /** Id of the trigger - pass the one from `Field` so its label points here. */ + id?: string; + /** Accessible name when there is no visible label pointing at `id`. */ + ariaLabel?: string; + /** How each value is named, in the list and in the trigger. */ + labels: Record; + /** The values on offer, in this order; all keys of `labels` by default. */ + values?: readonly NoInfer[]; + /** + * Optional visual form of a value (e.g. a coloured badge), shown in the + * option rows and the trigger. Decoration only: screen readers still get + * the plain label from `labels`. + */ + renderOption?: (value: NoInfer) => ReactNode; + describedBy?: string; + invalid?: boolean; + disabled?: boolean; + className?: string; +} + +// T is inferred from `labels` only (NoInfer), so a "" value does not widen it. +type EnumSelectProps = EnumSelectBaseProps & + ( + | { noneLabel?: undefined; value: NoInfer; onChange: (value: NoInfer) => void } + | { + /** + * Offers an explicit first choice meaning "none" or "all" (e.g. "Alle + * Status"). It stands for the empty value "". + */ + noneLabel: string; + value: NoInfer | ""; + onChange: (value: NoInfer | "") => void; + } + ); + +/** The label as text - or, with a decoration, the decoration for the eye and the label for screen readers. */ +function OptionLabel({ label, decoration }: { label: string; decoration?: ReactNode }) { + if (decoration === undefined) return label; + return ( + <> + + {label} + + ); +} + +/** + * A select over a fixed set of values (status, category, type ...), named by + * a label map. The change handler gets the typed value back, so callers need + * no `as` cast, and the trigger always shows the label - never the raw value. + * Counterpart of RecordSelect, which picks records by id. + */ +export function EnumSelect(props: EnumSelectProps) { + const { id, ariaLabel, labels, renderOption, describedBy, invalid, disabled, className } = props; + const values = props.values ?? keysOf(labels); + + function handleValueChange(next: unknown) { + if (props.noneLabel !== undefined && next === NONE_VALUE) { + props.onChange(""); + return; + } + const chosen = values.find((value) => value === next); + if (chosen !== undefined) props.onChange(chosen); + } + + const selectValue = props.value === "" ? NONE_VALUE : props.value; + const triggerLabel = + props.value === "" ? ( + props.noneLabel + ) : ( + + ); + + return ( + + ); +} diff --git a/ApiCore-Frontend/src/components/form/field.spec.tsx b/ApiCore-Frontend/src/components/form/field.spec.tsx new file mode 100644 index 0000000..68ed410 --- /dev/null +++ b/ApiCore-Frontend/src/components/form/field.spec.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Field } from "./field"; + +describe("Field", () => { + it("labels a single control with label-for", () => { + render({({ id }) => }); + + expect(screen.getByLabelText(/Datum/)).toHaveAttribute("type", "date"); + }); + + it("uses a given id for the control, e.g. a select trigger focused on errors", () => { + render( + + {({ id }) => +
+ )} + , + ); + + expect(container.querySelector("label")).toBeNull(); + const group = screen.getByRole("radiogroup", { name: /^Futterart/ }); + expect(group).toHaveAccessibleDescription("Eine Art antippen."); + }); +}); diff --git a/ApiCore-Frontend/src/components/form/field.tsx b/ApiCore-Frontend/src/components/form/field.tsx index a2d174e..b5fff32 100644 --- a/ApiCore-Frontend/src/components/form/field.tsx +++ b/ApiCore-Frontend/src/components/form/field.tsx @@ -8,6 +8,14 @@ interface FieldProps { hint?: string; error?: string | null; className?: string; + /** Id for the control instead of a generated one. */ + id?: string; + /** + * The child is a group of buttons (radio group, chips), not one form + * control. A