Compare commits

...
Author SHA1 Message Date
Nick Szaunig 9c332d4716 Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export
# Conflicts:
#	ARCHITECTURE.md
2026-10-13 05:58:30 +02:00
Nick SzaunigandClaude Opus 5.5 10cbe9bf2d fix: Paket 0 – data integrity, security hardening and field usability
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s
Backend
- Backup covers every Prisma model (bundle v3); a guard test fails for any
  new model that is neither backed up nor explicitly excluded. Unsealed
  bundles may only add rows to 27 legacy master/field tables and never
  overwrite; counters, ledgers, settings, roles etc. need a sealed file.
  Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB)
  with warnings in log and UI; replaced document files are removed after
  commit.
- Post-commit side effects (automation, calendar sync, webhooks) run via
  runAfterCommit and never turn a committed write into a 500, so retries
  with the same Idempotency-Key no longer create duplicates.
- Calendar outbox is multi-replica safe: per-entry claims with stale
  takeover, revision-based settle, new Graph event only on 404/410,
  transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID
  validation for groupId, "run now" in background and resets FAILED.
- Automation tasks are deduplicated under a row lock on the rule.
- Update DTOs follow one null rule: omitted = unchanged, null clears a
  nullable field, null on a required field is a 400.
- Varroa mites/day is recomputed on correction; withdrawal status is
  checked against the harvest date (?at=), bounded colonyIds; inspection
  create is audited; updates re-enqueue calendar entries.
- Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS
  and QUEENLESS colonies as living.
- Prisma errors and body-parser errors are logged without query values or
  request bodies.
- Migration 20261012100000_calendar_sync_claims; schema index name aligned
  with the existing database object.

Frontend
- Sticky save bar sticks again (no scroll container ancestors, card uses
  overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms,
  sticky desktop sidebar.
- Inspections record yes / no / not checked instead of silent defaults.
- All living colonies are selectable; shared RecordSelect, EnumSelect,
  ChoiceGroup and TriStateField replace per-page copies; selects show
  names instead of ids; labels point at real controls.
- Emptied optional fields are sent as null (emptyToNull) so every field
  can be cleared; dates default to the local day and are sent via
  toApiDate.
- Harvest withdrawal warning uses the harvest date; backup page warns when
  a backup outgrows the import limit and lists skipped tables.

Build and docs
- Docker builds fail unless lint, typecheck and unit tests pass.
- ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec
  docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md
  for the backbone (api/worker/job roles, domain event outbox) and the
  feature roadmap T3-T7.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 16:38:55 +02:00
Nick SzaunigandClaude Opus 5.5 6505411761 fix(ui): API-Schlüssel nur mit Rechten anbieten, die der Nutzer selbst hat
Der Server lehnt Rechte ab, die der Anlegende nicht selbst hat. Das UI
bietet jetzt auch nur diese an (z. B. kein shop-sync:write ohne eigenes
Recht) und erklärt, wenn gar keines übrig bleibt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 12:00:03 +02:00
Nick SzaunigandClaude Opus 5.5 fe7b61c3c1 fix(api-keys): gültige Schlüssel nie durch die Fehlversuchs-Sperre abweisen
Befunde aus dem Sicherheitsreview des Branches:

- MEDIUM: Die Sperre lief vor der Schlüsselsuche und zählte auch falsch
  aufgebaute Tokens. Wer die gemeinsame Adresse (Cluster-Ausgang, Proxy)
  nutzt oder per X-Forwarded-For vortäuscht, hätte ERPNext und den Shop
  15 Minuten aussperren können. Jetzt: erst Suche über den Hash; ein
  gültiger, nicht widerrufener, nicht abgelaufener Schlüssel kommt immer
  durch; 429 nur für unbekannte, widerrufene und abgelaufene Schlüssel;
  falsch aufgebaute Tokens sofort 401 ohne Zählung. Beim Start warnt
  ApiCore, wenn TRUST_PROXY in Produktion fehlt (im Cluster gesetzt).
- LOW: Bei vollem Speicher verdrängt der Limiter zuerst nicht gesperrte
  Einträge, gesperrte erst zuletzt.
- LOW: Bewegungs-Cursor mit Jahr außerhalb 2000-2100 ergab 500, jetzt 400.
- LOW/INFO: Vertrag legt fest, dass ERPNext Produkte und Chargen bei jedem
  Lauf vollständig neu liest (Voll-Spiegel) und Bewegungen inkrementell;
  remainingQuantity ist live, Bewegungen kommen 5 Minuten verzögert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 12:00:03 +02:00
Nick SzaunigandClaude Opus 5.5 e28cc8e0f3 fix(api-keys): Fehlversuche je Client-Netz zählen (IPv6 /64)
Wie der Throttler seit 886b4e7: die Sperre nach abgelehnten Schlüsseln
zählt über rateLimitSubject, damit wechselnde Adressen im eigenen
IPv6-/64 das Budget nicht zurücksetzen. Im Log steht weiter die Adresse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:47:05 +02:00
Nick Szaunig e357e8be9a Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export 2026-10-12 11:44:31 +02:00
Nick SzaunigandClaude Opus 5.5 c4cdb63414 feat(ui): Berechtigungen beim Anlegen eines API-Schlüssels wählbar
Bisher vergab die Oberfläche fest shop-sync:read. Jetzt sind alle
freigegebenen Rechte ankreuzbar (Shop lesen, Shop-Buchungen,
Inventar-Spiegel für ERPNext); vorausgewählt bleibt wie bisher nur
Shop lesen, ohne Haken lässt sich nichts anlegen. Die Liste zeigt, was
ein Schlüssel darf, und der Hinweis nach dem Anlegen nennt das Zielsystem.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:43:57 +02:00
Nick SzaunigandClaude Opus 5.5 68f81fabd4 feat(erp-export): nur lesender Inventar-Spiegel für ERPNext (T8)
Datenquelle für den nächtlichen Inventar-Abgleich in ERPNext (ERPNext-
Ausbau T8, Entscheidungen E2/E3). Neues Recht erp-inventory:read, auch als
API-Schlüssel-Scope; es öffnet nur diese vier GET-Endpunkte:

- /api/v1/export/inventar/produkte: Artikel (ohne Beschreibung)
- .../chargen: Abfüllungen und Herstellungschargen mit Losnummer, MHD,
  Ausgangsmenge, Rest aus dem Bewegungsbuch, Füllmenge g und Sorte
- .../bewegungen?seit=&nach=&limit=: Shop-Verkauf, Retoure, Entnahmen mit
  Grund (HOFLADEN = Vertrauensschrank, EIGENBEDARF, BRUCH, PROBE,
  SONSTIGES) und Inventur; Cursor über (createdAt, id), Bewegungen erst
  nach 5 Minuten Wartezeit, damit der Cursor keine offene Buchung überholt
- .../ernten?jahr=: Nettogewicht kg je Kalenderjahr, wie die Statistik

Alle Listen stabil sortiert, seitenweise (1-500), Parameter mit
class-validator geprüft, Cursor je Endpunkt streng validiert. Feld-
Allowlists: keine Notizen, Benutzer, Standorte, Rohstoffe. Die Gründe gab
es im Datenmodell schon - keine Migration; das Recht legt der Katalog-
Sync beim Start an und gibt es der Rolle Administrator.

Vertrag mit Feldern, Einheiten, Fehlercodes und Beispielen:
docs/erp-inventar-export.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:43:57 +02:00
Nick SzaunigandClaude Opus 5.5 1a1e70c94b feat(api-keys): Fehlversuchs-Sperre je IP und Log ohne Schlüssel
Nach 20 abgelehnten API-Schlüsseln (fehlerhaft, unbekannt, widerrufen,
abgelaufen) in 15 Minuten antwortet ApiCore der Absender-IP mit 429, auch
für richtige Schlüssel - vor jeder Datenbankabfrage. Gleiches Budget wie
der ERP-Export des Shops. Die Zählung liegt wie der Throttler im Speicher
des Pods und ist auf 10.000 Adressen begrenzt.

Abgelehnte Schlüssel werden mit Grund, öffentlichem Präfix (ack_xxxxxxxx)
und IP protokolliert, nie mit dem Schlüssel. Die Suche bleibt über den
SHA-256-Hash; neu sind Unit-Tests für authenticate (fehlend, unbekannt,
widerrufen, abgelaufen, Scope-Filter, IP-Allowlist, Sperre).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:43:56 +02:00
Nick SzaunigandClaude Opus 5.5 f5b1964223 fix(k8s): Ingress-Vorlagen auf Traefik statt nginx
Frontend Image / build-and-push (push) Canceled after 0s
Backend Image / build-and-push (push) Successful in 3m48s
Die cloudflare-only-Annotation ist eine Traefik-Annotation und war mit
ingressClassName nginx wirkungslos. Produktion laeuft auf RKE2 mit Traefik
(Ingresses apicore-backend/-frontend), die Vorlagen nennen jetzt dieselbe
Klasse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:26:37 +02:00
Nick SzaunigandClaude Opus 5.5 e53e4fb1cf fix(k8s): Traefik-Middleware cloudflare-only an Ingress-Manifesten
Frontend Image / build-and-push (push) Canceled after 0s
Backend Image / build-and-push (push) Canceled after 0s
Die live gesetzte Annotation traefik.ingress.kubernetes.io/router.middlewares
fehlte in den Manifesten (Backend und Frontend). Sie schuetzt vor
Direktzugriff am Origin vorbei, nicht vor fremden Cloudflare-Zonen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:23:46 +02:00
Nick SzaunigandClaude Opus 5.5 d9670dcda5 fix: TRUST_PROXY-Kommentar aktualisieren und Modus beim Start loggen
Die Kommentare beschrieben noch einen einzelnen Reverse-Proxy-Hop. Sie
beschreiben jetzt die Cloudflare/Traefik-Liste. Beim Start wird einmal
auf info-Level geloggt, ob Hop-Zahl, Liste oder der Default aktiv ist
(nur Anzahl, keine Adressen), damit eine fehlende Produktionseinstellung
auffaellt. Das Standardverhalten bleibt unveraendert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:23:41 +02:00
Nick SzaunigandClaude Opus 5.5 886b4e75e1 fix: Rate-Limit pro IPv6-/64 statt pro Einzeladresse
Der Standard-ThrottlerGuard trackt die volle req.ip. Ein IPv6-Client
kann innerhalb seines /64 beliebig Adressen rotieren und so die
Login-/Refresh-/SSO-Limits umgehen. Neuer ClientIpThrottlerGuard
liefert IPv4 unveraendert, IPv4-mapped als IPv4 und IPv6 als /64-Praefix.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:18:56 +02:00
Nick SzaunigandClaude Opus 5.5 d7f0d2c13b fix(security): TRUST_PROXY ohne uniquelocal, nur Pod-Netz und Cloudflare
Backend Image / build-and-push (push) Successful in 1m19s
Frontend Image / build-and-push (push) Successful in 30s
LAN-Adressen als vertrauenswuerdige Hops waren unnoetig weit: Traefik
verwirft X-Forwarded-For von LAN-Clients ohnehin, sie sind selbst Absender.
Vertraut werden nur noch 10.42.0.0/16 (Traefik-Pods) und die Cloudflare-Netze.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:57:09 +02:00
Nick SzaunigandClaude Opus 5.5 570f4f94e4 fix(security): TRUST_PROXY hinter Cloudflare auf Netzliste statt Hop-Zahl
Frontend Image / build-and-push (push) Successful in 3m26s
Backend Image / build-and-push (push) Successful in 3m33s
Mit TRUST_PROXY=1 (Standard) war req.ip die Cloudflare-Edge, weil Traefik
sie als letzten X-Forwarded-For-Eintrag anhaengt. Rate-Limits trafen damit
alle Clients derselben Edge gemeinsam, und IP-Freigaben von API-Schluesseln
sahen nie die echte Adresse.

Vorlage und .env.example nennen jetzt "loopback, uniquelocal" plus die
Cloudflare-Netze: Express nimmt den ersten Eintrag von rechts ausserhalb
dieser Netze, also den Client, den Cloudflare angehaengt hat. Vom Client
mitgeschickte Eintraege links davon erreicht die Auswertung nie.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:55:06 +02:00
Nick SzaunigandClaude Opus 5.5 04f9363e4b fix(k8s): persist documents and app backups, pin images, run hardened
Backend Image / build-and-push (push) Successful in 3m0s
Frontend Image / build-and-push (push) Successful in 3m12s
Documents, avatars and the encrypted app backups lived in ./uploads and
./backups inside the container and were lost on every restart. The
backend now mounts the apicore-data volume at /data (configmap paths
/data/documents and /data/backups).

Both deployments pin the image to a CI build (sha-<commit>) with
imagePullPolicy IfNotPresent instead of :latest, run as uid 1000 with
seccomp RuntimeDefault, no privilege escalation and all capabilities
dropped. The frontend gets a liveness probe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:25:45 +02:00
Nick SzaunigandClaude Opus 5.5 2359970f48 feat(k8s): daily pg_dump backup of the database
Backend Image / build-and-push (push) Successful in 2m43s
Frontend Image / build-and-push (push) Successful in 2m53s
CronJob apicore-db-backup dumps the database to the Storage Box
(StorageClass smb-backup, SMB 3.1.1 with seal). Keeps 14 daily and
8 weekly dumps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 02:04:28 +02:00
Nick SzaunigandClaude Opus 5.5 cde817845e chore(k8s): drop CPU and memory requests
Backend Image / build-and-push (push) Successful in 3m9s
Frontend Image / build-and-push (push) Canceled after 2m1s
Requests are set to an explicit 0 instead of being removed, because
Kubernetes defaults a missing request to the limit, which would reserve
even more. Limits stay as an upper bound.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 04:08:07 +02:00
Nick SzaunigandClaude Opus 5.5 910ba804a6 chore(k8s): pin postgres to 18.6-alpine
Backend Image / build-and-push (push) Successful in 3m12s
Frontend Image / build-and-push (push) Successful in 3m7s
The floating 18-alpine tag with IfNotPresent kept an old 18.x image
cached on the nodes, so minor releases never reached the cluster.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 01:55:03 +02:00
347 changed files with 16018 additions and 3792 deletions

No files matched your search

+12 -9
View File
@@ -26,6 +26,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig.
@@ -97,19 +98,21 @@ 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.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
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).
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen.
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
@@ -125,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.
@@ -142,7 +145,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
| Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
+3
View File
@@ -21,6 +21,9 @@ FRONTEND_ORIGIN="http://localhost:3000"
# Reverse-proxy hops in front of the backend whose X-Forwarded-For is
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
# (see deploy/k8s/backend/configmap.example.yaml); "1" would yield the
# Cloudflare edge IP that many clients share.
TRUST_PROXY=1
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
+6
View File
@@ -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
@@ -0,0 +1,5 @@
-- Outbox claims: one worker at a time writes an entry to M365, and a
-- revision counter lets it notice an enqueue that arrived during the sync.
-- AlterTable
ALTER TABLE "CalendarSyncOutboxEntry" ADD COLUMN "claimedAt" TIMESTAMP(3),
ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
+11 -1
View File
@@ -925,7 +925,9 @@ model HoneyBatchContainerAllocation {
createdAt DateTime @default(now())
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
@@ -6,12 +6,15 @@ import { PermissionKey } from '../auth/permissions/permission-catalog';
* only ever gets what a machine integration needs. shop-sync:write lets the
* shop book its own sales and returns - nothing else - and every booking is
* idempotent and kept in the ledger, so a leaked key cannot erase history.
* Removing an entry here also takes it away from existing keys (see
* ApiKeysService.authenticate).
* erp-inventory:read lets ERPNext read the inventory mirror (articles,
* batches with remaining stock, stock movements, harvest totals) - read
* only, no people, no notes. Removing an entry here also takes it away from
* existing keys (see ApiKeysService.authenticate).
*/
export const API_KEY_SCOPES = [
'shop-sync:read',
'shop-sync:write',
'erp-inventory:read',
] as const satisfies readonly PermissionKey[];
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
@@ -0,0 +1,219 @@
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
API_KEY_FAILURE_LIMIT,
ApiKeysService,
extractApiKeyToken,
} from './api-keys.service';
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`;
function storedKey(overrides: Record<string, unknown> = {}) {
return {
id: 'key-1',
name: 'ERPNext',
prefix: 'ack_abcd1234',
keyHash: createHash('sha256').update(TOKEN).digest('hex'),
scopes: ['erp-inventory:read'],
createdByUserId: 'user-1',
createdAt: new Date('2026-10-01T00:00:00.000Z'),
lastUsedAt: null,
lastUsedIp: null,
allowedIps: [],
expiresAt: null,
revokedAt: null,
...overrides,
};
}
describe('ApiKeysService.authenticate', () => {
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
let service: ApiKeysService;
let warn: jest.SpyInstance;
beforeEach(() => {
prisma = {
apiKey: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({}),
},
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
);
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
});
function loggedText(): string {
return JSON.stringify(warn.mock.calls);
}
it('sucht den Schlüssel nur über seinen Hash', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] });
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') },
});
});
it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => {
await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe(
null,
);
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
});
it('lehnt einen unbekannten Schlüssel ab', async () => {
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
});
it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('revoked');
});
it('lehnt einen abgelaufenen Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('expired');
});
it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date() }),
);
await service.authenticate(TOKEN, '203.0.113.7');
await service.authenticate(OTHER_TOKEN, '203.0.113.7');
const text = loggedText();
expect(text).not.toContain(TOKEN);
expect(text).not.toContain(OTHER_TOKEN);
expect(text).not.toContain('A'.repeat(43));
expect(text).not.toContain('B'.repeat(43));
expect(text).toContain('ack_abcd1234');
});
it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ scopes: ['erp-inventory:read', 'users:create'] }),
);
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal?.scopes).toEqual(['erp-inventory:read']);
});
it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ allowedIps: ['198.51.100.0/24'] }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toBeInstanceOf(ForbiddenException);
});
async function failFrom(ip: string, times: number): Promise<void> {
for (let i = 0; i < times; i++) {
// Unbekannter, aber richtig aufgebauter Schlüssel.
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
}
}
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
await expect(blocked).rejects.toBeInstanceOf(HttpException);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
expect(loggedText()).toContain('203.0.113.7');
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
// Andere Adressen sind nicht betroffen.
await expect(
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
).resolves.toBe(null);
});
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
id: 'key-1',
scopes: ['erp-inventory:read'],
});
});
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
null,
);
}
// Weder gesperrt noch eine Datenbankabfrage.
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).resolves.toBe(null);
});
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await service.authenticate(
OTHER_TOKEN,
`2001:db8:1:2::${i.toString(16)}`,
);
}
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
).rejects.toMatchObject({ status: 429 });
// Ein anderes /64 ist frei.
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
).resolves.toBe(null);
});
});
describe('extractApiKeyToken', () => {
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull();
expect(extractApiKeyToken(undefined)).toBeNull();
});
});
@@ -1,5 +1,7 @@
import {
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
@@ -13,6 +15,8 @@ import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
return [
@@ -30,6 +34,26 @@ const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
// lastUsedAt is informational; writing it on every request would turn each
// read into a write for no benefit.
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
// "ack_" plus the 8-character prefix: identifies a key in logs without
// revealing its secret part.
const LOGGABLE_PREFIX_LENGTH = 12;
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys for the rest of the window - the same
* budget as the shop's ERP export. It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
maxTrackedSources: 10_000,
} as const;
type RejectReason = 'unknown' | 'revoked' | 'expired';
/** What a request authenticated by an API key may do - it is not a user. */
export interface ApiKeyPrincipal {
@@ -67,6 +91,7 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
constructor(
private readonly prisma: PrismaService,
@@ -176,6 +201,13 @@ export class ApiKeysService {
/**
* Resolves a presented key to what it may do, or null for anything that
* must not pass: malformed, unknown, revoked or expired.
*
* The key is found by the SHA-256 of the presented token through a unique
* index - never by comparing secrets byte by byte, so response timing says
* nothing about the stored key. Order matters: the lookup comes first, and
* only an invalid key counts against (or is stopped by) the failure limit
* of the client network. A malformed token is not a guess at a key and is
* refused without lookup and without counting.
*/
async authenticate(
token: string,
@@ -188,13 +220,52 @@ export class ApiKeysService {
where: { keyHash: hashToken(token) },
});
const now = new Date();
if (
!apiKey ||
apiKey.revokedAt ||
(apiKey.expiresAt && apiKey.expiresAt <= now)
) {
return null;
const reason = rejectReason(apiKey, now);
if (reason === null && apiKey) {
return this.accept(apiKey, clientIp, now);
}
this.recordRejection(token, reason ?? 'unknown', clientIp);
return null;
}
/** Counts an invalid key; 429 once the client network is over the limit. */
private recordRejection(
token: string,
reason: RejectReason,
clientIp: string | undefined,
): void {
// Counted per client network like the throttler (IPv6 /64), so rotating
// addresses inside one's own prefix does not reset the budget.
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
if (this.failures.isBlocked(subject)) {
// Same body shape as the built-in 4xx exceptions (JSON object, not a
// bare string), so clients can read `message` uniformly.
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
error: 'Too Many Requests',
message:
'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen',
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
// Only the public prefix is ever logged, never the key itself.
this.logger.warn(
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
);
if (this.failures.recordFailure(subject)) {
this.logger.warn(
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
);
}
}
private async accept(
apiKey: ApiKey,
clientIp: string | undefined,
now: Date,
): Promise<ApiKeyPrincipal> {
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
// The key itself is right: say so, so the admin finds the cause - a
// thief learns nothing new, they already hold the key.
@@ -221,6 +292,13 @@ export class ApiKeysService {
}
}
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
if (!apiKey) return 'unknown';
if (apiKey.revokedAt) return 'revoked';
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
return null;
}
/** The key from an `Authorization: Bearer ack_…` header, or null. */
export function extractApiKeyToken(
authorization: string | undefined,
@@ -0,0 +1,112 @@
import { FailedAttemptLimiter } from './failed-attempt-limiter';
describe('FailedAttemptLimiter', () => {
let now: number;
let limiter: FailedAttemptLimiter;
beforeEach(() => {
now = 1_000_000;
limiter = new FailedAttemptLimiter({
maxFailures: 3,
windowMs: 60_000,
maxTrackedSources: 100,
clock: () => now,
});
});
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
});
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
limiter.recordFailure('203.0.113.7');
limiter.recordFailure('203.0.113.7');
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
});
it('trennt Quellen voneinander', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
});
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
now += 60_001;
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
});
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
now += 60_001;
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
small.recordFailure('c');
expect(small.trackedSources()).toBe(1);
expect(small.isBlocked('c')).toBe(true);
});
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
const small = new FailedAttemptLimiter({
maxFailures: 5,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
expect(small.trackedSources()).toBe(2);
});
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 2,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('angreifer');
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
small.recordFailure('neu');
expect(small.trackedSources()).toBe(2);
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
expect(small.isBlocked('angreifer')).toBe(true);
// Verdrängt wurde der nicht gesperrte Eintrag.
expect(small.recordFailure('harmlos')).toBe(false);
});
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
expect(small.trackedSources()).toBe(2);
expect(small.isBlocked('a')).toBe(false);
expect(small.isBlocked('b')).toBe(true);
expect(small.isBlocked('c')).toBe(true);
});
});
@@ -0,0 +1,94 @@
export interface FailedAttemptLimiterOptions {
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
maxFailures: number;
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
windowMs: number;
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
maxTrackedSources: number;
clock?: () => number;
}
interface Window {
failures: number;
startedAt: number;
}
/**
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
*
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
* das ist für eine Bremse gegen Durchprobieren ausreichend.
*/
export class FailedAttemptLimiter {
private readonly windows = new Map<string, Window>();
private readonly clock: () => number;
constructor(private readonly options: FailedAttemptLimiterOptions) {
this.clock = options.clock ?? Date.now;
}
isBlocked(source: string): boolean {
const window = this.activeWindow(source);
return window !== undefined && window.failures >= this.options.maxFailures;
}
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
recordFailure(source: string): boolean {
const now = this.clock();
const current = this.activeWindow(source);
if (!current) {
this.makeRoom(now);
}
const failures = (current?.failures ?? 0) + 1;
this.windows.set(source, {
failures,
startedAt: current?.startedAt ?? now,
});
return failures === this.options.maxFailures;
}
trackedSources(): number {
return this.windows.size;
}
private activeWindow(source: string): Window | undefined {
const window = this.windows.get(source);
if (window && this.clock() - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
return undefined;
}
return window;
}
private makeRoom(now: number): void {
if (this.windows.size < this.options.maxTrackedSources) return;
for (const [source, window] of this.windows) {
if (now - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
}
}
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
// die Einfügereihenfolge).
if (this.windows.size >= this.options.maxTrackedSources) {
const victim =
this.oldestWhere(
(window) => window.failures < this.options.maxFailures,
) ?? this.oldestWhere(() => true);
if (victim !== undefined) this.windows.delete(victim);
}
}
private oldestWhere(
matches: (window: Window) => boolean,
): string | undefined {
for (const [source, window] of this.windows) {
if (matches(window)) return source;
}
return undefined;
}
}
@@ -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);
@@ -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(
@@ -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,
}) {}
+120
View File
@@ -0,0 +1,120 @@
import { Logger } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import {
configureApp,
describeTrustProxy,
trustProxySetting,
} from './app.config';
describe('trustProxySetting', () => {
const original = process.env.TRUST_PROXY;
afterEach(() => {
if (original === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = original;
});
it('defaults to one hop when unset or blank', () => {
delete process.env.TRUST_PROXY;
expect(trustProxySetting()).toBe(1);
process.env.TRUST_PROXY = ' ';
expect(trustProxySetting()).toBe(1);
});
it('parses a number as hop count and anything else as a list', () => {
process.env.TRUST_PROXY = '2';
expect(trustProxySetting()).toBe(2);
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxySetting()).toBe('loopback, 10.42.0.0/16');
});
});
describe('describeTrustProxy', () => {
it('reports the hop count mode and the default fallback', () => {
expect(describeTrustProxy(1, false)).toBe(
'TRUST_PROXY not set, using default of 1 trusted hop',
);
expect(describeTrustProxy(2, true)).toBe('TRUST_PROXY: 2 trusted hop(s)');
});
it('reports the list mode with entry count but not the entries', () => {
const text = describeTrustProxy(
'loopback, 10.42.0.0/16, 2400:cb00::/32',
true,
);
expect(text).toBe('TRUST_PROXY: list of 3 trusted proxy range(s)');
});
});
describe('configureApp trust proxy logging', () => {
it('logs the active mode once at startup', () => {
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
expect(
log.mock.calls.filter(([m]) => String(m).startsWith('TRUST_PROXY')),
).toHaveLength(1);
log.mockRestore();
});
});
describe('configureApp trust proxy warning', () => {
const originalProxy = process.env.TRUST_PROXY;
const originalEnv = process.env.NODE_ENV;
afterEach(() => {
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = originalProxy;
if (originalEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalEnv;
});
function configure(): jest.SpyInstance {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
return warn;
}
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
const calls = warn.mock.calls as unknown[][];
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
}
afterEach(() => jest.restoreAllMocks());
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
process.env.NODE_ENV = 'production';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(1);
});
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
process.env.NODE_ENV = 'production';
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
it('warnt nicht in der Entwicklung', () => {
process.env.NODE_ENV = 'development';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
});
+43 -8
View File
@@ -1,4 +1,9 @@
import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common';
import {
Logger,
RequestMethod,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
@@ -7,11 +12,15 @@ import { requestLoggerMiddleware } from './common/middleware/request-logger.midd
import { noStoreMiddleware } from './common/middleware/no-store.middleware';
/**
* How many reverse-proxy hops to trust for X-Forwarded-For. It decides the
* client IP behind every rate limit and API-key IP allowlist: too few and
* all users share the proxy's IP, too many and a client can forge its own.
* A hop count ("1", the single K8s ingress) or Express' list syntax
* ("loopback, 10.0.0.0/8").
* Which proxies to trust for X-Forwarded-For. It decides the client IP behind
* every rate limit and API-key IP allowlist: trust too little and all users
* share the proxy's IP, trust too much and a client can forge its own.
*
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
* count and stays the default when the variable is unset.
*/
export function trustProxySetting(): number | string {
const raw = process.env.TRUST_PROXY?.trim();
@@ -21,11 +30,37 @@ export function trustProxySetting(): number | string {
return /^\d+$/.test(raw) ? Number(raw) : raw;
}
/** Startup log line for the active mode; never includes the addresses. */
export function describeTrustProxy(
setting: number | string,
isExplicit: boolean,
): string {
if (typeof setting === 'number') {
return isExplicit
? `TRUST_PROXY: ${setting} trusted hop(s)`
: `TRUST_PROXY not set, using default of ${setting} trusted hop`;
}
const entries = setting.split(',').filter((e) => e.trim()).length;
return `TRUST_PROXY: list of ${entries} trusted proxy range(s)`;
}
export function configureApp(app: NestExpressApplication): void {
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the
// Trust the configured proxies (Traefik, Cloudflare) so req.ip reflects the
// real client IP from X-Forwarded-For — required for per-client throttling
// to work correctly rather than rate-limiting all traffic together.
app.set('trust proxy', trustProxySetting());
const trustProxy = trustProxySetting();
app.set('trust proxy', trustProxy);
const bootstrapLogger = new Logger('Bootstrap');
const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim();
bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit));
if (!trustProxyExplicit && process.env.NODE_ENV === 'production') {
// Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the
// proxy, so rate limits, API-key failure limits and IP allowlists act
// on a shared address instead of the client.
bootstrapLogger.warn(
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
);
}
// No ETags: they let caches and proxies revalidate (and keep) personal
// responses that are meant to be no-store.
app.set('etag', false);
+5 -2
View File
@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerModule } from '@nestjs/throttler';
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module';
@@ -48,6 +49,7 @@ import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module';
import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
@@ -106,13 +108,14 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
SiteSettingsModule,
BackupModule,
ShopSyncModule,
ErpExportModule,
ApiKeysModule,
],
controllers: [],
providers: [
// Throttle first: a request with a wrong token or API key must count
// against the limit too, before any database lookup is spent on it.
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: ClientIpThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
@@ -361,6 +361,15 @@ export const PERMISSION_CATALOG = [
[['manage', 'Anlegen, ansehen und widerrufen']],
3400,
),
// Like shop-sync: a narrow read-only allowlist for one machine consumer
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
// which return descriptions and full records.
...group(
'ERP-Anbindung',
'erp-inventory',
[['read', 'Inventar-Spiegel für ERPNext lesen']],
3500,
),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -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', () => {
@@ -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<void>,
): Promise<void> {
return runAfterCommit(
this.logger,
{ action: 'Automation rule evaluation', entityType, entityId },
evaluate,
);
}
async evaluateInspection(inspection: Inspection): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
return;
}
const config = rule.config as {
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return;
}
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
return;
}
const config = rule.config as {
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return;
}
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
});
});
}
async evaluateTreatment(treatment: Treatment): Promise<void> {
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<void> {
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<void> {
const alreadyOpen = await this.prisma.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return;
}
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const 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',
@@ -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;
@@ -0,0 +1,75 @@
import { Logger } from '@nestjs/common';
import {
DEFAULT_BACKUP_MAX_IMPORT_BYTES,
backupMaxImportBytes,
exceedsBackupImportLimit,
} from './backup-import-limit';
describe('backup-import-limit', () => {
const original = process.env.BACKUP_MAX_IMPORT_BYTES;
let warn: jest.SpyInstance;
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
if (original === undefined) {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
} else {
process.env.BACKUP_MAX_IMPORT_BYTES = original;
}
});
it('allows 50 MB when BACKUP_MAX_IMPORT_BYTES is not set', () => {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
expect(DEFAULT_BACKUP_MAX_IMPORT_BYTES).toBe(50 * 1024 * 1024);
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
expect(warn).not.toHaveBeenCalled();
});
it('takes a positive byte count from BACKUP_MAX_IMPORT_BYTES', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '104857600';
expect(backupMaxImportBytes()).toBe(104857600);
expect(warn).not.toHaveBeenCalled();
});
it.each(['abc', '0', '-5', '1.5', '50MB'])(
'falls back to the default with a logged warning for "%s"',
(value) => {
process.env.BACKUP_MAX_IMPORT_BYTES = value;
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('BACKUP_MAX_IMPORT_BYTES'),
);
},
);
it('caps a value beyond 256 MB, since the whole upload is held in memory', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = String(8 * 1024 * 1024 * 1024);
expect(backupMaxImportBytes()).toBe(256 * 1024 * 1024);
expect(warn).toHaveBeenCalledTimes(1);
});
it('warns only once for the same invalid value', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = 'not-a-number';
backupMaxImportBytes();
backupMaxImportBytes();
expect(warn).toHaveBeenCalledTimes(1);
});
it('flags only a file larger than the limit', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
expect(exceedsBackupImportLimit(1000)).toBe(false);
expect(exceedsBackupImportLimit(1001)).toBe(true);
});
});
@@ -0,0 +1,59 @@
import { Logger } from '@nestjs/common';
/**
* Largest backup file POST /backup/import accepts - and the size every
* export is checked against, so a backup that could no longer be restored
* is noticed when it is written, not on the day it is needed. A full bundle
* is far larger than the 5mb CSV import cap elsewhere (dozens of tables,
* years of records, document contents as base64), hence its own limit.
*
* BACKUP_MAX_IMPORT_BYTES overrides the default. Like the other BACKUP_*
* variables, an unusable value falls back instead of stopping the server,
* here with a logged warning.
*/
export const DEFAULT_BACKUP_MAX_IMPORT_BYTES = 50 * 1024 * 1024;
// The whole upload is held in memory, and parsed and decoded from there
// (a string near V8's ~512 MB limit, a 512Mi pod) - no setting goes beyond.
const BACKUP_MAX_IMPORT_CEILING_BYTES = 256 * 1024 * 1024;
const logger = new Logger('BackupImportLimit');
// Parsed once per distinct env value, so an invalid one warns only once.
let resolved: { raw: string | undefined; bytes: number } | undefined;
function parseLimit(raw: string | undefined): number {
if (raw === undefined || raw.trim() === '') {
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
}
const bytes = Number(raw);
if (!Number.isInteger(bytes) || bytes <= 0) {
logger.warn(
`BACKUP_MAX_IMPORT_BYTES="${raw}" ist keine positive ganze Zahl (Bytes) - es gilt der Standard von ${DEFAULT_BACKUP_MAX_IMPORT_BYTES} Bytes`,
);
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
}
if (bytes > BACKUP_MAX_IMPORT_CEILING_BYTES) {
logger.warn(
`BACKUP_MAX_IMPORT_BYTES=${bytes} liegt über der Obergrenze - es gelten ${BACKUP_MAX_IMPORT_CEILING_BYTES} Bytes`,
);
return BACKUP_MAX_IMPORT_CEILING_BYTES;
}
return bytes;
}
export function backupMaxImportBytes(): number {
const raw = process.env.BACKUP_MAX_IMPORT_BYTES;
if (!resolved || resolved.raw !== raw) {
resolved = { raw, bytes: parseLimit(raw) };
}
return resolved.bytes;
}
/** True when a backup file of this size would be refused on import. */
export function exceedsBackupImportLimit(sizeBytes: number): boolean {
return sizeBytes > backupMaxImportBytes();
}
/** A byte count as "52.4 MB" for log lines. */
export function formatMegabytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -0,0 +1,198 @@
import { Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import * as path from 'node:path';
import { Prisma } from '../../generated/prisma';
import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service';
jest.mock('node:fs/promises', () => ({
mkdir: jest.fn(),
writeFile: jest.fn(),
readdir: jest.fn(),
stat: jest.fn(),
unlink: jest.fn(),
}));
import * as fs from 'node:fs/promises';
describe('BackupSchedulerService', () => {
const originalEnv = { ...process.env };
const directory = path.resolve('/data/backups');
let service: BackupSchedulerService;
let backupService: { exportSealed: jest.Mock };
let error: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
process.env.BACKUP_STORAGE_PATH = directory;
delete process.env.BACKUP_MAX_IMPORT_BYTES;
delete process.env.BACKUP_RETENTION_COUNT;
delete process.env.BACKUP_CRON_SCHEDULE;
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
backupService = {
exportSealed: jest.fn().mockResolvedValue({
format: 'apicore-backup',
exportedAt: '2026-10-12T03:00:00.000Z',
ciphertext: 'x'.repeat(2000),
}),
};
const settings = {
get: jest.fn().mockResolvedValue({
storagePath: null,
cronSchedule: null,
retentionCount: null,
}),
};
service = new BackupSchedulerService(
backupService as unknown as BackupService,
settings as unknown as BackupSettingsService,
{} as SchedulerRegistry,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.readdir as jest.Mock).mockResolvedValue([]);
});
afterEach(() => {
error.mockRestore();
process.env = { ...originalEnv };
});
function onDisk(files: { name: string; size: number; mtime: string }[]) {
(fs.readdir as jest.Mock).mockResolvedValue(files.map((f) => f.name));
(fs.stat as jest.Mock).mockImplementation((filePath: string) => {
const file = files.find((f) => filePath.endsWith(f.name));
return Promise.resolve({
size: file?.size,
mtime: new Date(file!.mtime),
});
});
}
describe('getStatus', () => {
it('reports the import limit and the size of the newest backup', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
onDisk([
{
name: 'apicore-backup-old.json',
size: 5000,
mtime: '2026-10-10T03:00:00.000Z',
},
{
name: 'apicore-backup-new.json',
size: 900,
mtime: '2026-10-11T03:00:00.000Z',
},
]);
const status = await service.getStatus();
expect(status).toEqual(
expect.objectContaining({
importLimitBytes: 1000,
lastBackupSizeBytes: 900,
lastBackupExceedsImportLimit: false,
}),
);
});
it('warns when the newest backup could no longer be imported', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
onDisk([
{
name: 'apicore-backup-new.json',
size: 1001,
mtime: '2026-10-11T03:00:00.000Z',
},
]);
const status = await service.getStatus();
expect(status.lastBackupSizeBytes).toBe(1001);
expect(status.lastBackupExceedsImportLimit).toBe(true);
});
it('still reports the import limit while no schedule is configured', async () => {
delete process.env.BACKUP_STORAGE_PATH;
const status = await service.getStatus();
expect(status).toEqual(
expect.objectContaining({
enabled: false,
importLimitBytes: 50 * 1024 * 1024,
lastBackupSizeBytes: null,
lastBackupExceedsImportLimit: false,
}),
);
});
});
describe('runNow', () => {
it('keeps a backup larger than the import limit but logs an error', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
const result = await service.runNow();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(result.fileName).toMatch(/^apicore-backup-.*\.json$/);
expect(error).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledWith(
expect.stringContaining('Import-Limit'),
);
});
it('logs nothing for a backup within the import limit', async () => {
await service.runNow();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(error).not.toHaveBeenCalled();
});
});
// The export reads every table through Prisma; a Prisma message quotes
// query values, so a failed scheduled run logs name, code and model only.
describe('scheduled run', () => {
it('logs a failed scheduled backup without the Prisma message', async () => {
const registry = {
doesExist: jest.fn().mockReturnValue(false),
addCronJob: jest.fn(),
deleteCronJob: jest.fn(),
};
const scheduled = new BackupSchedulerService(
backupService as unknown as BackupService,
{
get: jest.fn().mockResolvedValue({
storagePath: null,
cronSchedule: null,
retentionCount: null,
}),
} as unknown as BackupSettingsService,
registry as unknown as SchedulerRegistry,
);
backupService.exportSealed.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'Task' },
}),
);
await scheduled.reload();
const [[, job]] = registry.addCronJob.mock.calls as [[string, CronJob]];
await job.stop();
await job.fireOnTick();
await new Promise((resolve) => setImmediate(resolve));
const failure = error.mock.calls.find(([message]) =>
String(message).startsWith('Scheduled backup failed'),
) as [string, unknown];
expect(failure[0]).toContain('P2010');
expect(failure[0]).not.toContain('Mustermann');
expect(failure[1]).toBeUndefined();
});
});
});
@@ -5,10 +5,16 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { 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<BackupScheduleStatus> {
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 };
}),
);
@@ -0,0 +1,100 @@
import { Logger } from '@nestjs/common';
import type { Response } from 'express';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service';
import { ReauthService } from '../auth/reauth/reauth.service';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
describe('BackupController', () => {
const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES;
let controller: BackupController;
let backupService: { importFile: jest.Mock; exportSealed: jest.Mock };
let scheduler: { reload: jest.Mock };
let reauth: { verify: jest.Mock };
const actor = { id: 'admin-1' } as AuthenticatedUser;
const file = {
buffer: Buffer.from(JSON.stringify({ format: 'apicore-backup' })),
} as Express.Multer.File;
beforeEach(() => {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
backupService = {
importFile: jest.fn().mockResolvedValue({ imported: {}, skipped: [] }),
exportSealed: jest.fn().mockResolvedValue({
format: 'apicore-backup',
exportedAt: '2026-10-12T03:00:00.000Z',
ciphertext: 'x'.repeat(2000),
}),
};
scheduler = { reload: jest.fn().mockResolvedValue(undefined) };
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
controller = new BackupController(
backupService as unknown as BackupService,
scheduler as unknown as BackupSchedulerService,
{} as BackupSettingsService,
reauth as unknown as ReauthService,
);
});
afterEach(() => {
if (originalLimit === undefined) {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
} else {
process.env.BACKUP_MAX_IMPORT_BYTES = originalLimit;
}
});
it('re-registers the backup schedule after an import, since it may restore other backup settings', async () => {
await controller.import(file, { password: 'secret' }, actor);
expect(backupService.importFile).toHaveBeenCalledTimes(1);
expect(scheduler.reload).toHaveBeenCalledTimes(1);
expect(scheduler.reload.mock.invocationCallOrder[0]).toBeGreaterThan(
backupService.importFile.mock.invocationCallOrder[0],
);
});
it('leaves the schedule alone when the import fails', async () => {
backupService.importFile.mockRejectedValue(new Error('broken file'));
await expect(
controller.import(file, { password: 'secret' }, actor),
).rejects.toThrow('broken file');
expect(scheduler.reload).not.toHaveBeenCalled();
});
describe('export', () => {
let warn: jest.SpyInstance;
let res: { setHeader: jest.Mock; send: jest.Mock };
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
res = { setHeader: jest.fn(), send: jest.fn() };
});
afterEach(() => {
warn.mockRestore();
});
it('still delivers a backup larger than the import limit, but logs a warning', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
await controller.export(res as unknown as Response);
expect(res.send).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Import-Limit'),
);
});
it('logs nothing for a backup within the import limit', async () => {
await controller.export(res as unknown as Response);
expect(res.send).toHaveBeenCalledTimes(1);
expect(warn).not.toHaveBeenCalled();
});
});
});
@@ -3,6 +3,7 @@ import {
Body,
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<void> {
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;
}
}
+11 -1
View File
@@ -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],
})
+602 -25
View File
@@ -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<string, unknown>;
describe('BackupService', () => {
let service: BackupService;
let prisma: Record<string, { findMany: jest.Mock }> & {
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<string, { upsert: jest.Mock; update?: jest.Mock }> & {
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<string, unknown>;
create: Record<string, unknown>;
},
][]
)[0][0];
expect(firstCall('rawMaterialLot').where).toEqual({ id: 'lot-1' });
expect(firstCall('rawMaterialLot').create.userId).toBe('admin-1');
expect(firstCall('productionBatch').create.userId).toBe('known-user');
expect(firstCall('batchMovement').create.userId).toBeNull();
expect(firstCall('productionInput').where).toEqual({ id: 'input-1' });
expect(firstCall('shopStockRequest').where).toEqual({ id: 'request-1' });
expect(firstCall('honeyBatchContainerAllocation').where).toEqual({
id: 'allocation-1',
});
});
it('writes every parent row before the rows that reference it', async () => {
const one = (id: string) => [{ id }];
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: one('apiary-1'),
harvest: one('harvest-1'),
storageContainer: one('container-1'),
honeyBatch: one('honey-1'),
honeyBatchContainerAllocation: one('allocation-1'),
product: one('product-1'),
bottlingBatch: one('bottling-1'),
rawMaterialLot: one('lot-1'),
productionBatch: one('batch-1'),
productionInput: one('input-1'),
shopStockRequest: one('request-1'),
batchMovement: one('movement-1'),
automationRule: [{ triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT' }],
automationSequence: [
{
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
value: 7,
},
],
},
},
'admin-1',
TRUSTED,
);
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
const dependencies: [parent: string, child: string][] = [
['storageContainer', 'honeyBatchContainerAllocation'],
['honeyBatch', 'honeyBatchContainerAllocation'],
['apiary', 'rawMaterialLot'],
['harvest', 'rawMaterialLot'],
['product', 'productionBatch'],
['productionBatch', 'productionInput'],
['rawMaterialLot', 'productionInput'],
['bottlingBatch', 'batchMovement'],
['productionBatch', 'batchMovement'],
['rawMaterialLot', 'batchMovement'],
['shopStockRequest', 'batchMovement'],
['automationRule', 'automationSequence'],
];
for (const [parent, child] of dependencies) {
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
{ parent, child, before: true },
);
}
});
it('upserts automationSequence by triggerType and scope', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
automationSequence: [
{
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
value: 7,
},
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.automationSequence.upsert.mock.calls as [
[{ where: unknown; create: Record<string, unknown> }],
];
expect(call.where).toEqual({
triggerType_scope: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
},
});
expect(call.create.value).toBe(7);
});
it('upserts a calendar outbox entry by its source and keeps the target row id', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
calendarSyncOutboxEntry: [
{
id: 'entry-source-id',
sourceType: 'TASK',
sourceId: 'task-1',
graphEventId: 'graph-event-1',
},
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.calendarSyncOutboxEntry.upsert.mock.calls as [
[{ where: unknown; update: Record<string, unknown> }],
];
expect(call.where).toEqual({
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
});
expect(call.update).not.toHaveProperty('id');
expect(call.update.graphEventId).toBe('graph-event-1');
});
it('upserts the backup and calendar settings singletons by id without rewriting it', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
backupSettings: [{ id: 'singleton', retentionCount: 30 }],
calendarSyncSettings: [{ id: 'singleton', enabled: true }],
},
},
'admin-1',
TRUSTED,
);
for (const key of ['backupSettings', 'calendarSyncSettings']) {
const [[call]] = tx[key].upsert.mock.calls as [
[{ where: unknown; update: Record<string, unknown> }],
];
expect(call.where).toEqual({ id: 'singleton' });
expect(call.update).not.toHaveProperty('id');
}
});
it('takes no calendar sync or backup schedule from an unsealed file', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
backupSettings: [{ id: 'singleton', retentionCount: 1 }],
calendarSyncSettings: [{ id: 'singleton', groupId: 'evil' }],
calendarSyncOutboxEntry: [
{ id: 'entry-1', sourceType: 'TASK', sourceId: 'task-1' },
],
},
},
'admin-1',
{ trusted: false },
);
expect(tx.backupSettings.upsert).not.toHaveBeenCalled();
expect(tx.calendarSyncSettings.upsert).not.toHaveBeenCalled();
expect(tx.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
expect(summary.skipped).toEqual(
expect.arrayContaining([
'backupSettings',
'calendarSyncSettings',
'calendarSyncOutboxEntry',
]),
);
});
it('restores an older bundle that lacks the newer tables as if they were empty', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
},
'admin-1',
TRUSTED,
);
expect(summary.imported).toEqual(
expect.objectContaining({
apiary: 1,
productionBatch: 0,
rawMaterialLot: 0,
batchMovement: 0,
automationSequence: 0,
}),
);
});
});
// A file without the seal cannot be proven to come from ApiCore: it may
// only add plain master and field records, nothing else.
describe('unsealed file', () => {
const UNSEALED = { trusted: false };
const SEALED_ONLY_SAMPLE: Record<string, Row[]> = {
automationRule: [{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT' }],
automationSequence: [
{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT', scope: 'x', value: 0 },
],
stockItem: [{ id: 'stock-1' }],
stockMovement: [{ id: 'movement-1' }],
batchMovement: [{ id: 'ledger-1' }],
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
rawMaterialLot: [{ id: 'lot-1' }],
productionBatch: [{ id: 'batch-1' }],
productionInput: [{ id: 'input-1' }],
honeyBatchContainerAllocation: [{ id: 'allocation-1' }],
siteSettings: [{ id: 'singleton', holderName: 'Fake' }],
document: [
{
id: 'doc-1',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
},
],
notification: [{ id: 'note-1', message: 'Bitte hier anmelden' }],
};
it('takes no counters, ledgers, shop requests, settings, documents or notifications from it', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
...SEALED_ONLY_SAMPLE,
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
},
},
'admin-1',
UNSEALED,
);
for (const key of Object.keys(SEALED_ONLY_SAMPLE)) {
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
key,
calls: 0,
});
}
expect(fs.writeFile).not.toHaveBeenCalled();
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
expect([...summary.skipped].sort()).toEqual(
Object.keys(SEALED_ONLY_SAMPLE).sort(),
);
});
it('allows only plain master and field records - no table added later', () => {
expect([...LEGACY_UNSEALED_TABLES].sort()).toEqual(
[
'apiary',
'queen',
'colony',
'colonyApiaryHistory',
'colonyLineage',
'queenEvaluation',
'queenColonyHistory',
'inspection',
'taskTemplate',
'task',
'varroaMeasurement',
'swarmControlCheck',
'winterCheck',
'treatment',
'feeding',
'harvest',
'harvestColony',
'extraction',
'extractionHarvest',
'storageContainer',
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'product',
'labelTemplate',
'bottlingBatch',
'equipment',
].sort(),
);
});
it('adds the records it carries but never rewrites ones that already exist', async () => {
const data = Object.fromEntries(
LEGACY_UNSEALED_TABLES.map((key) => [
key,
[{ id: `${key}-1`, notes: 'aus der Datei' }],
]),
);
await service.importAll(
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
'admin-1',
UNSEALED,
);
for (const key of LEGACY_UNSEALED_TABLES) {
const [[call]] = tx[key].upsert.mock.calls as [
[{ create: Row; update: Row }],
];
expect({ key, update: call.update }).toEqual({ key, update: {} });
expect(call.create.notes).toBe('aus der Datei');
}
});
it('does not re-link the mother line of a queen that was already there', async () => {
tx.queen.findMany.mockResolvedValue([{ id: 'queen-existing' }]);
await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
queen: [
{ id: 'queen-existing', motherQueenId: 'queen-mother' },
{ id: 'queen-new', motherQueenId: 'queen-mother' },
{ id: 'queen-mother', motherQueenId: null },
],
},
},
'admin-1',
UNSEALED,
);
expect(tx.queen.update).toHaveBeenCalledTimes(1);
expect(tx.queen.update).toHaveBeenCalledWith({
where: { id: 'queen-new' },
data: { motherQueenId: 'queen-mother' },
});
});
it('reports the import as unsealed and lists no unknown sections as skipped', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { notATable: [{ id: 'x' }], apiary: [{ id: 'apiary-1' }] },
},
'admin-1',
UNSEALED,
);
expect(summary.sealed).toBe(false);
expect(summary.skipped).toEqual([]);
});
it('still restores everything, overwriting existing rows, from a sealed file', async () => {
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
stockMovement: [{ id: 'movement-1', userId: null }],
},
},
'admin-1',
TRUSTED,
);
expect(summary.sealed).toBe(true);
expect(summary.skipped).toEqual([]);
const [[call]] = tx.apiary.upsert.mock.calls as [[{ update: Row }]];
expect(call.update).toEqual({ id: 'apiary-1', name: 'Stand Wald' });
expect(tx.stockMovement.upsert).toHaveBeenCalledTimes(1);
});
});
// Restoring a document writes its content to a new file; the file the row
// pointed at before must not be left behind on disk.
describe('replaced document files', () => {
const storageRoot = path.resolve(
process.env.DOCUMENTS_STORAGE_PATH ?? './uploads',
);
const oldFile = path.join(storageRoot, 'colony', 'old.pdf');
const bundle = {
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
document: [
{
id: 'doc-1',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
},
],
},
};
let warn: jest.SpyInstance;
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
});
afterEach(() => {
warn.mockRestore();
});
it('deletes the file a restored row replaced, only after the import committed', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).toHaveBeenCalledWith(oldFile);
expect(
(fs.unlink as jest.Mock).mock.invocationCallOrder[0],
).toBeGreaterThan(tx.document.upsert.mock.invocationCallOrder[0]);
expect(prisma.document.count).toHaveBeenCalledWith({
where: { storagePath: oldFile },
});
});
it('deletes nothing when the import fails', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
tx.notification.upsert.mockRejectedValue(new Error('db down'));
await expect(
service.importAll(
{
...bundle,
data: { ...bundle.data, notification: [{ id: 'note-1' }] },
},
'admin-1',
TRUSTED,
),
).rejects.toThrow('db down');
expect(fs.unlink).not.toHaveBeenCalled();
});
it('keeps a replaced file outside the documents root and logs it', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: '/etc/passwd' });
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
});
it('keeps a replaced file that another document row still uses', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
prisma.document.count.mockResolvedValue(1);
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
});
it('completes the import when a replaced file cannot be deleted, and logs it', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
(fs.unlink as jest.Mock).mockRejectedValue(new Error('EBUSY'));
await expect(
service.importAll(bundle, 'admin-1', TRUSTED),
).resolves.toEqual(expect.objectContaining({ sealed: true }));
expect(warn).toHaveBeenCalledTimes(1);
});
it('deletes nothing for a document that is new to this database', async () => {
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {
it('backs up every Prisma model or names why it is left out', async () => {
const bundle = await service.exportAll();
const uncovered = Object.values(Prisma.ModelName).filter(
(model) =>
!(modelKey(model) in bundle.data) &&
!(model in MODELS_EXCLUDED_FROM_BACKUP),
);
expect(uncovered).toEqual([]);
});
it('gives every excluded model a reason and never exports it anyway', async () => {
const bundle = await service.exportAll();
for (const [model, reason] of Object.entries(
MODELS_EXCLUDED_FROM_BACKUP,
)) {
expect(bundle.data).not.toHaveProperty(modelKey(model));
expect(reason?.trim().length).toBeGreaterThan(0);
}
});
it('restores every table it exports', async () => {
const bundle = await service.exportAll();
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
expect(Object.keys(summary.imported).sort()).toEqual(
Object.keys(bundle.data).sort(),
);
});
});
});
+361 -70
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as 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<string, number>;
/**
* 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<string>(
Object.values(Prisma.ModelName).map(
(model) => model.charAt(0).toLowerCase() + model.slice(1),
),
);
/**
* Models deliberately left out of the backup, each with its reason. Every
* other Prisma model is a section of the bundle, named like its client
* delegate (`productionBatch`). The model coverage test in
* backup.service.spec.ts fails for a model that is neither, so a new table
* cannot silently drop out of the backup.
*/
export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
Partial<Record<Prisma.ModelName, string>>
> = {
Permission:
'Code-derived catalog, re-synced from the code on every boot. RolePermission rows reference permissions by resource+action instead, resolved against the target database.',
RefreshToken:
'Live session tokens - a session must never be revivable from a backup file.',
ConsumedToken:
'Replay guard for one-time tokens that expire within minutes, so always stale by the time a backup is restored.',
ApiKey:
'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.',
IdempotencyRecord:
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
};
const DOCUMENT_ENTITY_TYPES = new Set<string>(
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<string, number> = {};
@@ -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<string>(
trusted
? []
: (
await tx.queen.findMany({
where: {
id: { in: rowsOf('queen').map((row) => row.id as string) },
},
select: { id: true },
})
).map((queen) => queen.id),
);
// Inserted without motherQueenId first (a daughter queen can appear
// 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<void> {
const storageRoot = path.resolve(documentsStorageRoot());
for (const storagePath of storagePaths) {
const resolved = path.resolve(storagePath);
if (!resolved.startsWith(storageRoot + path.sep)) {
this.logger.warn(
`Ersetzte Dokumentdatei liegt außerhalb von ${storageRoot} und bleibt erhalten: ${resolved}`,
);
continue;
}
if ((await this.prisma.document.count({ where: { storagePath } })) > 0) {
continue;
}
try {
await fs.unlink(resolved);
} catch (error: unknown) {
this.logger.warn(
`Ersetzte Dokumentdatei ${resolved} konnte nicht gelöscht werden: ${String(error)}`,
);
}
}
}
}
@@ -8,6 +8,7 @@ import {
IsString,
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;
}
@@ -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);
@@ -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);
}
@@ -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();
}
}
@@ -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<string, unknown> = {}) => ({
id: 'entry-1',
sourceType: 'TASK',
sourceId: 'task-1',
status: 'PENDING',
graphEventId: null,
attempts: 0,
revision: 3,
claimedAt: null,
...overrides,
});
/** Derived from the outbox entry, shaped like a GUID. */
const TRANSACTION_ID = expect.stringMatching(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
) as string;
/** Free, or held by a worker that gave up long ago (cut-off time). */
const CLAIMABLE = {
OR: [{ claimedAt: null }, { claimedAt: { lt: expect.any(Date) as Date } }],
};
type UpdateManyArgs = {
where: Record<string, unknown>;
data: Record<string, unknown>;
};
const updateManyCalls = () =>
prisma.calendarSyncOutboxEntry.updateMany.mock.calls.map(
([args]) => args as UpdateManyArgs,
);
/** The claim taken by this pass - every later write is conditional on it. */
const claimedAt = () => updateManyCalls()[0].data.claimedAt as Date;
beforeEach(() => {
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<string, unknown>) => {
expect(updateManyCalls()[1]).toEqual({
where: {
id: 'entry-1',
status: 'PENDING',
revision: 3,
claimedAt: claimedAt(),
},
data: { ...data, claimedAt: null },
});
};
it('does nothing when sync is disabled, unconfigured, or no groupId', async () => {
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<string, unknown> }],
];
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<string, unknown>) => {
graphClient.createEvent.mockClear();
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(entry),
]);
await service.processOutbox();
const [[, , transactionId]] = graphClient.createEvent.mock.calls as [
[string, unknown, string],
];
return transactionId;
};
beforeEach(() => {
prisma.task.findUnique.mockResolvedValue({
id: 'task-1',
title: 'Beute prüfen',
dueDate: new Date('2026-03-07'),
});
graphClient.createEvent.mockResolvedValue('graph-event-1');
});
it('is the same for every retry of the same create', async () => {
graphClient.createEvent.mockRejectedValueOnce(new Error('timeout'));
const first = await transactionIdFor({ attempts: 0 });
const retry = await transactionIdFor({ attempts: 1 });
expect(first).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(retry).toBe(first);
});
it('differs per outbox entry', async () => {
const one = await transactionIdFor({ id: 'entry-1' });
const other = await transactionIdFor({ id: 'entry-2' });
expect(other).not.toBe(one);
});
// A cancelled source deleted its event; enqueued again it needs a new
// one, which an old transactionId might be matched against.
it('is new once the entry was enqueued again', async () => {
const before = await transactionIdFor({ revision: 3 });
const after = await transactionIdFor({ revision: 4 });
expect(after).not.toBe(before);
});
it('is new when replacing an event that was deleted in M365', async () => {
const initial = await transactionIdFor({ graphEventId: null });
graphClient.updateEvent.mockRejectedValue(
new GraphRequestError('updateEvent', 404, 'gone'),
);
const replacement = await transactionIdFor({
graphEventId: 'deleted-on-m365',
});
expect(replacement).not.toBe(initial);
});
});
describe('runNow', () => {
it('gives FAILED entries a fresh set of attempts before processing', async () => {
await service.runNow();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [readOrder] =
prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(readOrder);
});
it('leaves FAILED entries alone on a regular cron tick', async () => {
await service.processOutbox();
expect(
prisma.calendarSyncOutboxEntry.updateMany,
).not.toHaveBeenCalledWith(
expect.objectContaining({ where: { status: 'FAILED' } }),
);
});
});
// "Jetzt synchronisieren" must not hold the HTTP request for a whole pass
// (up to 20 entries with several Graph calls each): it only starts it.
describe('startRun', () => {
const flush = async () => {
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
};
/** Lets the pass hang on reading the outbox until released. */
const holdOutbox = () => {
let release: () => void = () => undefined;
prisma.calendarSyncOutboxEntry.findMany.mockReturnValue(
new Promise((resolve) => {
release = () => resolve([]);
}),
);
return () => release();
};
it('returns while the pass is still running and reports it as running', async () => {
const release = holdOutbox();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
expect((await service.getStatus()).running).toBe(true);
release();
await flush();
expect((await service.getStatus()).running).toBe(false);
});
it('starts only one pass per pod at a time', async () => {
const release = holdOutbox();
service.startRun();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
release();
await flush();
});
it('logs a failed pass without query values and clears the indicator', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
service.startRun();
await flush();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect((await service.getStatus()).running).toBe(false);
errorSpy.mockRestore();
});
it('lets shutdown wait for a running pass', async () => {
const release = holdOutbox();
service.startRun();
let destroyed = false;
const shutdown = service.onModuleDestroy().then(() => {
destroyed = true;
});
await flush();
expect(destroyed).toBe(false);
release();
await shutdown;
expect(destroyed).toBe(true);
});
});
// The cron tick's own errors would otherwise reach the scheduler's logger
// with the full Prisma message and stack.
describe('scheduledRun (cron tick)', () => {
it('runs the regular pass', async () => {
await service.scheduledRun();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
});
it('never throws and logs a failed tick without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
await expect(service.scheduledRun()).resolves.toBeUndefined();
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
errorSpy.mockRestore();
});
});
@@ -355,7 +778,8 @@ describe('CalendarSyncService', () => {
prisma.calendarSyncOutboxEntry.count
.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 } },
});
});
});
@@ -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<void> | 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<void>,
): Promise<void> {
return runAfterCommit(
this.logger,
{ action, entityType: sourceType, entityId: sourceId },
work,
);
}
// Called from the ~6 domain services right after a create/update that
// 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<void> {
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<void> {
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<void> {
const settings = await this.settingsService.get();
if (
!settings.enabled ||
!settings.groupId ||
!this.graphClient.isConfigured()
) {
return;
}
try {
await this.graphClient.deleteEvent(settings.groupId, graphEventId);
} catch (error: unknown) {
this.logger.warn(
`Could not delete Graph event ${graphEventId}: ${getErrorMessage(error)}`,
);
}
}
// The cron tick. An error is logged here, Prisma errors by name/code/model
// only (see loggableError) - left uncaught, the scheduler would log the
// full Prisma message and stack.
@Cron(CronExpression.EVERY_MINUTE)
async scheduledRun(): Promise<void> {
try {
await this.processOutbox();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Calendar sync tick failed: ${message}`, stack);
}
}
async processOutbox(): Promise<void> {
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<void> {
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<void> {
await this.activeRun;
}
// Compare-and-set on the row as it was read: the update only matches while
// the row is still PENDING, unchanged and unclaimed, so of two workers
// reading the same candidate exactly one gets it (the other gets count 0).
private async claim(
entry: CalendarSyncOutboxEntry,
): Promise<ClaimedEntry | null> {
const claimedAt = new Date();
const claim = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: {
id: entry.id,
status: 'PENDING',
revision: entry.revision,
...claimableNow(),
},
data: { claimedAt },
});
return claim.count === 1 ? { ...entry, claimedAt } : null;
}
private async processEntry(
entry: ClaimedEntry,
groupId: string,
): Promise<void> {
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<void> {
const settled = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: this.unchangedSinceClaim(entry),
data: { ...outcome, claimedAt: null },
});
if (settled.count === 0) {
await this.release(entry);
}
}
private async release(entry: ClaimedEntry): Promise<number> {
const released = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: { id: entry.id, claimedAt: entry.claimedAt },
data: { claimedAt: null },
});
return released.count;
}
// The event now exists in M365. Unless the row changed meanwhile it is
// SYNCED. Otherwise the event id must still be kept - a lost id means a
// duplicate event on the next pass.
private async recordSynced(
entry: ClaimedEntry,
graphEventId: string,
): Promise<void> {
const synced = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: this.unchangedSinceClaim(entry),
data: {
status: 'SYNCED',
graphEventId,
lastError: null,
claimedAt: null,
},
});
if (synced.count === 1) {
return;
}
// Re-enqueued during the sync (the source was edited): stays PENDING,
// and the next pass updates this event with the newer state.
const kept = await this.prisma.calendarSyncOutboxEntry.updateMany({
where: { id: entry.id, status: 'PENDING', claimedAt: entry.claimedAt },
data: { graphEventId, claimedAt: null },
});
if (kept.count === 1) {
return;
}
// Cancelled during the sync (the source left the calendar): the event
// just written must not stay behind.
if ((await this.release(entry)) === 1) {
await this.deleteGraphEvent(graphEventId);
return;
}
this.logger.warn(
`Calendar sync claim on ${entry.sourceType} ${entry.sourceId} expired before Graph event ${graphEventId} was recorded`,
);
}
// PATCH on an existing event id that was deleted on the M365 side (e.g. an
// 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<string> {
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<CalendarSyncStatusReport> {
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,
};
}
}
@@ -0,0 +1,33 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateCalendarSyncSettingsDto } from './update-calendar-sync-settings.dto';
async function errorsFor(groupId: unknown): Promise<string[]> {
const dto = plainToInstance(UpdateCalendarSyncSettingsDto, { groupId });
const errors = await validate(dto);
return errors.flatMap((error) => Object.values(error.constraints ?? {}));
}
// The group id goes into the Graph URL path - only an Entra object id
// (a GUID) is accepted, or an empty string to clear the value.
describe('UpdateCalendarSyncSettingsDto groupId', () => {
it.each([
'3fa85f64-5717-4562-b3fc-2c963f66afa6',
'3FA85F64-5717-4562-B3FC-2C963F66AFA6',
'',
])('accepts %p', async (groupId) => {
expect(await errorsFor(groupId)).toEqual([]);
});
it.each([
'meine-gruppe',
'3fa85f64-5717-4562-b3fc-2c963f66afa6/events',
'../users/[email protected]',
'3fa85f64571745 62b3fc2c963f66afa6',
' 3fa85f64-5717-4562-b3fc-2c963f66afa6',
])('rejects %p with a German message', async (groupId) => {
expect(await errorsFor(groupId)).toEqual([
'Gruppen-ID muss die Objekt-ID der Microsoft-365-Gruppe sein (Format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)',
]);
});
});
@@ -1,48 +1,69 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { 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;
}
@@ -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
@@ -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<string> {
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<void> {
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<void> {
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(),
);
}
}
@@ -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,
@@ -317,7 +317,9 @@ export class ColoniesService {
async reactivate(id: string, actingUserId: string): Promise<Colony> {
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({
@@ -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;
@@ -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;
}
@@ -0,0 +1,85 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { runAfterCommit } from './after-commit';
describe('runAfterCommit', () => {
let logger: Logger;
let errorSpy: jest.SpyInstance;
beforeEach(() => {
logger = new Logger('AfterCommitSpec');
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
});
const subject = {
action: 'Automation rule evaluation',
entityType: 'Inspection',
entityId: 'inspection-1',
};
it('runs the side effect and logs nothing when it succeeds', async () => {
const work = jest.fn().mockResolvedValue(undefined);
await runAfterCommit(logger, subject, work);
expect(work).toHaveBeenCalledTimes(1);
expect(errorSpy).not.toHaveBeenCalled();
});
it('never rejects when the side effect fails', async () => {
const work = jest.fn().mockRejectedValue(new Error('database gone'));
await expect(
runAfterCommit(logger, subject, work),
).resolves.toBeUndefined();
});
it('logs the failure at error level with entity type, id, error and stack', async () => {
const failure = new Error('database gone');
await runAfterCommit(logger, subject, () => Promise.reject(failure));
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message, stack] = errorSpy.mock.calls[0] as [string, string];
expect(message).toContain('Automation rule evaluation');
expect(message).toContain('Inspection inspection-1');
expect(message).toContain('database gone');
expect(stack).toBe(failure.stack);
});
it('also contains a synchronous throw', async () => {
await runAfterCommit(logger, subject, () => {
throw new Error('thrown synchronously');
});
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain('thrown synchronously');
});
it('never logs the message or stack of a Prisma error (they quote query values)', async () => {
const failure = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on title "Max Mustermann"',
{ code: 'P2002', clientVersion: '7.9.1', meta: { modelName: 'Task' } },
);
await runAfterCommit(logger, subject, () => Promise.reject(failure));
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('Inspection inspection-1');
expect(message).toContain('P2002');
expect(message).toContain('Task');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
});
it('logs a rejection that is not an Error object', async () => {
const work = jest.fn().mockRejectedValue('plain string');
await runAfterCommit(logger, subject, work);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('plain string');
expect(stack).toBeUndefined();
});
});
@@ -0,0 +1,37 @@
import { Logger } from '@nestjs/common';
import { loggableError } from './loggable-error';
/** What a post-commit side effect was about - named in the log line. */
export interface AfterCommitSubject {
action: string;
entityType: string;
entityId: string;
}
/**
* Runs a side effect of a write that is already committed: automation
* rules, calendar sync, webhooks. It must never fail the request - the
* client would get an error for a record that exists, the Idempotency-Key
* would be released, and the offline queue would send the request again and
* store the record twice (for a treatment: a second Bestandsbuch entry).
*
* A failure is logged at error level with the record it concerned, and the
* caller carries on. Only for steps after the commit: anything that has to
* stop the write (number generation, validation) keeps throwing. What of
* the error is logged follows loggableError() - no query values.
*/
export async function runAfterCommit(
logger: Logger,
subject: AfterCommitSubject,
work: () => Promise<void> | void,
): Promise<void> {
try {
await work();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
logger.error(
`${subject.action} failed for ${subject.entityType} ${subject.entityId}: ${message}`,
stack,
);
}
}
@@ -176,4 +176,127 @@ describe('AllExceptionsFilter', () => {
message: 'Internal server error',
});
});
// Prisma messages quote the query (titles, names, notes) - the log gets
// name, code and model only, the client the same bodies as before.
describe('Prisma errors in the log', () => {
it('logs an unmapped Prisma error without its message or stack', () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientValidationError(
'Invalid `prisma.task.create()` invocation: { data: { title: "Max Mustermann" } }',
{ clientVersion: '7.9.1' },
);
filter.catch(exception, host);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('PrismaClientValidationError');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 500,
message: 'Internal server error',
});
});
it('logs a translated Prisma error at warn level by code and model only', () => {
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const exception = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on the fields: (`code`) for "Mustermann"',
{
code: 'P2002',
clientVersion: '7.9.1',
meta: { modelName: 'Colony', target: ['code'] },
},
);
filter.catch(exception, host);
const [message] = warnSpy.mock.calls[0] as [string];
expect(message).toContain('P2002');
expect(message).toContain('Colony');
expect(message).not.toContain('Mustermann');
expect(jsonMock).toHaveBeenCalledWith({
statusCode: 409,
message:
'Code wird bereits verwendet - bitte einen anderen Wert wählen',
});
});
it('still logs message and stack of other server errors', () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const exception = new Error('Graph timeout');
filter.catch(exception, host);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('Graph timeout');
expect(stack).toBe(exception.stack);
});
});
// body-parser rejects a request before Nest sees it. Its message (and the
// `body` property) can quote the request body - names, notes, passwords -
// so the log gets status and type only, the client a fixed German text.
describe('body-parser errors', () => {
/** Shaped like body-parser's http-errors. */
const bodyParserError = (
status: number,
type: string,
message: string,
): Error =>
Object.assign(new Error(message), {
status,
statusCode: status,
type,
expose: true,
body: '{"name":"Max Mustermann","password":"geheim"',
});
it.each([
[
400,
'entity.parse.failed',
'Unexpected token, "{"name":"Max Mustermann"..." is not valid JSON',
'Ungültige Anfrage',
],
[
413,
'entity.too.large',
'request entity too large',
'Anfrage ist zu groß',
],
[
415,
'charset.unsupported',
'unsupported charset "MUSTERMANN-8"',
'Format der Anfrage wird nicht unterstützt',
],
])(
'logs a %i (%s) by status and type only and answers with a fixed text',
(status, type, message, clientMessage) => {
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
filter.catch(bodyParserError(status, type, message), host);
const [logged] = warnSpy.mock.calls[0] as [string];
expect(logged).toContain(String(status));
expect(logged).toContain(type);
expect(logged).not.toMatch(/Mustermann|MUSTERMANN|geheim/);
expect(statusMock).toHaveBeenCalledWith(status);
expect(jsonMock).toHaveBeenCalledWith({
statusCode: status,
message: clientMessage,
});
},
);
});
});
@@ -8,6 +8,7 @@ import {
} from '@nestjs/common';
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}`);
}
@@ -0,0 +1,62 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
import { IdempotencyCleanupService } from './idempotency-cleanup.service';
describe('IdempotencyCleanupService', () => {
let service: IdempotencyCleanupService;
let prisma: {
idempotencyRecord: { deleteMany: jest.Mock };
consumedToken: { deleteMany: jest.Mock };
};
beforeEach(() => {
prisma = {
idempotencyRecord: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
consumedToken: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
service = new IdempotencyCleanupService(prisma as unknown as PrismaService);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('removes records older than the retention period and expired tokens', async () => {
prisma.idempotencyRecord.deleteMany.mockResolvedValue({ count: 3 });
const now = new Date('2026-10-12T04:00:00.000Z');
await expect(service.removeExpired(now)).resolves.toBe(3);
expect(prisma.idempotencyRecord.deleteMany).toHaveBeenCalledWith({
where: { createdAt: { lt: new Date('2026-07-14T04:00:00.000Z') } },
});
expect(prisma.consumedToken.deleteMany).toHaveBeenCalledWith({
where: { expiresAt: { lt: now } },
});
});
// The cron tick's own errors would otherwise reach the scheduler's logger
// with the full Prisma message and stack.
it('the daily tick never throws and logs a failure without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.idempotencyRecord.deleteMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('key "Max Mustermann"', {
code: 'P2024',
clientVersion: '7.9.1',
meta: { modelName: 'IdempotencyRecord' },
}),
);
await expect(service.scheduledCleanup()).resolves.toBeUndefined();
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2024');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
});
});
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { 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<void> {
try {
await this.removeExpired();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Idempotency cleanup failed: ${message}`, stack);
}
}
async removeExpired(now: Date = new Date()): Promise<number> {
const cutoff = new Date(
now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
@@ -0,0 +1,76 @@
import { Prisma } from '../../generated/prisma';
import { loggableError } from './loggable-error';
// Prisma messages quote the query - titles, descriptions, names. None of
// that may reach the log; what identifies the failure is name, code, model.
describe('loggableError', () => {
const clientVersion = '7.9.1';
it('keeps message and stack of an ordinary error', () => {
const error = new Error('Graph createEvent fehlgeschlagen (503)');
expect(loggableError(error)).toEqual({
message: 'Graph createEvent fehlgeschlagen (503)',
stack: error.stack,
});
});
it('turns a thrown non-Error value into text without a stack', () => {
expect(loggableError('plain string')).toEqual({
message: 'plain string',
stack: undefined,
});
});
it('logs a known Prisma error by name, code and model only', () => {
const error = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on title "Max Mustermann"',
{
code: 'P2002',
clientVersion,
meta: { modelName: 'Task', target: ['title'] },
},
);
const logged = loggableError(error);
expect(logged).toEqual({
message: 'PrismaClientKnownRequestError P2002 (Task)',
stack: undefined,
});
});
it('leaves out the model when Prisma does not name one', () => {
const error = new Prisma.PrismaClientKnownRequestError('Mustermann', {
code: 'P2025',
clientVersion,
});
expect(loggableError(error).message).toBe(
'PrismaClientKnownRequestError P2025',
);
});
it('never logs the query of a validation error', () => {
const error = new Prisma.PrismaClientValidationError(
'Invalid `prisma.task.create()` invocation: { data: { title: "Max Mustermann" } }',
{ clientVersion },
);
expect(loggableError(error)).toEqual({
message: 'PrismaClientValidationError',
stack: undefined,
});
});
it.each([
new Prisma.PrismaClientUnknownRequestError('Mustermann', { clientVersion }),
new Prisma.PrismaClientRustPanicError('Mustermann', clientVersion),
new Prisma.PrismaClientInitializationError('Mustermann', clientVersion),
])('logs %s by name only', (error) => {
const logged = loggableError(error);
expect(logged.message).toBe(error.name);
expect(logged.stack).toBeUndefined();
});
});
@@ -0,0 +1,46 @@
import { Prisma } from '../../generated/prisma';
/** What of an error may go into the log. */
export interface LoggableError {
message: string;
stack: string | undefined;
}
const PRISMA_ERRORS = [
Prisma.PrismaClientKnownRequestError,
Prisma.PrismaClientUnknownRequestError,
Prisma.PrismaClientRustPanicError,
Prisma.PrismaClientInitializationError,
Prisma.PrismaClientValidationError,
];
function isPrismaError(error: unknown): error is Error {
return PRISMA_ERRORS.some((type) => error instanceof type);
}
function modelName(meta: Record<string, unknown> | undefined): string | null {
return typeof meta?.modelName === 'string' ? meta.modelName : null;
}
/**
* Prisma error messages quote the failing query - titles, descriptions,
* names, i.e. personal data - and so does the stack, whose first line is
* the message. A Prisma error is therefore logged by name, error code and
* model only. Any other error keeps message and stack for diagnosis.
*/
export function loggableError(error: unknown): LoggableError {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
const model = modelName(error.meta);
return {
message: `${error.name} ${error.code}${model ? ` (${model})` : ''}`,
stack: undefined,
};
}
if (isPrismaError(error)) {
return { message: error.name, stack: undefined };
}
if (error instanceof Error) {
return { message: error.message, stack: error.stack };
}
return { message: String(error), stack: undefined };
}
@@ -0,0 +1,15 @@
import { optionalDate } from './patch-values';
describe('optionalDate', () => {
it('leaves an omitted date untouched', () => {
expect(optionalDate(undefined)).toBeUndefined();
});
it('clears the date on null', () => {
expect(optionalDate(null)).toBeNull();
});
it('parses a given date', () => {
expect(optionalDate('2026-07-15')).toEqual(new Date('2026-07-15'));
});
});
@@ -0,0 +1,11 @@
// PATCH semantics for a nullable date column: an omitted field (undefined)
// stays as it is, null clears it. Null for a column that cannot hold it never
// gets this far - the update DTOs reject it (see IsOptionalNotNull).
export function optionalDate(
value: string | null | undefined,
): Date | null | undefined {
if (value === undefined) {
return undefined;
}
return value === null ? null : new Date(value);
}
@@ -0,0 +1,26 @@
import { ClientIpThrottlerGuard } from './client-ip-throttler.guard';
describe('ClientIpThrottlerGuard.getTracker', () => {
// getTracker is protected and needs none of the guard's dependencies.
const guard = Object.create(
ClientIpThrottlerGuard.prototype,
) as ClientIpThrottlerGuard & {
getTracker(req: Record<string, any>): Promise<string>;
};
it('tracks IPv6 clients by their /64 prefix', async () => {
await expect(guard.getTracker({ ip: '2001:db8:1:2::9' })).resolves.toBe(
'2001:db8:1:2::/64',
);
});
it('tracks IPv4 clients by address', async () => {
await expect(guard.getTracker({ ip: '203.0.113.7' })).resolves.toBe(
'203.0.113.7',
);
});
it('falls back to a stable key when req.ip is missing', async () => {
await expect(guard.getTracker({})).resolves.toBe('unknown');
});
});
@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { rateLimitSubject } from './rate-limit-subject';
/** ThrottlerGuard that counts per client network (IPv6 /64), not per address. */
@Injectable()
export class ClientIpThrottlerGuard extends ThrottlerGuard {
protected getTracker(req: Record<string, any>): Promise<string> {
const ip: unknown = req.ip;
return Promise.resolve(
typeof ip === 'string' && ip ? rateLimitSubject(ip) : 'unknown',
);
}
}
@@ -0,0 +1,56 @@
import { rateLimitSubject } from './rate-limit-subject';
describe('rateLimitSubject', () => {
it('returns IPv4 unchanged', () => {
expect(rateLimitSubject('203.0.113.7')).toBe('203.0.113.7');
});
it('unwraps IPv4-mapped IPv6 addresses', () => {
expect(rateLimitSubject('::ffff:203.0.113.7')).toBe('203.0.113.7');
expect(rateLimitSubject('::FFFF:203.0.113.7')).toBe('203.0.113.7');
});
it('reduces a compressed IPv6 address to its /64 prefix', () => {
expect(rateLimitSubject('2001:db8:1:2::5')).toBe('2001:db8:1:2::/64');
});
it('reduces a full-form IPv6 address to its /64 prefix', () => {
expect(rateLimitSubject('2001:0db8:0001:0002:aaaa:bbbb:cccc:dddd')).toBe(
'2001:db8:1:2::/64',
);
});
it('normalises uppercase', () => {
expect(rateLimitSubject('2001:DB8:1:2::ABCD')).toBe('2001:db8:1:2::/64');
});
it('handles :: at the start and for the unspecified address', () => {
expect(rateLimitSubject('::1')).toBe('0:0:0:0::/64');
expect(rateLimitSubject('::')).toBe('0:0:0:0::/64');
});
it('handles :: that swallows part of the prefix', () => {
expect(rateLimitSubject('2001:db8::1')).toBe('2001:db8:0:0::/64');
});
it('maps different interface identifiers in one /64 to the same key', () => {
expect(rateLimitSubject('2001:db8:1:2:1::1')).toBe(
rateLimitSubject('2001:db8:1:2:ffff:eeee:dddd:cccc'),
);
});
it('maps different /64 networks to different keys', () => {
expect(rateLimitSubject('2001:db8:1:2::1')).not.toBe(
rateLimitSubject('2001:db8:1:3::1'),
);
});
it('ignores an IPv6 zone id', () => {
expect(rateLimitSubject('fe80::1%eth0')).toBe('fe80:0:0:0::/64');
});
it('returns unparseable input unchanged', () => {
expect(rateLimitSubject('not-an-ip')).toBe('not-an-ip');
expect(rateLimitSubject('')).toBe('');
});
});
@@ -0,0 +1,47 @@
import { isIPv4, isIPv6 } from 'node:net';
const MAPPED_V4_PREFIX = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
const PREFIX_GROUPS = 4; // /64 = first four 16-bit groups
/** Expands an IPv6 address (no zone id) into its eight 16-bit groups. */
function expandIPv6(address: string): number[] {
let text = address;
// Embedded IPv4 tail (e.g. 64:ff9b::1.2.3.4) becomes two hex groups.
const tail = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
if (tail) {
const [a, b, c, d] = tail[1].split('.').map(Number);
const hi = ((a << 8) | b).toString(16);
const lo = ((c << 8) | d).toString(16);
text = `${text.slice(0, tail.index)}${hi}:${lo}`;
}
const [head, rest] = text.split('::');
const headGroups = head ? head.split(':') : [];
const restGroups = rest === undefined ? [] : rest ? rest.split(':') : [];
const missing = 8 - headGroups.length - restGroups.length;
const groups =
rest === undefined
? headGroups
: [...headGroups, ...Array<string>(missing).fill('0'), ...restGroups];
return groups.map((g) => parseInt(g, 16));
}
/**
* Rate-limit key for a client IP. IPv4 stays as is, IPv4-mapped IPv6 becomes
* plain IPv4, and IPv6 collapses to its /64 prefix: a single subscriber
* controls a whole /64, so keying on the full address would let it rotate
* addresses to dodge every limit. Unparseable input is returned unchanged.
*/
export function rateLimitSubject(ip: string): string {
const mapped = MAPPED_V4_PREFIX.exec(ip);
if (mapped && isIPv4(mapped[1])) {
return mapped[1];
}
const address = ip.split('%')[0];
if (!isIPv6(address)) {
return ip;
}
const prefix = expandIPv6(address)
.slice(0, PREFIX_GROUPS)
.map((g) => g.toString(16));
return `${prefix.join(':')}::/64`;
}
@@ -0,0 +1,13 @@
import { ValidateIf } from 'class-validator';
/**
* Like @IsOptional, but only an omitted field skips validation: null still
* runs the field's validators and is rejected. For fields whose column cannot
* hold null (required, or filled with a default when omitted) - null is a 400
* here instead of being silently ignored or failing the write with a 500.
* Update DTOs built with PartialType(..., { skipNullProperties: false }) get
* the same treatment for every field the create DTO requires.
*/
export function IsOptionalNotNull(): PropertyDecorator {
return ValidateIf((_object, value) => value !== undefined);
}
@@ -0,0 +1,369 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateApiaryDto } from '../../apiaries/dto/update-apiary.dto';
import { UpdateAutomationRuleDto } from '../../automation/dto/update-automation-rule.dto';
import { UpdateBackupSettingsDto } from '../../backup/dto/update-backup-settings.dto';
import { UpdateCalendarSyncSettingsDto } from '../../calendar-sync/dto/update-calendar-sync-settings.dto';
import { UpdateBottlingBatchDto } from '../../bottling/dto/update-bottling-batch.dto';
import { UpdateColonyDto } from '../../colonies/dto/update-colony.dto';
import { UpdateDocumentDto } from '../../documents/dto/update-document.dto';
import { UpdateEquipmentDto } from '../../equipment/dto/update-equipment.dto';
import { UpdateExtractionDto } from '../../extractions/dto/update-extraction.dto';
import { UpdateFeedingDto } from '../../feedings/dto/update-feeding.dto';
import { UpdateHarvestDto } from '../../harvests/dto/update-harvest.dto';
import { UpdateHoneyBatchDto } from '../../honey-batches/dto/update-honey-batch.dto';
import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto';
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
import { UpdateProductDto } from '../../products/dto/update-product.dto';
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
import { UpdateQueenEvaluationDto } from '../../queens/dto/update-queen-evaluation.dto';
import { UpdateQueenDto } from '../../queens/dto/update-queen.dto';
import { UpdateRawMaterialLotDto } from '../../raw-materials/dto/update-raw-material-lot.dto';
import { UpdateRoleDto } from '../../roles/dto/update-role.dto';
import { UpdateStockItemDto } from '../../storage/dto/update-stock-item.dto';
import { UpdateStorageContainerDto } from '../../storage/dto/update-storage-container.dto';
import { UpdateSwarmControlCheckDto } from '../../swarm-control/dto/update-swarm-control-check.dto';
import { UpdateTaskTemplateDto } from '../../task-templates/dto/update-task-template.dto';
import { UpdateTaskDto } from '../../tasks/dto/update-task.dto';
import { UpdateTreatmentDto } from '../../treatments/dto/update-treatment.dto';
import { UpdateUserDto } from '../../users/dto/update-user.dto';
import { UpdateVarroaMeasurementDto } from '../../varroa/dto/update-varroa-measurement.dto';
import { UpdateWebhookDto } from '../../webhooks/dto/update-webhook.dto';
import { UpdateWinterCheckDto } from '../../winter-checks/dto/update-winter-check.dto';
// PATCH contract of every update DTO: an omitted field stays unchanged, null
// clears a field whose column is nullable, and null on a column that cannot
// hold it is a 400 - never silently ignored, never a 500 from the write.
interface NullContract {
dto: new () => object;
notNull: string[];
clearable: string[];
}
const contracts: Record<string, NullContract> = {
UpdateApiaryDto: {
dto: UpdateApiaryDto,
notNull: ['name'],
clearable: [
'description',
'location',
'latitude',
'longitude',
'address',
'notes',
],
},
UpdateAutomationRuleDto: {
dto: UpdateAutomationRuleDto,
notNull: ['active'],
clearable: ['config', 'actionTemplate'],
},
UpdateBackupSettingsDto: {
dto: UpdateBackupSettingsDto,
notNull: [],
clearable: ['storagePath', 'cronSchedule', 'retentionCount'],
},
// groupId: null clears it, the same as the empty string.
UpdateCalendarSyncSettingsDto: {
dto: UpdateCalendarSyncSettingsDto,
notNull: [
'enabled',
'syncTask',
'syncInspection',
'syncTreatment',
'syncFeeding',
'syncHarvest',
'syncQueenAssignment',
],
clearable: ['groupId'],
},
UpdateBottlingBatchDto: {
dto: UpdateBottlingBatchDto,
notNull: [
'honeyBatchId',
'date',
'jarSize',
'jarCount',
'bestBeforeDate',
'lotNumber',
],
clearable: ['productId'],
},
UpdateColonyDto: {
dto: UpdateColonyDto,
notNull: ['apiaryId', 'code', 'colonyType', 'status'],
clearable: [
'hiveType',
'originText',
'strength',
'broodStatus',
'feedStatus',
'healthStatus',
'notes',
],
},
UpdateDocumentDto: {
dto: UpdateDocumentDto,
notNull: ['entityType', 'entityId', 'fileName'],
clearable: [],
},
UpdateEquipmentDto: {
dto: UpdateEquipmentDto,
notNull: ['inventoryNumber', 'name', 'condition'],
clearable: ['manufacturer', 'purchaseDate', 'location', 'maintenanceDate'],
},
UpdateExtractionDto: {
dto: UpdateExtractionDto,
notNull: ['date', 'harvestIds'],
clearable: ['amount', 'room', 'waterContent', 'temperature', 'notes'],
},
UpdateFeedingDto: {
dto: UpdateFeedingDto,
notNull: ['colonyId', 'date', 'feedType', 'amount', 'unit'],
clearable: ['product', 'batchNumber', 'notes'],
},
UpdateHarvestDto: {
dto: UpdateHarvestDto,
notNull: ['apiaryId', 'date', 'colonyIds'],
clearable: [
'honeyType',
'honeySuperCount',
'grossWeight',
'netWeight',
'notes',
],
},
UpdateHoneyBatchDto: {
dto: UpdateHoneyBatchDto,
notNull: ['batchNumber', 'extractionId', 'status'],
clearable: ['honeyType', 'amount', 'waterContent', 'storageContainerId'],
},
UpdateHoneyMeasurementDto: {
dto: UpdateHoneyMeasurementDto,
notNull: ['waterContent', 'measuredAt'],
clearable: ['device', 'temperature', 'notes'],
},
UpdateInspectionDto: {
dto: UpdateInspectionDto,
notNull: ['colonyId', 'date'],
clearable: [
'weather',
'temperature',
'colonyStrength',
'broodAmount',
'broodPattern',
'openBroodPresent',
'cappedBroodPresent',
'eggsPresent',
'queenSeen',
'queenCellsPresent',
'swarmMood',
'gentleness',
'combBehavior',
'feedStatus',
'honeySuperStatus',
'healthIssues',
'varroaIssues',
'measures',
'notes',
],
},
UpdateLabelTemplateDto: {
dto: UpdateLabelTemplateDto,
notNull: [
'name',
'elements',
'widthMm',
'heightMm',
'marginTopMm',
'marginLeftMm',
'gapXMm',
'gapYMm',
'cropMarks',
'isDefault',
],
clearable: ['columns', 'rows'],
},
UpdateProductDto: {
dto: UpdateProductDto,
notNull: ['name', 'status', 'kind', 'shopCategory'],
clearable: ['description', 'sku', 'packagingSize', 'weight', 'salesUnit'],
},
UpdateProductionBatchDto: {
dto: UpdateProductionBatchDto,
notNull: ['lotNumber', 'date', 'quantity'],
clearable: ['bestBeforeDate', 'notes'],
},
UpdateProfileDto: {
dto: UpdateProfileDto,
notNull: ['name', 'email'],
clearable: [],
},
UpdateQueenDto: {
dto: UpdateQueenDto,
notNull: ['code', 'status'],
clearable: [
'hatchYear',
'breed',
'origin',
'breeder',
'markingColor',
'markingNumber',
'matingType',
'matingStatus',
'inseminationData',
'motherLine',
'fatherLine',
'motherQueenId',
'notes',
],
},
UpdateQueenEvaluationDto: {
dto: UpdateQueenEvaluationDto,
notNull: [
'date',
'colonyStrength',
'gentleness',
'combBuild',
'swarmingTendency',
'broodPattern',
'honeyYield',
],
clearable: ['colonyId', 'notes'],
},
UpdateRawMaterialLotDto: {
dto: UpdateRawMaterialLotDto,
notNull: ['lotNumber', 'date', 'quantity'],
clearable: ['apiaryId', 'harvestId', 'notes'],
},
UpdateRoleDto: {
dto: UpdateRoleDto,
notNull: ['key', 'name', 'permissionKeys'],
clearable: ['description'],
},
UpdateStockItemDto: {
dto: UpdateStockItemDto,
notNull: ['name', 'category', 'unit', 'quantity'],
clearable: ['minThreshold', 'notes'],
},
UpdateStorageContainerDto: {
dto: UpdateStorageContainerDto,
notNull: ['code'],
clearable: ['containerType', 'capacity', 'location'],
},
UpdateSwarmControlCheckDto: {
dto: UpdateSwarmControlCheckDto,
notNull: [
'colonyId',
'date',
'queenCellsCapped',
'swarmMood',
'actionTaken',
],
clearable: ['queenCellsFound', 'notes', 'nextCheckDate'],
},
UpdateTaskDto: {
dto: UpdateTaskDto,
notNull: ['title', 'priority', 'status', 'relatedType'],
clearable: ['description', 'dueDate', 'assignedUserId', 'relatedId'],
},
UpdateTaskTemplateDto: {
dto: UpdateTaskTemplateDto,
notNull: ['title', 'priority', 'recurrenceType', 'active'],
clearable: [
'description',
'assignedUserId',
'recurrenceMonth',
'recurrenceDayOfMonth',
'intervalDays',
],
},
UpdateTreatmentDto: {
dto: UpdateTreatmentDto,
notNull: ['colonyId', 'date', 'product'],
clearable: [
'startDate',
'endDate',
'activeIngredient',
'amount',
'method',
'reason',
'batchNumber',
'supplier',
'purchaseProof',
'prescribingVet',
'notes',
'withdrawalPeriodDays',
],
},
UpdateUserDto: {
dto: UpdateUserDto,
notNull: ['name', 'email', 'roleId'],
clearable: [],
},
UpdateVarroaMeasurementDto: {
dto: UpdateVarroaMeasurementDto,
notNull: ['colonyId', 'date', 'method'],
clearable: [
'durationMinutes',
'miteCount',
'calculatedValue',
'assessment',
'notes',
],
},
UpdateWebhookDto: {
dto: UpdateWebhookDto,
notNull: ['url', 'events', 'active'],
clearable: [],
},
UpdateWinterCheckDto: {
dto: UpdateWinterCheckDto,
notNull: ['colonyId', 'season', 'date'],
clearable: [
'occupiedSeams',
'feedStockKg',
'queenPresent',
'outcome',
'lossCause',
'notes',
],
},
};
async function errorPropertiesFor(
dto: new () => object,
body: Record<string, unknown>,
): Promise<string[]> {
const errors = await validate(plainToInstance(dto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
describe('update DTO null handling', () => {
describe.each(Object.entries(contracts))('%s', (_name, contract) => {
it('accepts an empty patch', async () => {
await expect(errorPropertiesFor(contract.dto, {})).resolves.toEqual([]);
});
if (contract.notNull.length > 0) {
it.each(contract.notNull)('rejects null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([field]);
});
}
if (contract.clearable.length > 0) {
it.each(contract.clearable)('accepts null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([]);
});
}
});
});
@@ -108,6 +108,34 @@ describe('DashboardService', () => {
expect(alerts).toHaveLength(0);
});
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
@@ -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: {
@@ -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;
@@ -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;
}
@@ -0,0 +1,85 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
MAX_PAGE_SIZE,
} from './erp-query.dto';
async function errorsOf<T extends object>(
cls: new () => T,
query: Record<string, string>,
): Promise<string[]> {
const errors = await validate(plainToInstance(cls, query), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
describe('ERP-Abfrageparameter', () => {
it('nimmt eine leere Abfrage an', async () => {
expect(await errorsOf(ErpPageQueryDto, {})).toEqual([]);
expect(await errorsOf(ErpMovementsQueryDto, {})).toEqual([]);
expect(await errorsOf(ErpHarvestsQueryDto, {})).toEqual([]);
});
it('wandelt limit in eine Zahl um', () => {
const dto = plainToInstance(ErpPageQueryDto, { limit: '25' });
expect(dto.limit).toBe(25);
});
it.each(['0', '-1', String(MAX_PAGE_SIZE + 1), '2.5', 'viele'])(
'lehnt limit=%s ab',
async (limit) => {
expect(await errorsOf(ErpPageQueryDto, { limit })).toEqual(['limit']);
},
);
it('nimmt limit an der Obergrenze an', async () => {
expect(
await errorsOf(ErpPageQueryDto, { limit: String(MAX_PAGE_SIZE) }),
).toEqual([]);
});
it.each(["abc'def", 'a b', 'a'.repeat(513), 'abc=='])(
'lehnt den Cursor %s ab',
async (nach) => {
expect(await errorsOf(ErpPageQueryDto, { nach })).toEqual(['nach']);
},
);
it('nimmt einen ISO-Zeitpunkt für seit an', async () => {
expect(
await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01T00:00:00Z' }),
).toEqual([]);
expect(
await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01' }),
).toEqual([]);
});
it.each(['gestern', '2026-13-01', '01.01.2026'])(
'lehnt seit=%s ab',
async (seit) => {
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual(['seit']);
},
);
it.each(['1899', '2101', '2026.5', 'dieses'])(
'lehnt jahr=%s ab',
async (jahr) => {
expect(await errorsOf(ErpHarvestsQueryDto, { jahr })).toEqual(['jahr']);
},
);
it('wandelt jahr in eine Zahl um', () => {
expect(plainToInstance(ErpHarvestsQueryDto, { jahr: '2026' }).jahr).toBe(
2026,
);
});
it('lehnt unbekannte Parameter ab', async () => {
expect(await errorsOf(ErpPageQueryDto, { seite: '2' })).toEqual(['seite']);
});
});
@@ -0,0 +1,68 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsISO8601,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
export const DEFAULT_PAGE_SIZE = 100;
export const MAX_PAGE_SIZE = 500;
const MAX_CURSOR_LENGTH = 512;
/** Seitenweises Lesen: `nach` = nextCursor der vorigen Seite, `limit` = Seitengröße. */
export class ErpPageQueryDto {
@ApiPropertyOptional({
description:
'nextCursor der vorigen Antwort, unverändert. Ohne Angabe beginnt die Liste von vorn.',
})
@IsOptional()
@IsString()
@MaxLength(MAX_CURSOR_LENGTH)
@Matches(/^[A-Za-z0-9_-]+$/, {
message: 'nach muss ein Cursor aus einer vorigen Antwort sein',
})
nach?: string;
@ApiPropertyOptional({
description: `Einträge je Seite, 1 bis ${MAX_PAGE_SIZE}`,
default: DEFAULT_PAGE_SIZE,
minimum: 1,
maximum: MAX_PAGE_SIZE,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_PAGE_SIZE)
limit?: number;
}
export class ErpMovementsQueryDto extends ErpPageQueryDto {
@ApiPropertyOptional({
description:
'Nur Bewegungen ab diesem Zeitpunkt (ISO 8601, einschließlich; UTC mit "Z" empfohlen). Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".',
example: '2026-01-01T00:00:00Z',
})
@IsOptional()
@IsISO8601({ strict: true })
seit?: string;
}
export class ErpHarvestsQueryDto {
@ApiPropertyOptional({
description: 'Nur dieses Kalenderjahr; ohne Angabe alle Jahre',
example: 2026,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1900)
@Max(2100)
jahr?: number;
}
@@ -0,0 +1,93 @@
import { BadRequestException } from '@nestjs/common';
import { decodeCursor, encodeCursor } from './erp-cursor';
function raw(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
describe('ERP-Cursor', () => {
it('liefert jeden Cursor unverändert zurück (Rundreise)', () => {
const product = { k: 'produkte', id: 'cmabc123' } as const;
const batch = { k: 'chargen', t: 'P', id: 'cmdef456' } as const;
const movement = {
k: 'bewegungen',
ts: '2026-10-01T08:15:30.123Z',
id: 'cmghi789',
} as const;
expect(decodeCursor(encodeCursor(product), 'produkte')).toEqual(product);
expect(decodeCursor(encodeCursor(batch), 'chargen')).toEqual(batch);
expect(decodeCursor(encodeCursor(movement), 'bewegungen')).toEqual(
movement,
);
});
it('ist URL-sicher (base64url ohne Auffüllzeichen)', () => {
const cursor = encodeCursor({
k: 'bewegungen',
ts: '2026-10-01T08:15:30.123Z',
id: 'cmghi789',
});
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('lehnt einen Cursor eines anderen Endpunkts ab', () => {
const productCursor = encodeCursor({ k: 'produkte', id: 'cmabc123' });
expect(() => decodeCursor(productCursor, 'bewegungen')).toThrow(
BadRequestException,
);
});
it.each([
['kein base64/JSON', 'produkte', 'nicht-json'],
['JSON, aber kein Objekt', 'produkte', raw([1, 2])],
['fehlende id', 'produkte', raw({ k: 'produkte' })],
[
'id mit fremden Zeichen',
'produkte',
raw({ k: 'produkte', id: "x' OR 1" }),
],
['id zu lang', 'produkte', raw({ k: 'produkte', id: 'a'.repeat(65) })],
['zusätzliche Felder', 'produkte', raw({ k: 'produkte', id: 'c1', x: 1 })],
[
'unbekannte Chargenart',
'chargen',
raw({ k: 'chargen', t: 'X', id: 'c1' }),
],
[
'ungültiger Zeitstempel',
'bewegungen',
raw({ k: 'bewegungen', ts: 'gestern', id: 'c1' }),
],
[
'Zeitstempel nicht kanonisch',
'bewegungen',
raw({ k: 'bewegungen', ts: '2026-10-01', id: 'c1' }),
],
[
'Jahr jenseits des Datenbankbereichs',
'bewegungen',
raw({ k: 'bewegungen', ts: '+275760-09-13T00:00:00.000Z', id: 'c1' }),
],
[
'Jahr vor 2000',
'bewegungen',
raw({ k: 'bewegungen', ts: '1999-12-31T23:59:59.999Z', id: 'c1' }),
],
[
'Jahr nach 2100',
'bewegungen',
raw({ k: 'bewegungen', ts: '2101-01-01T00:00:00.000Z', id: 'c1' }),
],
] as const)('lehnt ab: %s', (_label, kind, cursor) => {
expect(() => decodeCursor(cursor, kind)).toThrow(BadRequestException);
});
it.each(['2000-01-01T00:00:00.000Z', '2100-12-31T23:59:59.999Z'])(
'nimmt den Rand des erlaubten Bereichs an: %s',
(ts) => {
const cursor = encodeCursor({ k: 'bewegungen', ts, id: 'c1' });
expect(decodeCursor(cursor, 'bewegungen').ts).toBe(ts);
},
);
});
@@ -0,0 +1,94 @@
import { BadRequestException } from '@nestjs/common';
/**
* Blätter-Cursor des ERP-Exports: die Position hinter dem letzten gelieferten
* Eintrag, als base64url-kodiertes JSON. Für den Aufrufer undurchsichtig - er
* reicht ihn nur unverändert als `nach` zurück.
*
* Jeder Cursor nennt seinen Endpunkt (`k`): ein Produkt-Cursor an den
* Bewegungen wäre sonst stillschweigend eine falsche Position. Was hier
* herauskommt, landet in einer Datenbankabfrage, deshalb wird jedes Feld
* streng geprüft statt nur gecastet.
*/
export type ErpCursor =
| { k: 'produkte'; id: string }
| { k: 'chargen'; t: 'B' | 'P'; id: string }
| { k: 'bewegungen'; ts: string; id: string };
export type ErpCursorKind = ErpCursor['k'];
type CursorOf<K extends ErpCursorKind> = Extract<ErpCursor, { k: K }>;
// cuid-artige Kennungen; großzügig, aber ohne Zeichen, die in einer Abfrage
// etwas anderes bedeuten könnten.
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
// Zeitstempel eines Bewegungs-Cursors: nur plausible Jahre. JavaScript
// kennt Jahre bis +275760, die Datenbankabfrage nicht - das wäre ein 500.
const MIN_CURSOR_YEAR = 2000;
const MAX_CURSOR_YEAR = 2100;
const FIELDS: Record<ErpCursorKind, readonly string[]> = {
produkte: ['id', 'k'],
chargen: ['id', 'k', 't'],
bewegungen: ['id', 'k', 'ts'],
};
function invalid(): BadRequestException {
return new BadRequestException(
'Ungültiger Cursor - bitte nextCursor der vorigen Seite unverändert als "nach" übergeben',
);
}
export function encodeCursor(cursor: ErpCursor): string {
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
}
function parse(raw: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
throw invalid();
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw invalid();
}
return value as Record<string, unknown>;
}
function isCanonicalTimestamp(value: unknown): value is string {
if (typeof value !== 'string') return false;
const date = new Date(value);
if (Number.isNaN(date.getTime()) || date.toISOString() !== value) {
return false;
}
const year = date.getUTCFullYear();
return year >= MIN_CURSOR_YEAR && year <= MAX_CURSOR_YEAR;
}
/** Prüft den Cursor für genau diesen Endpunkt; sonst 400. */
export function decodeCursor<K extends ErpCursorKind>(
raw: string,
kind: K,
): CursorOf<K> {
const value = parse(raw);
const keys = Object.keys(value).sort();
const expected = FIELDS[kind];
if (
value.k !== kind ||
keys.length !== expected.length ||
keys.some((key, index) => key !== expected[index]) ||
typeof value.id !== 'string' ||
!ID_PATTERN.test(value.id)
) {
throw invalid();
}
if (kind === 'chargen' && value.t !== 'B' && value.t !== 'P') {
throw invalid();
}
if (kind === 'bewegungen' && !isCanonicalTimestamp(value.ts)) {
throw invalid();
}
return value as CursorOf<K>;
}
@@ -0,0 +1,82 @@
import { Controller, Get, Query } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiForbiddenResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiTooManyRequestsResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import {
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
} from './dto/erp-query.dto';
import {
ErpBatch,
ErpExportService,
ErpHarvestYears,
ErpMovementPage,
ErpPage,
ErpProduct,
} from './erp-export.service';
/**
* Inventar-Spiegel für ERPNext, nur lesend. Vertrag mit Feldern, Einheiten,
* Paginierung und Fehlercodes: docs/erp-inventar-export.md.
*/
@ApiTags('erp-export')
@ApiBearerAuth()
@ApiUnauthorizedResponse({
description: 'Kein, unbekannter, widerrufener oder abgelaufener Schlüssel',
})
@ApiForbiddenResponse({
description:
'Schlüssel ohne erp-inventory:read oder von einer nicht freigegebenen IP-Adresse',
})
@ApiTooManyRequestsResponse({
description: 'Zu viele Anfragen oder Fehlversuche von dieser Adresse',
})
@ApiBadRequestResponse({ description: 'Ungültiger Parameter oder Cursor' })
@RequirePermission('erp-inventory:read')
@Controller('export/inventar')
export class ErpExportController {
constructor(private readonly erpExport: ErpExportService) {}
@ApiOperation({ summary: 'Artikel (ApiCore-Produkte), nach id sortiert' })
@ApiOkResponse({ description: 'Eine Seite Artikel' })
@Get('produkte')
products(@Query() query: ErpPageQueryDto): Promise<ErpPage<ErpProduct>> {
return this.erpExport.products(query);
}
@ApiOperation({
summary:
'Chargen (Abfüllungen, dann Herstellungschargen) mit Ausgangsmenge und Rest',
})
@ApiOkResponse({ description: 'Eine Seite Chargen' })
@Get('chargen')
batches(@Query() query: ErpPageQueryDto): Promise<ErpPage<ErpBatch>> {
return this.erpExport.batches(query);
}
@ApiOperation({
summary:
'Bestandsbewegungen (Shop-Verkauf, Retoure, Entnahme mit Grund, Inventur), älteste zuerst',
})
@ApiOkResponse({ description: 'Eine Seite Bewegungen' })
@Get('bewegungen')
movements(@Query() query: ErpMovementsQueryDto): Promise<ErpMovementPage> {
return this.erpExport.movements(query);
}
@ApiOperation({ summary: 'Geerntete kg (netto) je Kalenderjahr' })
@ApiOkResponse({ description: 'Erntemengen je Jahr' })
@Get('ernten')
harvests(@Query() query: ErpHarvestsQueryDto): Promise<ErpHarvestYears> {
return this.erpExport.harvestYears(query);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { ErpExportController } from './erp-export.controller';
import { ErpExportService } from './erp-export.service';
@Module({
imports: [StockModule],
controllers: [ErpExportController],
providers: [ErpExportService],
})
export class ErpExportModule {}
@@ -0,0 +1,524 @@
import { BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { StockService } from '../stock/stock.service';
import { decodeCursor, encodeCursor } from './erp-cursor';
import {
ERP_INVENTORY_CONTRACT_VERSION,
ErpExportService,
MOVEMENT_SETTLE_MS,
} from './erp-export.service';
type FindManyArgs = {
where?: Record<string, unknown>;
select?: Record<string, unknown>;
orderBy?: unknown;
take?: number;
};
const NOW = new Date('2026-10-12T02:00:00.000Z');
function bottlingRow(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
lotNumber: `L-${id}`,
productId: 'prod-honig',
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
jarCount: 60,
jarSize: 500,
honeyBatch: { honeyType: 'Sommertracht' },
...overrides,
};
}
function productionRow(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
lotNumber: `K-${id}`,
productId: 'prod-kerze',
date: new Date('2026-09-01T00:00:00.000Z'),
bestBeforeDate: null,
quantity: 20,
...overrides,
};
}
function movementRow(
id: string,
createdAt: string,
overrides: Record<string, unknown> = {},
) {
return {
id,
createdAt: new Date(createdAt),
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -2,
bottlingBatchId: 'b1',
productionBatchId: null,
bottlingBatch: { lotNumber: 'L-b1', productId: 'prod-honig' },
productionBatch: null,
...overrides,
};
}
describe('ErpExportService', () => {
let prisma: {
product: { findMany: jest.Mock };
bottlingBatch: { findMany: jest.Mock };
productionBatch: { findMany: jest.Mock };
batchMovement: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
};
let stock: {
remainingByBottling: jest.Mock;
remainingByProductionBatch: jest.Mock;
};
let service: ErpExportService;
beforeEach(() => {
jest.useFakeTimers().setSystemTime(NOW);
prisma = {
product: { findMany: jest.fn().mockResolvedValue([]) },
bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) },
productionBatch: { findMany: jest.fn().mockResolvedValue([]) },
batchMovement: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
};
stock = {
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
remainingByProductionBatch: jest.fn().mockResolvedValue(new Map()),
};
service = new ErpExportService(
prisma as unknown as PrismaService,
stock as unknown as StockService,
);
});
afterEach(() => {
jest.useRealTimers();
});
function lastArgs(mock: jest.Mock): FindManyArgs {
const calls = mock.mock.calls as FindManyArgs[][];
return calls[calls.length - 1][0];
}
describe('products', () => {
it('liest nur die freigegebenen Felder, stabil nach id sortiert', async () => {
prisma.product.findMany.mockResolvedValueOnce([
{
id: 'p1',
name: 'Sommertracht 500 g',
sku: 'WH-500',
kind: 'BOTTLED_HONEY',
shopCategory: 'HONIG',
status: 'ACTIVE',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
updatedAt: new Date('2026-09-01T00:00:00.000Z'),
},
]);
const page = await service.products({});
const args = lastArgs(prisma.product.findMany);
expect(Object.keys(args.select ?? {}).sort()).toEqual(
[
'id',
'kind',
'name',
'packagingSize',
'salesUnit',
'shopCategory',
'sku',
'status',
'updatedAt',
'weight',
].sort(),
);
// Die Beschreibung ist in ApiCore eine interne Notiz.
expect(args.select).not.toHaveProperty('description');
expect(args.orderBy).toEqual({ id: 'asc' });
expect(args.take).toBe(101);
expect(page).toMatchObject({
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
hasMore: false,
items: [{ id: 'p1', name: 'Sommertracht 500 g', sku: 'WH-500' }],
});
expect(decodeCursor(page.nextCursor!, 'produkte')).toEqual({
k: 'produkte',
id: 'p1',
});
});
it('blättert mit Cursor und meldet weitere Seiten', async () => {
prisma.product.findMany.mockResolvedValueOnce([
{ id: 'p3' },
{ id: 'p4' },
{ id: 'p5' },
]);
const page = await service.products({
limit: 2,
nach: encodeCursor({ k: 'produkte', id: 'p2' }),
});
const args = lastArgs(prisma.product.findMany);
expect(args.where).toEqual({ id: { gt: 'p2' } });
expect(args.take).toBe(3);
expect(page.items.map((p) => p.id)).toEqual(['p3', 'p4']);
expect(page.hasMore).toBe(true);
expect(decodeCursor(page.nextCursor!, 'produkte').id).toBe('p4');
});
it('gibt bei leerer Seite den übergebenen Cursor zurück', async () => {
const nach = encodeCursor({ k: 'produkte', id: 'p9' });
const page = await service.products({ nach });
expect(page).toMatchObject({
items: [],
hasMore: false,
nextCursor: nach,
});
});
it('lehnt einen fremden Cursor ab', async () => {
await expect(
service.products({
nach: encodeCursor({ k: 'chargen', t: 'B', id: 'b1' }),
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.product.findMany).not.toHaveBeenCalled();
});
});
describe('batches', () => {
it('liefert Abfüllungen vor Herstellungschargen, mit Rest aus dem Bewegungsbuch', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([
bottlingRow('b1'),
bottlingRow('b2', { productId: null, honeyBatch: { honeyType: null } }),
]);
prisma.productionBatch.findMany.mockResolvedValueOnce([
productionRow('k1', { bestBeforeDate: new Date('2027-09-01') }),
]);
stock.remainingByBottling.mockResolvedValueOnce(
new Map([
['b1', 41],
['b2', 0],
]),
);
stock.remainingByProductionBatch.mockResolvedValueOnce(
new Map([['k1', 17]]),
);
const page = await service.batches({ limit: 10 });
expect(page.items).toEqual([
{
id: 'b1',
batchType: 'BOTTLING',
lotNumber: 'L-b1',
productId: 'prod-honig',
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
initialQuantity: 60,
remainingQuantity: 41,
fillWeightGrams: 500,
honeyType: 'Sommertracht',
},
{
id: 'b2',
batchType: 'BOTTLING',
lotNumber: 'L-b2',
productId: null,
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
initialQuantity: 60,
remainingQuantity: 0,
fillWeightGrams: 500,
honeyType: null,
},
{
id: 'k1',
batchType: 'PRODUCTION',
lotNumber: 'K-k1',
productId: 'prod-kerze',
date: new Date('2026-09-01T00:00:00.000Z'),
bestBeforeDate: new Date('2027-09-01'),
initialQuantity: 20,
remainingQuantity: 17,
fillWeightGrams: null,
honeyType: null,
},
]);
expect(stock.remainingByBottling).toHaveBeenCalledWith(['b1', 'b2']);
expect(stock.remainingByProductionBatch).toHaveBeenCalledWith(['k1']);
expect(page.hasMore).toBe(false);
expect(decodeCursor(page.nextCursor!, 'chargen')).toEqual({
k: 'chargen',
t: 'P',
id: 'k1',
});
});
it('liest keine Notizen und keine Benutzer', async () => {
await service.batches({});
for (const mock of [
prisma.bottlingBatch.findMany,
prisma.productionBatch.findMany,
]) {
const select = lastArgs(mock).select ?? {};
expect(select).not.toHaveProperty('notes');
expect(select).not.toHaveProperty('user');
expect(select).not.toHaveProperty('userId');
}
});
it('fragt Herstellungschargen nicht ab, solange die Seite mit Abfüllungen voll ist', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([
bottlingRow('b1'),
bottlingRow('b2'),
bottlingRow('b3'),
]);
const page = await service.batches({ limit: 2 });
expect(prisma.productionBatch.findMany).not.toHaveBeenCalled();
expect(page.items.map((b) => b.id)).toEqual(['b1', 'b2']);
expect(page.hasMore).toBe(true);
expect(decodeCursor(page.nextCursor!, 'chargen')).toMatchObject({
t: 'B',
id: 'b2',
});
});
it('füllt den Rest der Seite mit Herstellungschargen und erkennt weitere', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([bottlingRow('b9')]);
prisma.productionBatch.findMany.mockResolvedValueOnce([
productionRow('k1'),
productionRow('k2'),
]);
const page = await service.batches({
limit: 2,
nach: encodeCursor({ k: 'chargen', t: 'B', id: 'b8' }),
});
expect(lastArgs(prisma.bottlingBatch.findMany).where).toEqual({
id: { gt: 'b8' },
});
// Nach den Abfüllungen beginnen die Herstellungschargen von vorn.
expect(lastArgs(prisma.productionBatch.findMany).where).toBeUndefined();
expect(lastArgs(prisma.productionBatch.findMany).take).toBe(2);
expect(page.items.map((b) => b.id)).toEqual(['b9', 'k1']);
expect(page.hasMore).toBe(true);
});
it('setzt hinter einer Herstellungscharge fort, ohne Abfüllungen erneut zu lesen', async () => {
prisma.productionBatch.findMany.mockResolvedValueOnce([
productionRow('k3'),
]);
const page = await service.batches({
nach: encodeCursor({ k: 'chargen', t: 'P', id: 'k2' }),
});
expect(prisma.bottlingBatch.findMany).not.toHaveBeenCalled();
expect(lastArgs(prisma.productionBatch.findMany).where).toEqual({
id: { gt: 'k2' },
});
expect(page.items.map((b) => b.id)).toEqual(['k3']);
});
it('nimmt die Ausgangsmenge als Rest, wenn das Bewegungsbuch nichts liefert', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([bottlingRow('b1')]);
const page = await service.batches({});
expect(page.items[0].remainingQuantity).toBe(60);
});
});
describe('movements', () => {
const settledUntil = new Date(NOW.getTime() - MOVEMENT_SETTLE_MS);
it('liefert nur Bewegungen von Abfüllungen und Herstellungschargen, die älter als die Wartezeit sind', async () => {
await service.movements({});
const args = lastArgs(prisma.batchMovement.findMany);
expect(args.where).toEqual({
AND: [
{
OR: [
{ bottlingBatchId: { not: null } },
{ productionBatchId: { not: null } },
],
},
{ createdAt: { lte: settledUntil } },
],
});
expect(args.orderBy).toEqual([{ createdAt: 'asc' }, { id: 'asc' }]);
expect(args.take).toBe(101);
});
it('liest weder Notizen noch Benutzer noch Shop-Anfragen', async () => {
await service.movements({});
const select = lastArgs(prisma.batchMovement.findMany).select ?? {};
for (const field of [
'notes',
'user',
'userId',
'apiKeyId',
'shopRequest',
'shopRequestId',
'rawMaterialLotId',
]) {
expect(select).not.toHaveProperty(field);
}
});
it('bildet Bewegungen auf den Vertrag ab', async () => {
prisma.batchMovement.findMany.mockResolvedValueOnce([
movementRow('m1', '2026-10-01T10:00:00.000Z'),
movementRow('m2', '2026-10-01T11:00:00.000Z', {
type: 'SHOP_SALE',
reason: null,
quantity: -3,
bottlingBatchId: null,
productionBatchId: 'k1',
bottlingBatch: null,
productionBatch: { lotNumber: 'K-k1', productId: 'prod-kerze' },
}),
]);
const page = await service.movements({});
expect(page.items).toEqual([
{
id: 'm1',
createdAt: new Date('2026-10-01T10:00:00.000Z'),
batchType: 'BOTTLING',
batchId: 'b1',
lotNumber: 'L-b1',
productId: 'prod-honig',
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -2,
},
{
id: 'm2',
createdAt: new Date('2026-10-01T11:00:00.000Z'),
batchType: 'PRODUCTION',
batchId: 'k1',
lotNumber: 'K-k1',
productId: 'prod-kerze',
type: 'SHOP_SALE',
reason: null,
quantity: -3,
},
]);
expect(page.settledUntil).toEqual(settledUntil);
expect(decodeCursor(page.nextCursor!, 'bewegungen')).toEqual({
k: 'bewegungen',
ts: '2026-10-01T11:00:00.000Z',
id: 'm2',
});
});
it('beginnt mit "seit" einschließlich des Zeitpunkts', async () => {
await service.movements({ seit: '2026-01-01T00:00:00.000Z' });
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
expect(where.AND[1]).toEqual({
createdAt: {
lte: settledUntil,
gte: new Date('2026-01-01T00:00:00.000Z'),
},
});
});
it('setzt hinter dem Cursor fort (Zeitpunkt, dann id)', async () => {
const nach = encodeCursor({
k: 'bewegungen',
ts: '2026-10-01T11:00:00.000Z',
id: 'm2',
});
const page = await service.movements({ nach });
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
const ts = new Date('2026-10-01T11:00:00.000Z');
expect(where.AND[2]).toEqual({
OR: [{ createdAt: { gt: ts } }, { createdAt: ts, id: { gt: 'm2' } }],
});
// Nichts Neues: der Cursor bleibt, der Abruf in der nächsten Nacht setzt dort fort.
expect(page).toMatchObject({
items: [],
hasMore: false,
nextCursor: nach,
});
});
it('meldet weitere Seiten', async () => {
prisma.batchMovement.findMany.mockResolvedValueOnce([
movementRow('m1', '2026-10-01T10:00:00.000Z'),
movementRow('m2', '2026-10-01T10:00:00.000Z'),
]);
const page = await service.movements({ limit: 1 });
expect(page.items.map((m) => m.id)).toEqual(['m1']);
expect(page.hasMore).toBe(true);
});
it('lehnt "seit" und "nach" zusammen ab', async () => {
await expect(
service.movements({
seit: '2026-01-01T00:00:00.000Z',
nach: encodeCursor({
k: 'bewegungen',
ts: '2026-10-01T11:00:00.000Z',
id: 'm2',
}),
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.batchMovement.findMany).not.toHaveBeenCalled();
});
});
describe('harvestYears', () => {
it('liest für ein Jahr nur Datum und Nettogewicht in den UTC-Jahresgrenzen', async () => {
prisma.harvest.findMany.mockResolvedValueOnce([
{ date: new Date('2026-07-14T00:00:00.000Z'), netWeight: 41 },
{ date: new Date('2026-08-02T00:00:00.000Z'), netWeight: null },
]);
const result = await service.harvestYears({ jahr: 2026 });
const args = lastArgs(prisma.harvest.findMany);
expect(args.select).toEqual({ date: true, netWeight: true });
expect(args.where).toEqual({
date: {
gte: new Date('2026-01-01T00:00:00.000Z'),
lt: new Date('2027-01-01T00:00:00.000Z'),
},
});
expect(result).toEqual({
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: NOW,
years: [
{
year: 2026,
netWeightKg: 41,
harvestCount: 2,
harvestsWithoutNetWeight: 1,
},
],
});
});
it('liefert ohne Jahr alle Jahre', async () => {
await service.harvestYears({});
expect(lastArgs(prisma.harvest.findMany).where).toBeUndefined();
});
});
});
@@ -0,0 +1,423 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { StockService } from '../stock/stock.service';
import {
BatchMovementType,
Prisma,
ProductKind,
ProductStatus,
ShopCategory,
WithdrawalReason,
} from '../../generated/prisma';
import { decodeCursor, encodeCursor, type ErpCursor } from './erp-cursor';
import {
DEFAULT_PAGE_SIZE,
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
} from './dto/erp-query.dto';
import { summarizeHarvestYears, type HarvestYear } from './harvest-years';
/**
* Erhöht nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in
* der Bedeutung geändert). Neue Felder kommen ohne Versionswechsel dazu; der
* Importer ignoriert Unbekanntes. Vertrag: docs/erp-inventar-export.md.
*/
export const ERP_INVENTORY_CONTRACT_VERSION = 1;
/**
* Bewegungen erscheinen erst, wenn sie so alt sind. Eine Buchung bekommt
* ihren Zeitstempel beim Einfügen, sichtbar wird sie aber erst beim Commit
* ihrer Transaktion. Ohne Wartezeit könnte ein Abruf eine spätere Buchung
* schon sehen und den Cursor über eine frühere, noch offene hinwegschieben -
* die frühere ginge dem Spiegel dann für immer verloren.
*/
export const MOVEMENT_SETTLE_MS = 5 * 60 * 1000;
export type ErpBatchType = 'BOTTLING' | 'PRODUCTION';
export interface ErpPage<T> {
contractVersion: number;
generatedAt: Date;
items: T[];
/** Position hinter dem letzten Eintrag; bei leerer Seite der übergebene Cursor. */
nextCursor: string | null;
hasMore: boolean;
}
export interface ErpProduct {
id: string;
name: string;
sku: string | null;
kind: ProductKind;
shopCategory: ShopCategory;
status: ProductStatus;
packagingSize: number | null;
weight: number | null;
salesUnit: string | null;
updatedAt: Date;
}
export interface ErpBatch {
id: string;
batchType: ErpBatchType;
lotNumber: string;
productId: string | null;
/** Abfüll- bzw. Herstelldatum. */
date: Date;
bestBeforeDate: Date | null;
/** Abgefüllte Gläser bzw. hergestellte Stück. */
initialQuantity: number;
/** Ausgangsmenge plus Summe aller Bewegungen; kann negativ sein (zu prüfen). */
remainingQuantity: number;
/** Füllmenge je Glas in Gramm; nur bei Abfüllungen. */
fillWeightGrams: number | null;
/** Sorte der Honigcharge hinter der Abfüllung; nur bei Abfüllungen. */
honeyType: string | null;
}
export interface ErpMovement {
id: string;
createdAt: Date;
batchType: ErpBatchType;
batchId: string;
lotNumber: string;
productId: string | null;
type: BatchMovementType;
reason: WithdrawalReason | null;
/** Stück mit Vorzeichen: minus = aus dem Bestand, plus = zurück. */
quantity: number;
}
export interface ErpMovementPage extends ErpPage<ErpMovement> {
/** Bis hierhin ist die Liste vollständig (jetzt minus Wartezeit). */
settledUntil: Date;
}
export interface ErpHarvestYears {
contractVersion: number;
generatedAt: Date;
years: HarvestYear[];
}
// Feld-Allowlists: nur was hier steht, verlässt ApiCore. Notizen, Benutzer,
// Beschreibungen (in ApiCore interne Notizen) und alles, was später an die
// Modelle kommt, bleiben drin.
const PRODUCT_SELECT = {
id: true,
name: true,
sku: true,
kind: true,
shopCategory: true,
status: true,
packagingSize: true,
weight: true,
salesUnit: true,
updatedAt: true,
} satisfies Prisma.ProductSelect;
const BOTTLING_SELECT = {
id: true,
lotNumber: true,
productId: true,
date: true,
bestBeforeDate: true,
jarCount: true,
jarSize: true,
honeyBatch: { select: { honeyType: true } },
} satisfies Prisma.BottlingBatchSelect;
const PRODUCTION_SELECT = {
id: true,
lotNumber: true,
productId: true,
date: true,
bestBeforeDate: true,
quantity: true,
} satisfies Prisma.ProductionBatchSelect;
const BATCH_REF_SELECT = { select: { lotNumber: true, productId: true } };
const MOVEMENT_SELECT = {
id: true,
createdAt: true,
type: true,
reason: true,
quantity: true,
bottlingBatchId: true,
productionBatchId: true,
bottlingBatch: BATCH_REF_SELECT,
productionBatch: BATCH_REF_SELECT,
} satisfies Prisma.BatchMovementSelect;
type BottlingRow = Prisma.BottlingBatchGetPayload<{
select: typeof BOTTLING_SELECT;
}>;
type ProductionRow = Prisma.ProductionBatchGetPayload<{
select: typeof PRODUCTION_SELECT;
}>;
type MovementRow = Prisma.BatchMovementGetPayload<{
select: typeof MOVEMENT_SELECT;
}>;
/** Chargen ohne Rest - der kommt nach dem Zuschnitt der Seite dazu. */
type BatchWithoutRemaining = Omit<ErpBatch, 'remainingQuantity'>;
function toPage<T>(
rows: readonly T[],
limit: number,
cursorOf: (last: T) => ErpCursor,
given: string | undefined,
): Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'> {
const items = rows.slice(0, limit);
const last = items[items.length - 1];
return {
items,
hasMore: rows.length > limit,
nextCursor:
last !== undefined ? encodeCursor(cursorOf(last)) : (given ?? null),
};
}
function fromBottling(row: BottlingRow): BatchWithoutRemaining {
return {
id: row.id,
batchType: 'BOTTLING',
lotNumber: row.lotNumber,
productId: row.productId,
date: row.date,
bestBeforeDate: row.bestBeforeDate,
initialQuantity: row.jarCount,
fillWeightGrams: row.jarSize,
honeyType: row.honeyBatch.honeyType,
};
}
function fromProduction(row: ProductionRow): BatchWithoutRemaining {
return {
id: row.id,
batchType: 'PRODUCTION',
lotNumber: row.lotNumber,
productId: row.productId,
date: row.date,
bestBeforeDate: row.bestBeforeDate,
initialQuantity: row.quantity,
fillWeightGrams: null,
honeyType: null,
};
}
function fromMovement(row: MovementRow): ErpMovement {
const batch = row.bottlingBatch ?? row.productionBatch;
const batchId = row.bottlingBatchId ?? row.productionBatchId;
if (!batch || !batchId) {
// Durch die Abfrage ausgeschlossen (nur Bewegungen mit Charge).
throw new Error(
`Bewegung ${row.id} ohne Abfüllung oder Herstellungscharge`,
);
}
return {
id: row.id,
createdAt: row.createdAt,
batchType: row.bottlingBatchId ? 'BOTTLING' : 'PRODUCTION',
batchId,
lotNumber: batch.lotNumber,
productId: batch.productId,
type: row.type,
reason: row.reason,
quantity: row.quantity,
};
}
function idAfter(id: string | undefined): { id: { gt: string } } | undefined {
return id !== undefined ? { id: { gt: id } } : undefined;
}
/**
* Nur lesender Inventar-Export für ERPNext (Teilprojekt T8 des
* ERPNext-Ausbaus): Artikel, Chargen mit Rest, Bestandsbewegungen und
* Erntemengen. ApiCore bleibt führend; ERPNext spiegelt nur.
*
* Zugang über einen API-Schlüssel mit dem Recht erp-inventory:read, das nur
* diese Endpunkte öffnet. Alle Listen sind stabil sortiert und seitenweise.
*/
@Injectable()
export class ErpExportService {
constructor(
private readonly prisma: PrismaService,
private readonly stock: StockService,
) {}
async products(query: ErpPageQueryDto): Promise<ErpPage<ErpProduct>> {
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'produkte') : null;
const rows = await this.prisma.product.findMany({
where: idAfter(after?.id),
select: PRODUCT_SELECT,
orderBy: { id: 'asc' },
take: limit + 1,
});
return this.envelope(
toPage(
rows,
limit,
(last) => ({ k: 'produkte', id: last.id }),
query.nach,
),
);
}
/** Erst alle Abfüllungen, dann alle Herstellungschargen, je nach id. */
async batches(query: ErpPageQueryDto): Promise<ErpPage<ErpBatch>> {
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'chargen') : null;
const bottlings =
after?.t === 'P'
? []
: await this.prisma.bottlingBatch.findMany({
where: idAfter(after?.id),
select: BOTTLING_SELECT,
orderBy: { id: 'asc' },
take: limit + 1,
});
// Erst wenn die Abfüllungen auf dieser Seite enden, kommen
// Herstellungschargen dazu - eine mehr als Platz ist, um weitere zu erkennen.
const productions =
bottlings.length > limit
? []
: await this.prisma.productionBatch.findMany({
where: idAfter(after?.t === 'P' ? after.id : undefined),
select: PRODUCTION_SELECT,
orderBy: { id: 'asc' },
take: limit - bottlings.length + 1,
});
const page = toPage(
[...bottlings.map(fromBottling), ...productions.map(fromProduction)],
limit,
(last) => ({
k: 'chargen',
t: last.batchType === 'BOTTLING' ? 'B' : 'P',
id: last.id,
}),
query.nach,
);
return this.envelope({
...page,
items: await this.withRemaining(page.items),
});
}
async movements(query: ErpMovementsQueryDto): Promise<ErpMovementPage> {
if (query.seit !== undefined && query.nach !== undefined) {
throw new BadRequestException(
'Entweder "seit" (erster Abruf) oder "nach" (weiterlesen) angeben, nicht beides',
);
}
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'bewegungen') : null;
const settledUntil = new Date(Date.now() - MOVEMENT_SETTLE_MS);
const afterCursor: Prisma.BatchMovementWhereInput[] = after
? [
{
OR: [
{ createdAt: { gt: new Date(after.ts) } },
{ createdAt: new Date(after.ts), id: { gt: after.id } },
],
},
]
: [];
const conditions: Prisma.BatchMovementWhereInput[] = [
// Nur Ware: Rohstoff-Bewegungen (Wachs, Propolis) gehören nicht in den Spiegel.
{
OR: [
{ bottlingBatchId: { not: null } },
{ productionBatchId: { not: null } },
],
},
{
createdAt: {
lte: settledUntil,
...(query.seit !== undefined ? { gte: new Date(query.seit) } : {}),
},
},
...afterCursor,
];
const rows = await this.prisma.batchMovement.findMany({
where: { AND: conditions },
select: MOVEMENT_SELECT,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: limit + 1,
});
const page = toPage(
rows.map(fromMovement),
limit,
(last) => ({
k: 'bewegungen',
ts: last.createdAt.toISOString(),
id: last.id,
}),
query.nach,
);
return { ...this.envelope(page), settledUntil };
}
async harvestYears(query: ErpHarvestsQueryDto): Promise<ErpHarvestYears> {
const year = query.jahr;
const rows = await this.prisma.harvest.findMany({
where:
year !== undefined
? {
date: {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
},
}
: undefined,
select: { date: true, netWeight: true },
});
return {
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: new Date(),
years: summarizeHarvestYears(rows, year),
};
}
private envelope<T>(
page: Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'>,
): ErpPage<T> {
return {
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: new Date(),
...page,
};
}
private async withRemaining(
batches: readonly BatchWithoutRemaining[],
): Promise<ErpBatch[]> {
const idsOf = (type: ErpBatchType) =>
batches.filter((b) => b.batchType === type).map((b) => b.id);
const bottlingIds = idsOf('BOTTLING');
const productionIds = idsOf('PRODUCTION');
const [bottlingRest, productionRest] = await Promise.all([
bottlingIds.length > 0
? this.stock.remainingByBottling(bottlingIds)
: new Map<string, number>(),
productionIds.length > 0
? this.stock.remainingByProductionBatch(productionIds)
: new Map<string, number>(),
]);
return batches.map((batch) => {
const rest =
batch.batchType === 'BOTTLING' ? bottlingRest : productionRest;
return {
...batch,
remainingQuantity: rest.get(batch.id) ?? batch.initialQuantity,
};
});
}
}
@@ -0,0 +1,96 @@
import { summarizeHarvestYears } from './harvest-years';
function harvest(date: string, netWeight: number | null) {
return { date: new Date(date), netWeight };
}
describe('summarizeHarvestYears', () => {
it('summiert das Nettogewicht je Kalenderjahr (UTC), älteste Jahre zuerst', () => {
const years = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 41),
harvest('2025-06-01T00:00:00.000Z', 12.5),
harvest('2026-05-20T00:00:00.000Z', 18.25),
]);
expect(years).toEqual([
{
year: 2025,
netWeightKg: 12.5,
harvestCount: 1,
harvestsWithoutNetWeight: 0,
},
{
year: 2026,
netWeightKg: 59.25,
harvestCount: 2,
harvestsWithoutNetWeight: 0,
},
]);
});
it('zählt Ernten ohne Nettogewicht mit, rechnet sie aber nicht ein', () => {
const [year] = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 20),
harvest('2026-08-01T00:00:00.000Z', null),
]);
expect(year).toEqual({
year: 2026,
netWeightKg: 20,
harvestCount: 2,
harvestsWithoutNetWeight: 1,
});
});
it('rundet auf Gramm, damit Gleitkomma-Reste nicht im Vertrag landen', () => {
const [year] = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 0.1),
harvest('2026-07-15T00:00:00.000Z', 0.2),
harvest('2026-07-16T00:00:00.000Z', 12.3456),
]);
expect(year.netWeightKg).toBe(12.646);
});
it('ordnet Silvester und Neujahr nach UTC zu', () => {
const years = summarizeHarvestYears([
harvest('2025-12-31T23:59:59.999Z', 1),
harvest('2026-01-01T00:00:00.000Z', 2),
]);
expect(years.map((y) => [y.year, y.netWeightKg])).toEqual([
[2025, 1],
[2026, 2],
]);
});
it('liefert für ein angefragtes Jahr ohne Ernten eine Nullzeile statt nichts', () => {
expect(summarizeHarvestYears([], 2024)).toEqual([
{
year: 2024,
netWeightKg: 0,
harvestCount: 0,
harvestsWithoutNetWeight: 0,
},
]);
});
it('beschränkt sich auf das angefragte Jahr', () => {
const years = summarizeHarvestYears(
[
harvest('2025-06-01T00:00:00.000Z', 5),
harvest('2026-06-01T00:00:00.000Z', 7),
],
2026,
);
expect(years).toEqual([
{
year: 2026,
netWeightKg: 7,
harvestCount: 1,
harvestsWithoutNetWeight: 0,
},
]);
});
it('liefert ohne Ernten und ohne Jahr eine leere Liste', () => {
expect(summarizeHarvestYears([])).toEqual([]);
});
});
@@ -0,0 +1,72 @@
/**
* Geerntete Honigmenge je Kalenderjahr - Grundlage für die Honigkosten je kg
* in ERPNext (Imkerei-Ausgaben des Jahres / geerntete kg).
*
* Gleiche Definition wie die Statistik in ApiCore (Honigertrag je Jahr):
* Summe der Nettogewichte (kg) der Ernten, Jahr aus dem Erntedatum. Das
* Jahr wird in UTC bestimmt, weil Erntedaten als Kalendertag (Mitternacht
* UTC) gespeichert werden. Ernten ohne Nettogewicht zählen nicht zur Menge,
* werden aber ausgewiesen - sonst sähe eine unvollständig erfasste Ernte
* wie ein schlechtes Jahr aus und die Kosten je kg wären zu hoch.
*/
export interface HarvestWeight {
date: Date;
/** Kilogramm; null, wenn nicht erfasst. */
netWeight: number | null;
}
export interface HarvestYear {
year: number;
/** Summe der Nettogewichte in kg, auf Gramm gerundet. */
netWeightKg: number;
harvestCount: number;
harvestsWithoutNetWeight: number;
}
const GRAMS_PER_KG = 1000;
function roundToGrams(kg: number): number {
return Math.round(kg * GRAMS_PER_KG) / GRAMS_PER_KG;
}
function emptyYear(year: number): HarvestYear {
return {
year,
netWeightKg: 0,
harvestCount: 0,
harvestsWithoutNetWeight: 0,
};
}
/**
* Fasst Ernten je Jahr zusammen, älteste Jahre zuerst. Mit `onlyYear` nur
* dieses Jahr, und zwar auch dann, wenn es keine Ernte hat (Nullzeile).
*/
export function summarizeHarvestYears(
harvests: readonly HarvestWeight[],
onlyYear?: number,
): HarvestYear[] {
const byYear = new Map<number, HarvestYear>();
if (onlyYear !== undefined) {
byYear.set(onlyYear, emptyYear(onlyYear));
}
for (const harvest of harvests) {
const year = harvest.date.getUTCFullYear();
if (onlyYear !== undefined && year !== onlyYear) continue;
const current = byYear.get(year) ?? emptyYear(year);
byYear.set(year, {
year,
netWeightKg: current.netWeightKg + (harvest.netWeight ?? 0),
harvestCount: current.harvestCount + 1,
harvestsWithoutNetWeight:
current.harvestsWithoutNetWeight + (harvest.netWeight === null ? 1 : 0),
});
}
return [...byYear.values()]
.sort((a, b) => a.year - b.year)
.map((entry) => ({
...entry,
netWeightKg: roundToGrams(entry.netWeightKg),
}));
}
@@ -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,
}) {}
@@ -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,
}) {}
@@ -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 } },
@@ -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;
}
}
@@ -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,
}) {}
@@ -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);
@@ -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;
}
}
@@ -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();
});
});
@@ -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');
}
}
@@ -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;
@@ -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;
}
@@ -8,4 +8,5 @@ import { CreateHoneyMeasurementDto } from './create-honey-measurement.dto';
*/
export class UpdateHoneyMeasurementDto extends PartialType(
CreateHoneyMeasurementDto,
{ skipNullProperties: false },
) {}
@@ -395,6 +395,32 @@ describe('HoneyBatchesService', () => {
});
});
it('removes the container assignment on null without a relocation', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
storageContainerId: 'container-1',
});
await service.update(
'batch-1',
{ storageContainerId: null as never },
'user-1',
);
const [[updateArg]] = prisma.honeyBatch.update.mock.calls as [
[{ data: { storageContainerId?: string | null } }],
];
expect(updateArg.data.storageContainerId).toBeNull();
expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyBatch',
entityId: 'batch-1',
action: 'UPDATE',
}),
);
});
it('records no relocation when an edit leaves the container untouched', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
@@ -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',
},
@@ -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,
}) {}
@@ -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' });
@@ -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',
@@ -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;
}
@@ -3,4 +3,5 @@ import { CreateLabelTemplateDto } from './create-label-template.dto';
export class UpdateLabelTemplateDto extends PartialType(
CreateLabelTemplateDto,
{ skipNullProperties: false },
) {}
@@ -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;
@@ -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,
},
});
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -7,4 +7,5 @@ import { CreateQueenEvaluationDto } from './create-queen-evaluation.dto';
*/
export class UpdateQueenEvaluationDto extends PartialType(
CreateQueenEvaluationDto,
{ skipNullProperties: false },
) {}
@@ -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;
}
@@ -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;
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff. Show more