Compare commits

...
104 Commits
Author SHA1 Message Date
Nick Szaunig 3f7c4ed404 feat: Jahresbericht-PDF pro Volk/Stand
Neue PDF-Reports im Export-Modul: colony-annual-report (Durchsichten,
Behandlungen, Varroakontrollen, Schwarmkontrolle, Ernten eines Volkes fuer
ein Jahr) und apiary-annual-report (Voelker-Uebersicht + Ernten eines
Stands fuer ein Jahr). Jahr ist optional, faellt auf das aktuelle Jahr
zurueck.

Frontend: "Jahresbericht"-Button auf Volk- und Stand-Detailseite, laedt
das PDF ueber den bestehenden Blob-Download-Mechanismus herunter.
2026-08-25 11:51:41 +02:00
Nick Szaunig f1298eff08 fix: harden auth against brute force, add auth audit logging, fix cookie/TLS gaps
Security review findings (HIGH-1/2/3, MEDIUM):

- Rate limiting via @nestjs/throttler: global 100 req/min default, stricter
  10 req/min on login and refresh. Also closes the MEDIUM finding (public
  batch-lookup enumeration) since it now sits behind the global limit too.
  Trust the K8s ingress's single proxy hop so throttling keys off the real
  client IP, not the ingress pod's IP.
- Auth events (login success/failure, logout, token refresh) are now
  audit-logged via new AuditAction values, closing the gap where the
  project's own audit-log principle never actually covered the Auth module.
- Refresh-token cookie now defaults to Secure=true (opt out only via
  COOKIE_SECURE=false for local HTTP dev), instead of keying off NODE_ENV
  which was never set in the K8s deploy templates. Added a TLS block +
  cert-manager annotation to both the backend and frontend ingress
  templates, which previously had no HTTPS termination configured at all.
2026-08-25 10:49:25 +02:00
Nick Szaunig 1a7331b6b2 feat: Schwarmkontrolle-Protokoll
Neues SwarmControlCheck-Modul: strukturierte Weiselzellenkontrollen pro
Volk mit Zahl/Verdeckelungsstatus der Zellen, Schwarmstimmung, ergriffener
Maßnahme und optionalem naechsten Kontrolltermin.

Frontend: neue Karte auf der Volk-Detailseite, Erfassungsformular unter
/swarm-control/new sowie Quick-Add-Eintrag.
2026-08-25 09:12:50 +02:00
Nick Szaunig 4609a0f9ec feat: Wiederkehrende Aufgaben-Vorlagen (recurring task templates)
Neue TaskTemplate-Entität mit YEARLY/MONTHLY/INTERVAL_DAYS-Wiederholung.
Fällige Aufgaben werden lazy bei GET /tasks erzeugt (Compare-and-Swap auf
nextDueDate), damit mehrere API-Replicas im Kubernetes-Cluster dieselbe
Vorlage nicht doppelt feuern - ohne zusätzliche Cron-Abhängigkeit.

Frontend: Verwaltungsseite unter /settings/task-templates zum Anlegen,
Auflisten und Deaktivieren von Vorlagen.
2026-08-25 05:36:55 +02:00
Nick Szaunig e458a7cf07 feat: Königinnen-Abstammungsbaum (Mutterlinie)
Neue Funktion jenseits der Lastenheft-Checkliste, explizit im Text als
spätere Erweiterung angedacht (§4.4: "ApiCore SOLL später
Abstammungsbäume darstellen können"). Königin.motherQueenId ist eine
neue optionale Selbstreferenz (additive Migration), zusätzlich zu den
bestehenden freien Textfeldern motherLine/fatherLine – letztere
bleiben für Fälle, in denen die Mutter nicht selbst in ApiCore erfasst
ist. Die Vaterlinie bleibt bewusst Freitext, da eine Königin mit vielen
Drohnen aus unterschiedlichen Völkern begattet wird und es keine
einzelne "Vater-Königin" gibt, die referenzierbar wäre.

Backend: GET /queens/:id/lineage baut die mütterliche Ahnenkette
(rekursiv über motherQueenId, Tiefe 8) sowie den Töchterbaum
(rekursiv über die neue daughterQueens-Relation) in einer Antwort;
create/update lehnen eine nicht existierende oder auf sich selbst
zeigende motherQueenId ab.

Frontend: neue "Abstammungsbaum"-Karte auf der Königin-Detailseite
zeigt die mütterliche Linie als Liste sowie die Töchter als
eingerückten Baum, beide mit Links zu den jeweiligen Königinnen.
Anlage- und Bearbeiten-Formular bekommen ein "Mutter"-Auswahlfeld
(Bearbeiten schließt die Königin selbst aus der Liste aus).

Live in Chrome verifiziert: dreistufige Kette (Großmutter → Mutter →
Tochter) korrekt in beide Richtungen dargestellt; dabei einen echten
Anzeigefehler gefunden und behoben – das Mutter-Auswahlfeld zeigte
nach dem Speichern die rohe Datenbank-ID statt des Königin-Codes an
(derselbe SelectValue-ohne-children-Fehler wie zuvor bei der
Import-Entitätsauswahl).
2026-08-25 05:13:45 +02:00
Nick Szaunig 68f459001a feat: Bearbeiten für alle Log-Einträge (Aufgaben, Durchsichten, Behandlungen, Fütterungen, Varroa-Messungen, Ernten, Schleuderungen, Abfüllungen)
Bisher liessen sich diese acht Objekttypen nur anlegen, nie nachträglich
korrigieren – Aufgaben hatten zwar schon einen PATCH-Endpunkt, aber
keine Bearbeiten-UI, die übrigen sieben hatten weder Backend- noch
Frontend-Unterstützung. Jetzt bekommt jeder Typ einen PATCH :id
Endpunkt (UpdateXDto per PartialType(PickType(...)) nach demselben
Muster wie UpdateColonyDto/UpdateApiaryDto, mit Audit-Log-Eintrag
inkl. Vorher-/Nachher-Stand) sowie eine Bearbeiten-UI:

- Aufgaben: Dialog auf der Aufgabenliste
- Durchsichten/Behandlungen/Fütterungen/Varroa-Messungen: Dialog pro
  Listeneintrag auf der Volk-Detailseite
- Ernten: Dialog auf der eigenen Ernte-Detailseite
- Schleuderungen: Dialog direkt in der Rückverfolgbarkeits-Karte der
  Honigcharge (keine eigene Detailseite nötig); Erntezeilen dort
  verlinken jetzt zur Ernte-Detailseite statt sie zu duplizieren
- Abfüllungen: Dialog auf der Abfüllungs-Detailseite, bewusst nur
  Datum/MHD/Losnummer editierbar – Glasgröße/-anzahl bleiben
  unveränderlich, weil sie bereits unumkehrbar die Honigcharge-Menge
  reduziert haben

Relationsstiftende Fremdschlüssel (colonyId, apiaryId/colonyIds,
harvestIds, honeyBatchId) bleiben überall bewusst nicht editierbar,
konsistent mit dem bestehenden apiaryId-Ausschluss bei UpdateColonyDto.
2026-08-25 04:28:37 +02:00
Nick Szaunig 63ab0e842d docs: NFC-Tags in TASKS.md abgehakt 2026-08-25 01:50:26 +02:00
Nick Szaunig 53b781aa04 feat: NFC-Tags beschreiben mit demselben Deep-Link wie der QR-Code
Neuer Endpunkt GET /api/v1/qr/:entityType/:id/link liefert den vom
QR-Dienst bereits genutzten Deep-Link als reine URL statt als SVG,
damit die Web-NFC-API (client-seitig, Chrome/Android) ihn per
NDEFReader.write() auf einen physischen NFC-Tag schreiben kann.
QrService.resolveUrl() ist aus generateSvg() herausgezogen, damit
beide Endpunkte exakt denselben Link erzeugen.

Der bestehende QrCodeDialog bekommt dadurch für alle sechs
QR-fähigen Entitäten (Völker, Bienenstände, Königinnen, Honigchargen,
Lagerbehälter, Abfüllungen) automatisch einen "Auf NFC-Tag
schreiben"-Button, der nur erscheint, wenn window.NDEFReader
tatsächlich existiert – kein Fallback für andere Browser oder iOS,
wie in der Lastenheft vorgesehen.
2026-08-25 01:50:14 +02:00
Nick Szaunig f346859121 docs: Herkunftsseiten + QR-Code in TASKS.md abgehakt 2026-08-25 01:36:08 +02:00
Nick Szaunig d56550c0e5 feat: öffentliche Herkunftsseiten für Honigchargen + Kunden-QR-Code
Neuer unauthentifizierter Endpunkt GET /api/v1/public/batches/:lot
(Abschnitt 27 der Lastenheft) liefert kontrolliert freigegebene
Rückverfolgbarkeitsdaten einer Abfüllcharge – Produkt, Erntemonat,
Bienenstand-Namen, Schleuderdatum, Wassergehalt – ohne interne IDs,
Adressen oder Koordinaten preiszugeben. Die neue Seite /herkunft/{lot}
liegt außerhalb der (app)-Route-Group und damit ohne Login/Sidebar,
gedacht als Ziel eines QR-Codes auf dem Honigglas. Der bestehende
QR-Dienst kennt jetzt den Entity-Typ "bottling-batches" und verlinkt
intern auf diese neue öffentliche Seite; ein entsprechender
QR-Code-Button samt Klartext-Link steht ab sofort auf der
Abfüllungs-Detailseite bereit.
2026-08-25 01:35:47 +02:00
Nick Szaunig 711536f299 docs: Offline-PWA in TASKS.md abgehakt 2026-08-25 01:14:28 +02:00
Nick Szaunig eb66c4bd53 feat: offline-fähige PWA mit IndexedDB-Sync für Durchsichten
Service Worker (public/sw.js) cached die App-Shell für Seiten-GETs
(network-first, cache-fallback). Durchsichten, die offline gespeichert
werden, landen in einer IndexedDB-Warteschlange (idb) statt zu
scheitern und werden automatisch synchronisiert, sobald wieder
Netzwerk verfügbar ist (Mount + online-Event), mit manuellem Retry
über ein Badge im App-Header. Ein modulweiter In-Flight-Lock in
sync.ts verhindert doppelte Synchronisierung bei gleichzeitigen
Aufrufen (gefunden über React-StrictMode-Doppel-Mount, das ohne den
Lock dieselbe Durchsicht zweimal anlegte).
2026-08-25 01:14:18 +02:00
Nick Szaunig 20239885b4 docs: Kartenansicht in TASKS.md als fertig markiert
Siehe vorherigen Commit fuer Implementierung und Live-Verifikation.
2026-08-25 00:40:39 +02:00
Nick Szaunig aa3a777381 feat(frontend): Kartenansicht für Bienenstände (Später-Liste)
Neue Seite /apiaries/map zeigt alle Bienenstände mit hinterlegten
Koordinaten auf einer Leaflet-Karte (OpenStreetMap-Kacheln), mit
Marker-Popup, das zum jeweiligen Bienenstand verlinkt. Karte wird
per next/dynamic mit ssr:false geladen, da Leaflet auf window/document
zugreift.

Breiten-/Längengrad waren zwar seit dem MVP im Backend-DTO vorhanden,
aber über kein Formular erreichbar – ohne diese Lücke wäre die Karte
dauerhaft leer geblieben. Daher Breitengrad-/Längengrad-Felder auf der
Bienenstand-Anlegen- und Bearbeiten-Seite ergänzt.

Marker-Icons werden aus public/leaflet/ bedient statt aus
node_modules-Bild-Imports, da Turbopack Bilder aus node_modules nicht
als StaticImageData auflöst (führte zu "iconUrl not set"-Laufzeitfehler,
live im Browser gefunden und behoben) – der von Leaflet empfohlene
Workaround für Bundler-Icon-Probleme.

Live verifiziert: Koordinaten speichern, Karte zeigt Marker an
korrekter Position, Popup verlinkt korrekt, Empty State bei fehlenden
Koordinaten, mobile Breite ohne Overflow. Zero Konsolenfehler.
2026-08-25 00:38:08 +02:00
Nick Szaunig 4b9f5c7f47 docs: v1.x-Kette end-to-end live verifiziert, v1.x vollständig abgehakt
Kompletten Ablauf live im Browser durchgeklickt: E2E-Kette-Stand ->
E2E-V-001 -> E2E-Q-001 (zugewiesen) -> Durchsicht -> Behandlung
(Ameisensäure) -> Ernte -> Schleuderung -> Honigcharge E2E-HC-001 ->
Lagerbehälter LB-01 (Umlagerung) -> Abfüllung E2E-LOT-001 -> Produkt
"Sommerhonig 500g". Jeder Schritt mit korrekt verknüpften Daten und
ohne Konsolenfehler bestätigt.

Damit ist die gesamte v1.x-Liste aus TASKS.md abgeschlossen.
2026-08-24 23:31:03 +02:00
Nick Szaunig c50c674e94 docs: Modul 33 (Import) und Build-Grün-Checks in TASKS.md abgehakt
Backend und Frontend beide fertig, getestet und live verifiziert
(siehe die beiden vorherigen Commits). Gesamter Backend- und
Frontend-Build war während der gesamten v1.x-Arbeit durchgehend grün.

Offen bleibt nur noch die abschließende End-to-End-Verifikation der
erweiterten Kernkette (Stand -> Volk -> ... -> Produkt) als
zusammenhängender manueller Durchlauf.
2026-08-24 23:07:42 +02:00
Nick Szaunig dfea017f75 feat(frontend): CSV-Import-Seite mit Ergebnisbericht (Modul 33)
Neue Seite /settings/import (nur Administrator, Sidebar-Link unter
Verwaltung): Datentyp wählen (Bienenstände/Völker/Königinnen), CSV-Datei
hochladen, danach Ergebnisbericht mit Zusammenfassung (erstellt/
übersprungen/Fehler) und Zeile-für-Zeile-Tabelle inkl. Statusbadge und
Fehlermeldung.

Live gegen das Backend verifiziert: Upload einer CSV mit einer gültigen
und einer ungültigen Zeile erzeugt korrekt 1 erstellt / 1 Fehler mit
Validierungsmeldung, der neu angelegte Bienenstand erscheint danach in
der regulären Bienenstände-Liste. Zero Konsolenfehler.
2026-08-24 23:07:07 +02:00
Nick Szaunig 4f2f31c05f feat(backend): CSV-Import für Stammdaten (Modul 33)
Neues ImportModule mit einem abhängigkeitsfreien CSV-Parser
(src/import/csv.ts, RFC4180-artig: Quoting, escapte Anführungszeichen,
CRLF/LF) passend zum bereits im ExportModule verwendeten
Schreibformat, sodass exportierte CSVs unverändert wieder importierbar
sind.

Endpunkt: POST /import/:entity (multipart, nur Administrator) für
apiaries, colonies, queens. Jede Zeile wird über die exakt gleichen
class-validator-DTOs geprüft, die auch die regulären POST-Endpunkte
nutzen (CreateApiaryDto/CreateColonyDto/CreateQueenDto) – keine
duplizierte Validierungslogik.

Ergebnisbericht pro Zeile (erstellt/übersprungen/Fehler + Meldung):
- Colonies/Queens: Zeilen mit bereits existierendem Code werden
  übersprungen statt einen Fehler zu werfen (idempotenter Re-Import)
- Colonies lösen den Bienenstand über eine "apiaryName"-Spalte auf
  (case-insensitive), da eine CSV keine internen IDs kennen kann;
  unbekannter Name → Fehlerzeile statt Abbruch des gesamten Imports

Unit-Tests für den CSV-Parser (Quoting, escapte Anführungszeichen,
CRLF, leere Zeilen) und den ImportService (alle drei Entitäten,
Validierungsfehler, Duplikat-Skip, unbekannter Bienenstand). E2E-Tests
über echten multipart-Upload (Rollenprüfung, Dateiendungsprüfung, alle
drei Entitäten, Re-Import-Idempotenz).
2026-08-24 23:00:46 +02:00
Nick Szaunig e051ad956b docs: Modul 30 (Automatisierungen) in TASKS.md als fertig markiert
Backend und Frontend beide fertig, getestet und live verifiziert
(siehe die beiden vorherigen Commits).
2026-08-24 22:47:58 +02:00
Nick Szaunig 5d32026092 feat(frontend): Automatisierungen-Verwaltung + erreichbare Trigger (Modul 30)
Neue Seite /settings/automation-rules (nur Administrator, Sidebar-Link
unter Verwaltung): zeigt alle drei fest hinterlegten Regeln mit
Beschreibung, Aktiv/Inaktiv-Umschalter, editierbarem Aufgabentext und
– nur bei Varroa-Schwellenwert – editierbarem Schwellenwert.

Zusätzlich zwei Lücken im Durchsicht-Formular geschlossen, ohne die
wären zwei der drei Trigger über die Oberfläche nie auslösbar gewesen:
- "Stifte vorhanden"-Checkbox ergänzt (eggsPresent wurde vom Formular
  bisher gar nicht gesendet, obwohl Backend und Typ es unterstützen)
- "Futtervorrat ausreichend"-Checkbox durch ein Select mit
  ausreichend/niedrig/kritisch ersetzt (bisher nur binär, "kritisch"
  war nicht erreichbar)

Live gegen das Backend verifiziert: Speichern einer Durchsicht mit
Königin nicht gesehen + keine Stifte erzeugt automatisch die erwartete
Aufgabe (sichtbar unter /tasks) und eine Benachrichtigung, zero
Konsolenfehler. Aktiv/Inaktiv-Umschalter auf der Automatisierungen-Seite
live getestet.
2026-08-24 17:34:29 +02:00
Nick Szaunig 3f47a6ae8d feat(backend): Automatisierungen mit drei fest hinterlegten Triggern (Modul 30)
Neues AutomationModule mit AutomationRule-Modell (ein Row je Trigger-Typ,
per Unique Constraint erzwungen). Drei fest im Code hinterlegte Trigger,
die Admins nur aktivieren/deaktivieren bzw. deren Konfiguration/Aufgaben-
text anpassen können:

- QUEENLESS_NO_EGGS: Königin nicht gesehen UND keine Stifte vorhanden
  (bei Durchsicht) → Kontrollaufgabe
- VARROA_THRESHOLD: Varroa-Schwellenwert überschritten (konfigurierbar,
  Standard 10 Milben/Tag) → Behandlungsprüfung
- FEED_STATUS_CRITICAL: Futterstatus "kritisch" (bei Durchsicht) →
  Fütterungsaufgabe

Jeder ausgelöste Trigger erzeugt eine Task UND eine Notification sowie
einen Audit-Log-Eintrag; Tasks lösen zusätzlich das bestehende
task.created-Webhook aus. InspectionsService und VarroaService rufen
die Auswertung direkt nach dem jeweiligen Erstellen auf.

Neue Endpunkte (nur Administrator): GET/PATCH /automation-rules.

Unit-Tests für AutomationService (alle drei Trigger, Deaktivierung,
Update mit Audit-Log) sowie E2E-Tests (Rollen-Check, alle drei Trigger
end-to-end über echte Inspection-/Varroa-Endpunkte, Deaktivierung
verhindert weitere Trigger). E2E-Suite zweimal hintereinander grün
verifiziert (Idempotenz der globalen Regel-Zeilen sichergestellt).
Migration auf Dev- und Test-Datenbank angewendet.
2026-08-24 17:25:42 +02:00
Nick Szaunig 42a2be31b4 docs: Modul 32 (Lagerbehälter-Umlagerung) in TASKS.md als fertig markiert
Backend und Frontend beide fertig, getestet und live verifiziert
(siehe die beiden vorherigen Commits).
2026-08-24 17:07:11 +02:00
Nick Szaunig 15f5ab57ae feat(frontend): Lagerbehälter-Sektion und Umlagerungshistorie (Modul 32)
Neue HoneyBatchStorageCard-Komponente auf der Honigcharge-Detailseite:
zeigt den aktuell zugeordneten Lagerbehälter, einen "Umlagern"-Dialog
(Ziel-Lagerbehälter wählen, optionale Notiz) und die vollständige
Umlagerungshistorie (von/nach, Zeitpunkt, Notiz) im etablierten
EmptyState-Stil.

HoneyBatch-Typ um storageContainer erweitert, neue
HoneyBatchRelocation/RelocateHoneyBatchInput-Typen und passende
API-Funktionen ergänzt. Live gegen das Backend verifiziert: Umlagerung
zwischen zwei echten Lagerbehältern aktualisiert die Anzeige und die
Historie korrekt, zero Konsolenfehler.
2026-08-24 17:06:54 +02:00
Nick Szaunig 59a320ae36 feat(backend): Lagerbehälter-Umlagerung für Honigchargen (Modul 32)
Neues HoneyBatchRelocation-Modell dokumentiert jede Umlagerung einer
Honigcharge zwischen Lagerbehältern (von/nach, Zeitpunkt, Benutzer,
Notiz). HoneyBatchesService.relocate() aktualisiert
HoneyBatch.storageContainerId transaktional zusammen mit dem
Historieneintrag; wirft NotFoundException bei unbekanntem Zielbehälter.

Neue Endpunkte: POST /honey-batches/:id/relocate,
GET /honey-batches/:id/relocations. honeyBatchInclude liefert jetzt
zusätzlich den aktuell zugeordneten storageContainer mit.

Unit- und E2E-Tests ergänzt (Mehrfachumlagerung, Historienreihenfolge,
404 bei unbekanntem Behälter). Migration auf Dev- und Test-Datenbank
angewendet.
2026-08-24 16:55:15 +02:00
Nick Szaunig fa13543578 docs: Modul 29 (Dokumente und Bilder) in TASKS.md als fertig markiert
Backend war bereits fertig, Frontend-Upload-Widget wurde soeben ergänzt
und live verifiziert (siehe vorheriger Commit). Anmerkung ergänzt, dass
Durchsicht keine eigene Detailseite hat und daher aktuell nicht
angebunden ist.
2026-08-24 16:43:36 +02:00
Nick Szaunig a21563cc76 feat(frontend): Dokumente-Upload-Widget (Modul 29 Frontend)
Backend war bereits fertig, das Frontend fehlte bislang. Neue
gemeinsame DocumentUploadCard-Komponente (Liste hochgeladener Dateien,
Upload per Dateiauswahl, Download, Löschen mit Bestätigungsdialog da
unumkehrbar) plus dazugehöriges src/lib/documents/ API-Modul.

apiFetch erzwingt bislang immer Content-Type: application/json, was
multipart-Uploads verhindert hätte – daher neue apiFetchFormData-
Hilfsfunktion in api-client.ts ergänzt (kein manuell gesetzter
Content-Type, damit der Browser die multipart-Boundary selbst setzt).

Eingebunden auf allen Detailseiten, die laut TASKS.md Modul 29 vorsehen
und tatsächlich eine eigene Route haben: Bienenstand, Volk, Königin,
Honigcharge. Durchsicht hat keine eigene Detailseite (nur Einträge in
der Volk-Unterliste) und wird daher vorerst ausgelassen.

Live verifiziert: Upload, Download und Löschen (inkl. Bestätigungsdialog)
funktionieren End-to-End gegen das Backend, zero Konsolenfehler auf
allen vier Detailseiten.
2026-08-24 16:43:18 +02:00
Nick Szaunig 9a32c6c11d feat(frontend): Icon-Badges auf Abfüllungs- und Ernte-Detailkarten
Header von BottlingBatchDetailPage und HarvestDetailPage um Icon-Badge
(Package bzw. Boxes im Honig-Gradient) ergänzt, konsistent zum bereits
überarbeiteten Look aller anderen Detailseiten. Schließt die letzten
beiden verbliebenen Detailseiten ohne Icon-Badge ab.
2026-08-24 14:50:34 +02:00
Nick Szaunig c0922387fd feat(frontend): EmptyState statt Fließtext in verschachtelten Listen
Ersetzt den bisherigen reinen Fließtext ("Noch keine X dokumentiert.")
durch die gemeinsame EmptyState-Komponente (Icon, Titel, Beschreibung)
in allen verschachtelten Listen auf den Volk- und Königin-Detailseiten:
Fütterung, Behandlungen, Varroa, Durchsichten, Historie (Volk und
Königin), Bewertungen und Honigmessungen.

Rundet den bereits umgesetzten EmptyState-Look der Hauptlistenseiten
konsistent auf die Detailseiten-Unterkarten ab.
2026-08-24 00:27:04 +02:00
Nick Szaunig e889edd6c5 feat(frontend): PageHeader mit Icon auf allen 13 "Neu"-Formularseiten
Ersetzt die bisherige schlichte CardTitle-Überschrift durch die
gemeinsame PageHeader-Komponente (Icon-Badge im passenden Gradient,
Titel, kurze Beschreibung) auf allen Formularseiten zum Anlegen neuer
Datensätze (Bienenstände, Völker, Königinnen, Aufgaben, Produkte,
Inventar, Schleuderungen, Abfüllungen, Durchsichten, Varroamessungen,
Behandlungen, Fütterungen, Ernten).

Konsistent zum bereits überarbeiteten Look der Listen- und Detailseiten;
die Beschreibungszeile gibt zusätzlich beim Öffnen sofort Kontext, was
die Seite tut – hilfreich für Nutzer, die schnell zwischen vielen
Formularen wechseln.
2026-08-24 00:21:41 +02:00
Nick Szaunig ebfbd2ef6d feat(frontend): Leerer-Zustand-Komponente für alle Listenseiten
Neue gemeinsame EmptyState-Komponente (Icon in Kreis, Titel, Beschreibung,
gestrichelter Rahmen) und Einbindung auf allen 9 Listenseiten mit Tabellen
(Völker, Bienenstände, Königinnen, Aufgaben, Honigchargen, Benutzer,
Inventar, Produkte, Lager mit Behältern und Artikeln).

Bug behoben: Bei leerem Datensatz wurde bisher nur eine Tabelle mit
Kopfzeile und keinerlei Inhalt oder Erklärung angezeigt – wirkte wie ein
Ladefehler statt eines normalen "noch keine Daten"-Zustands. Besonders
für neurodivergente Nutzer wichtig, da Unklarheit über den Systemzustand
("ist das kaputt oder einfach leer?") unnötige kognitive Last erzeugt.
2026-08-23 21:03:59 +02:00
Nick Szaunig fc88c272ae feat(frontend): Icon-Badges auf Honigcharge- und Inventar-Detailkarten
Header von HoneyBatchDetailPage und EquipmentDetailPage um Icon-Badge
(Droplet im Honig-Gradient bzw. Wrench im Himmel-Gradient) ergänzt,
konsistent zum bereits überarbeiteten Look der übrigen Detailseiten.
2026-08-23 20:59:28 +02:00
Nick Szaunig 5be352c4a0 feat(frontend): Icon-Badges auf Detailseiten-Karten (Volk/Bienenstand/Königin)
Header von ColonyEditCard, ApiaryEditCard und QueenEditCard um Icon-Badge
im Honig-/Salbei-Gradient ergänzt, passend zum bereits überarbeiteten
Look der Listen- und Übersichtsseiten.

Zusätzlich Bug behoben: ColonyEditCard und QueenEditCard nutzten bislang
ein hartcodiertes ACTIVE-Ternary statt der gemeinsamen statusTone()-Hilfsfunktion,
wodurch z. B. WEAK/CONSPICUOUS/DISSOLVED-Völker und LOST/DECEASED-Königinnen
auf der eigenen Detailseite nur als neutrales "secondary"-Badge statt in
Warn-/Fehlerfarbe erschienen.
2026-08-23 20:57:01 +02:00
Nick Szaunig 6c764bf885 feat(frontend): PageHeader auf den restlichen Hauptseiten
Inventar, Produkte, Lager, Kalender, Suche und Audit-Log bekommen den
gleichen Gradient-Icon-Header wie die uebrigen Seiten. Lager-Seite hatte
zwei h1-artige Ueberschriften fuer zwei Abschnitte (Lagerbehaelter/
Lagerartikel) auf einer Seite; jetzt ein PageHeader fuer die Seite plus
gleich grosse Unterueberschriften fuer beide Abschnitte statt
uneinheitlicher Groessen.
2026-08-23 20:48:03 +02:00
Nick Szaunig 805effb60b feat(frontend): kontrastreichere Tabs
Aktiver Tab war kaum vom Tab-Leisten-Hintergrund zu unterscheiden
(fast gleicher Ton). Jetzt weisser Karten-Hintergrund mit Schatten
und Primaerfarben-Text fuer den aktiven Tab, groesserer Radius/Padding
passend zum Rest der Oberflaeche.
2026-08-23 20:33:09 +02:00
Nick Szaunig 60b59c120a feat(frontend): einheitliche Seiten-Header mit Icon-Badge
Neue PageHeader-Komponente (Gradient-Icon-Badge + Titel + Beschreibung
+ Aktionen) ersetzt die nackten <h1>-Zeilen auf den meistgenutzten
Seiten (Voelker, Bienenstaende, Koeniginnen, Aufgaben, Honigchargen,
Statistik, Webhooks, Benutzerverwaltung). Gradient variiert pro
Bereich (Honig/Salbei/Himmel), passend zum jeweiligen Sidebar-Icon.
2026-08-23 20:31:03 +02:00
Nick Szaunig 3af56c96fa feat(frontend): Gradient-Buttons, weichere Formulare und Dialoge
Primaerer Button-Stil nutzt jetzt den Honig-Gradient statt Volltonfarbe,
mit Schatten und Hover-Lift. Buttons/Inputs/Select/Textarea/Dialog auf
rounded-xl bzw. rounded-2xl vereinheitlicht, Formularfelder mit Karten-
hintergrund, dezentem Schatten und Hover-Rahmen statt nacktem Rand.
Dialog-Footer-Radius/Aussenabstand an das groessere Popup-Padding
angepasst, sonst waere die Ecke am Fusszeilen-Rand versetzt gewesen.
2026-08-23 20:23:54 +02:00
Nick Szaunig 6a97ebc594 feat(frontend): Karten-Rahmen und ruhigere Typografie fuer alle Tabellen
Die geteilte Table-Komponente (11 Listenseiten: Voelker, Bienenstaende,
Koeniginnen, Aufgaben, Honigchargen, Inventar, Produkte, Lager, Benutzer,
Audit-Log, Webhooks) bekommt jetzt denselben Karten-Rahmen/Schatten wie
der Rest der Oberflaeche statt nackt auf dem Hintergrund zu schweben,
plus getoente Kopfzeile, ruhigere Uppercase-Spaltentitel und mehr
Zeilenabstand.
2026-08-23 20:13:56 +02:00
Nick Szaunig 5cacc3c60d feat(frontend): moderneres visuelles Redesign (Farben, Tiefe, Dashboard-Hero)
Kartenhintergrund und Seitenhintergrund waren fast farbgleich, wodurch
alles flach wirkte trotz vorhandenem Schatten. Staerkerer Kontrast
(--background/--card/--muted/--border), neue Gradient- und
Schatten-Tokens (--gradient-honey/-sage/-sky, --shadow-soft/-elevated).

Card-Komponente: rounded-2xl, echter sichtbarer Schatten, optionaler
Hover-Lift (interactive-Prop). Sidebar: solide Gradient-Badge, kraeftiger
Active-Pill-Zustand statt blassem Tint. Header: sticky mit Blur-Schatten
statt hartem Rand. Dashboard: Gradient-Hero mit Begruessung und
Honeycomb-Muster, farbige Gradient-Icon-Badges pro KPI-Kachel,
Hinweise-Zeilen mit Icon statt rohem Enum-Badge (dabei toten
CONSPICUOUS_COLONY-Style-Key gefunden und auf COLONY_ATTENTION
korrigiert). Honeycomb-Hintergrund aus der Login-Seite in eine
gemeinsame Komponente extrahiert und dort mit dem gleichen
Gradient-Badge-Header konsistent gemacht.
2026-08-23 20:10:33 +02:00
Nick Szaunig 6ab028d1be feat(frontend): confirmation dialogs and undo for destructive-feeling actions
Volk aufloesen, Bienenstand deaktivieren, Benutzer deaktivieren und
Webhook loeschen liefen bisher ohne jede Rueckfrage direkt auf Klick.
Neue wiederverwendbare ConfirmDialog-Komponente erzwingt jetzt eine
bewusste Bestaetigung. Zusaetzlich zeigen Volk/Bienenstand/Benutzer
nach dem Aufloesen/Deaktivieren einen Reaktivieren-Button (nutzt die
neuen Backend-Endpunkte), da diese Aktionen nie ein echtes Loeschen
sind und ein versehentlicher Klick bisher nicht rueckgaengig zu
machen war.
2026-08-23 11:50:45 +02:00
Nick Szaunig 57979f0371 feat(backend): activate endpoint for deactivated users
Symmetrisch zu Modul-27/28-Fix fuer Colony/Apiary: kein Weg, einen
versehentlich deaktivierten Benutzer wieder zu aktivieren. Neuer
POST :id/activate-Endpunkt setzt isActive zurueck auf true und
loescht deactivatedAt; ConflictException wenn bereits aktiv.
2026-08-23 11:44:52 +02:00
Nick Szaunig 6346bfab1d feat(backend): reactivate endpoint for dissolved colonies and inactive apiaries
Da fachliche Objekte nie hart geloescht werden (nur status +
deactivatedAt), fehlte bisher ein Weg, ein versehentlich aufgeloestes
Volk oder deaktivierten Bienenstand wieder zu aktivieren. Neue
POST :id/reactivate-Endpunkte setzen status zurueck auf ACTIVE und
loeschen deactivatedAt; ConflictException wenn bereits aktiv.
2026-08-23 11:37:06 +02:00
Nick Szaunig 4a9fdd5c71 feat(backend): Dokumente und Bilder mit lokalem Datei-Upload (Modul 29)
DocumentsModule (Document: entityType/entityId, Dateiname, MIME-Typ,
Groesse, Speicherpfad, Hochgeladen-von) fuer Bienenstand/Volk/Koenigin/
Durchsicht/Honigcharge. Upload als multipart/form-data (Multer, 10 MB
Limit, MIME-Allowlist jpeg/png/webp/pdf), Speicherung unter
DOCUMENTS_STORAGE_PATH (austauschbar). ARCHITECTURE.md Annahme 7
dokumentiert die Multi-Replica-Einschraenkung ohne geteiltes Volume.
2026-08-23 11:29:22 +02:00
Nick Szaunig e4d5af5fa7 feat(frontend): Webhook-Verwaltung fuer Administratoren (Modul 28)
/settings/webhooks: Liste, Anlage-Dialog mit Ereignis-Auswahl und
einmaliger Secret-Anzeige (Kopieren), Loeschen. Sidebar-Eintrag unter
Verwaltung. Live in Chrome verifiziert (Anlegen/Kopieren/Loeschen,
SSRF-Ablehnung, Mobile-Breakpoint); dabei einen Reset-Bug im
Anlage-Dialog gefunden und behoben (Fertig-Button loeschte den
Dialogzustand nicht, da onOpenChange bei programmatischem Schliessen
nicht feuert).
2026-08-23 11:15:48 +02:00
Nick Szaunig 79010a926b fix(backend): close two SSRF bypasses in webhook URL validation
1. The URL guard only classified IP-literal hostnames; any plain domain
   name (the common case) skipped validation entirely. Hostnames are now
   resolved via DNS and every returned address is checked against the
   same private/loopback/link-local ranges (net.BlockList), so an
   attacker-controlled domain pointed at an internal IP is rejected too.
2. fetch() follows redirects by default, so a registered public URL could
   302 to an internal target at delivery time. Delivery now uses
   redirect: 'manual' so such a redirect is never followed.
2026-08-23 11:08:40 +02:00
Nick Szaunig a0f7573af5 fix(backend): block SSRF via webhook subscription URLs
WebhooksService.create() rejected obviously private, loopback, or
link-local URLs (incl. cloud metadata 169.254.169.254 and the
decimal/octal/hex IPv4 shorthand encodings the WHATWG URL parser
normalizes) so an admin-configured webhook can no longer be used to
proxy requests into internal infrastructure. Registration-time only,
no DNS resolution (DNS-rebinding is out of scope for this check).
2026-08-23 07:05:53 +02:00
Nick Szaunig 2458927d38 fix(backend): DELETE /webhooks/:id returns 204 No Content
Matches REST convention and the frontend api-client's existing 204
fast-path, avoiding a JSON-parse crash on an empty response body.
2026-08-23 06:58:13 +02:00
Nick Szaunig 59fdc816a0 feat(backend): Webhooks mit HMAC-signierter Auslieferung (Modul 28)
WebhooksModule (WebhookSubscription: URL, Events, Secret, aktiv) mit
Event-Dispatcher, angehaengt an 10 Ereignisse in 8 bestehenden Services
(colony.created, inspection.created, queen.created, queen.replaced,
harvest.created, batch.created, batch.updated, product.stock_changed,
task.created, task.completed). Auslieferung ist best-effort und blockiert
die ausloesende Anfrage nicht bei Fehlern.
2026-08-23 06:56:56 +02:00
Nick Szaunig 8856d7d0f5 feat(frontend): Statistik-Seite mit animierten Auswertungen (Modul 27)
Tab-basierte /statistics-Seite (Voelker, Koeniginnen, Bienenstaende,
Varroa, Honig, Futter) mit animierten Balkendiagrammen, Sidebar-Eintrag
und Live-Verifikation ueber alle Tabs sowie Mobile-Breakpoint.
2026-08-23 06:41:16 +02:00
Nick Szaunig e8177432b0 feat(backend): Statistik- und Reporting-Endpunkte (Modul 27)
Aggregations-Endpunkte fuer Voelker, Koeniginnen, Bienenstaende, Varroa,
Honig und Fuetterung nach Lastenheft Abschnitt 4.26.
2026-08-23 06:33:50 +02:00
Nick Szaunig 8080a7f5ec feat(frontend): Toast-Bestaetigungen fuer Schnellerfassung
Sonner-Toaster global eingebunden. Erfolgs-Toast auf allen sechs
Schnellerfassungs-Formularen (Durchsicht, Varroa, Behandlung,
Fuetterung, Aufgabe, Ernte) - ohne Toast blieb nach dem Speichern
und Wegnavigieren keine sichtbare Bestaetigung, dass die Eingabe
gespeichert wurde. Wichtig fuer klares Feedback bei zeitkritischer
Erfassung am Bienenstand.
2026-08-23 06:24:14 +02:00
Nick Szaunig ecb3afe1f5 feat(frontend): Linksseitige Navigation, Seitenanimation, ADHS-freundliche Gruppierung
Ersetzt die horizontale Kopfzeilen-Navigation durch eine feste
Sidebar links: gruppiert nach Betrieb / Arbeit / Ernte & Lager /
Verwaltung mit Icon+Label je Eintrag, um kognitive Last zu
reduzieren. Aktiver Zustand klar farblich hervorgehoben. Mobile
Ansicht (Bottom-Nav + FAB) bleibt unveraendert bestehen.

Sanfte, kurze Seitenuebergangsanimation (PageTransition, respektiert
prefers-reduced-motion ueber motion-safe:) fuer Orientierung ohne
Ablenkung.

Ausserdem: wiederkehrenden Anzeigefehler in Status-Filtern behoben
(Select zeigte den rohen Sentinel-Wert "ALL" statt "Alle Status" an)
auf Voelker-, Honigchargen-, Aufgaben-, Koeniginnen- und
Bienenstand-Listen. Live in Desktop-, Tablet- und Mobilbreite
verifiziert, kein horizontales Overflow.
2026-08-23 06:18:35 +02:00
Nick Szaunig fb2b09a929 feat(frontend): Koeniginnenbewertung (Modul 31)
Bewertungsformular (Volksstaerke, Sanftmut, Wabensitz, Schwarm-
trieb, Brutbild, Honigertrag je 1-5) plus Verlauf auf der
Koenigin-Detailseite. Live verifiziert.
2026-08-22 23:01:28 +02:00
Nick Szaunig 50268fa591 feat(backend): Koeniginnenbewertung (Modul 31)
QueenEvaluation-Modell (Volksstaerke, Sanftmut, Wabensitz, Schwarm-
trieb, Brutbild, Honigertrag je Skala 1-5) als Sub-Ressource der
Queens-API: GET/POST /queens/:id/evaluations. Vorgezogen vor Modul
27 (Statistik), da dessen Koeniginnen-Auswertungen auf diesen Daten
aufbauen.
2026-08-22 22:55:36 +02:00
Nick Szaunig 0fc13952fb feat(frontend): Benachrichtigungssystem (Modul 26)
NotificationBell in der Kopfzeile: ungelesene Anzahl als Badge,
Dropdown mit Prioritaets-Badges (INFO/HINWEIS/WARNUNG/KRITISCH),
Klick markiert als gelesen und fuehrt zum verknuepften Objekt.
Ausserdem IDOR im Backend gefunden und behoben (siehe vorheriger
Commit) - live mit einem echten Datensatz verifiziert.
2026-08-22 22:50:45 +02:00
Nick Szaunig 75f0d4fd51 fix(backend): IDOR in POST /notifications/:id/read (Modul 26)
markRead() pruefte bisher nicht, ob die Benachrichtigung dem
anfragenden Nutzer gehoert - jeder authentifizierte Nutzer konnte
fremde Benachrichtigungen als gelesen markieren. Jetzt: 403 bei
fremder Benachrichtigung, weiterhin erlaubt fuer eigene und
Broadcast-Benachrichtigungen (userId = null). E2E-Regressionstest
ergaenzt.
2026-08-22 22:50:00 +02:00
Nick Szaunig a57cde1f9a feat(backend): Benachrichtigungssystem (Modul 26)
NotificationsModule: Notification-Modell mit Prioritaeten INFO/
HINWEIS/WARNUNG/KRITISCH, GET /notifications (eigene + Broadcast),
POST /notifications/:id/read. record() als interne API fuer
kuenftige Automatisierungsregeln (Modul 30) vorgesehen.
2026-08-22 22:43:38 +02:00
Nick Szaunig 9b078022ff feat(frontend): Kalender (Modul 25)
/calendar Monatsansicht mit Voelkerdaten aus Durchsichten, Aufgaben,
Behandlungen, Fuetterungen, Ernten und Koeniginnen-Zuweisungen;
Bienenstand-Filter, Monatsnavigation, Klick auf Ereignis fuehrt zum
verknuepften Objekt. Nav um Kalender-Link ergaenzt.
2026-08-22 22:39:48 +02:00
Nick Szaunig fef1177a2c feat(backend): Kalender (Modul 25)
CalendarModule mit GET /calendar?from=&to=&apiaryId= aggregiert
Durchsichten, Aufgaben, Behandlungen, Fuetterungen, Ernten und
Koeniginnen-Zuweisungen zu einem einheitlichen Ereignisfeed. Rein
lesende Aggregation, kein neues Datenmodell.
2026-08-22 22:34:47 +02:00
Nick Szaunig 8b42a86115 feat(frontend): Inventar (Modul 24)
/equipment, /equipment/new, /equipment/:id mit Zustandsaenderung.
statusTone()-Helper um Equipment-Zustaende (NEW/GOOD/WORN/DEFECTIVE)
erweitert, Nav-Dropdown um Inventar ergaenzt.
2026-08-22 22:30:46 +02:00
Nick Szaunig 3f20133d02 feat(backend): Inventar (Modul 24)
EquipmentModule (GET/POST /equipment, GET/PATCH /equipment/:id) fuer
Ausruestungsgegenstaende (Inventarnummer, Bezeichnung, Hersteller,
Kaufdatum, Zustand, Standort, Wartungsdatum). Neues Prisma-Modell
Equipment (Migration equipment).
2026-08-22 22:26:16 +02:00
Nick Szaunig f4df719df7 feat(frontend): Abfuellungen und Produkte (Modul 23)
/products, /products/new, /bottling/new, /bottling/:id. Verbindung
von der Honigcharge-Detailseite zur Abfuellung, Nav-Dropdown "Mehr"
fuer alle Rollen mit Produkte/Abfuellungen erweitert.
2026-08-22 22:21:22 +02:00
Nick Szaunig e82cae257b feat(backend): Abfuellungen und Produkte (Modul 23)
ProductsModule (GET/POST /products, GET/PATCH /products/:id) und
BottlingModule (GET/POST /bottling-batches) mit transaktionaler
Reduzierung der HoneyBatch-Restmenge beim Abfuellen; Ablehnung bei
Abfuellmenge > verfuegbarer Menge. Neue Prisma-Modelle Product und
BottlingBatch (Migration bottling_products).
2026-08-22 11:08:09 +02:00
Nick Szaunig 8b7fd61c1c feat(frontend): QR-Code-Dialog auf Objekt-Detailseiten (Modul 22)
Wiederverwendbarer QrCodeDialog (authentifizierter Blob-Fetch)
auf Volk-, Bienenstand-, Koenigin-, Honigcharge-Detailseiten und
je Lagerbehaelter-Zeile eingebunden.
2026-08-22 10:58:40 +02:00
Nick Szaunig dd88b6a92c feat(backend): QR-Code-System (Modul 22)
QrModule mit GET /qr/:entityType/:id.svg fuer colonies (Deep-Link
direkt zu /inspections/new), apiaries, queens, honey-batches und
storage-containers. Nutzt pdfkit-Nachbar qrcode zur SVG-Erzeugung.
2026-08-22 10:51:42 +02:00
Nick Szaunig a7ded02015 docs(tasks): v1.x/Spaeter-Roadmap gemaess Lastenheft Abschnitt 26
Module 22-33 (SOLL) mit Backend/Endpunkte/Frontend/Checkbox-Struktur
analog zum MVP spezifiziert. Spaeter-Liste (KANN) in software-seitig
umsetzbare Punkte und explizit nicht umsetzbare (native App, ML,
Sprach-/Sensorintegration) mit Begruendung aufgeteilt.
2026-08-22 10:47:31 +02:00
Nick Szaunig e7348b0b68 style(frontend): grundlegendes Redesign (Design-System, Farben, Typografie)
Neues Design-System: warme Honig-/Wachs-Farbpalette (Amber-Primaer,
Salbeigruen-Sekundaer) statt Standard-Graustufen, Space Grotesk fuer
Ueberschriften, Inter fuer Fliesstext, IBM Plex Mono fuer Codes/IDs.
Card-Komponente mit weicher Schattierung statt Ring, neuer
Badge-Warning-Variante und statusTone()-Helper fuer semantische
Status-Farben (Voelker/Honigchargen). Navigation umgebaut: aktiver
Link-Zustand, Hexagon-Markenzeichen, "Mehr"-Dropdown fuer Admin-Links.
Dashboard-Kacheln mit Icons/Farbakzenten, Login-Seite mit dezentem
Waben-Hintergrundmuster als wiederkehrendes Signature-Element.
2026-08-22 10:45:41 +02:00
Nick Szaunig 26b2eb0829 docs(tasks): MVP-Abschluss verifiziert
Gesamter Backend- und Frontend-Build gruen (lint/typecheck/test/
test:e2e/build), Rueckverfolgbarkeitskette Volk -> Durchsicht ->
Behandlung -> Ernte -> Schleuderung -> Honigcharge -> Lager
end-to-end manuell verifiziert, Erfolgskriterien aus Abschnitt 30
stichprobenartig geprueft. MVP gemaess TASKS.md vollstaendig
implementiert.
2026-08-22 10:14:32 +02:00
Nick Szaunig ed108045af feat(frontend): Export-Buttons fuer Voelker, Honigchargen (Modul 21)
CSV-Export in den Listen-Views (Voelker, Honigchargen) und
PDF-Rueckverfolgbarkeitsbericht auf der Honigcharge-Detailseite.
apiFetchBlob() im API-Client ergaenzt fuer authentifizierte
Blob-Downloads (JWT im Authorization-Header statt Cookie).
2026-08-22 10:10:23 +02:00
Nick Szaunig c281d04b16 feat(backend): Datenexport-Modul (Modul 21)
ExportModule mit GET /export/colonies.csv, /export/honey-batches.csv,
/export/:entity.json (apiaries/colonies/queens/honey-batches/tasks)
und /export/:report.pdf (honey-batch-traceability via pdfkit).
Ausserdem fehlendes "typecheck"-Skript in package.json ergaenzt
(von CLAUDE.md dokumentiert, war aber nicht vorhanden).
2026-08-22 10:05:46 +02:00
Nick Szaunig ab167d4e53 docs(architecture): Backup-/Restore-Strategie dokumentiert (Modul 20)
Migrations-, Backup- und Restore-Vorgehen in ARCHITECTURE.md
Abschnitt 8 festgehalten. migrate deploy/dev bereits einziger
Migrationsweg (kein db push), /health/ready prueft DB bereits.
2026-08-22 09:57:18 +02:00
Nick Szaunig ad787f8155 feat(frontend): Audit-Log-Ansicht (Modul 19)
Neue Seite /settings/audit-log (nur Administrator) mit Filter
nach Entitätstyp/-ID, Navigation um Audit-Log-Link ergaenzt.
2026-08-22 09:53:36 +02:00
Nick Szaunig 8a6769dbfd feat(backend): Audit-Log-Abfrage (Modul 19)
AuditModule um GET /audit-log?entityType=&entityId= erweitert
(nur Administrator). Zusaetzlich Jest-E2E-Parallelitaet begrenzt
(maxWorkers: 4), um wiederkehrende Verbindungsengpaesse gegen
Postgres unter vielen parallelen Testsuiten zu vermeiden.
2026-08-22 06:00:52 +02:00
Nick Szaunig 4945e92985 docs: API/Rollen-Prüfung abgeschlossen (Modul 16-18)
Verifiziert: alle 73 Endpunkte über 17 Tags in /api/v1/docs
sichtbar; jeder Mutations-Endpunkt trägt @Roles (oder @Public bei
Login/Refresh/Logout), lesende Endpunkte sind für alle
authentifizierten Rollen offen (RolesGuard-Standardverhalten).
2026-08-22 05:52:10 +02:00
Nick Szaunig 45646833b9 feat(frontend): Responsive Smartphone-Oberfläche (Modul 15)
Mobile Bottom-Navigation und permanent erreichbare "+ Neu"-
Schnellaktion (Durchsicht/Varroa/Behandlung/Fütterung/Aufgabe/Ernte).
Header-Navigation überarbeitet, damit sie bei schmaler Breite intern
scrollt statt die Seite horizontal zu sprengen. Verifiziert bei
Smartphone- (390px) und Tablet-Breite (820px).
2026-08-22 05:50:07 +02:00
Nick Szaunig 166979ff3c feat(frontend): Suche und Filter (Modul 14)
/search-Seite über Bienenstände, Völker, Königinnen, Aufgaben und
Honigchargen. Filterleisten (Status/Standort) in den Listen-Views
für Bienenstände, Völker, Königinnen und Honigchargen.
2026-08-22 05:39:59 +02:00
Nick Szaunig 146b56d683 feat(backend): Suche und Filter (Modul 14)
SearchModule mit GET /search?q= über Bienenstände, Völker,
Königinnen, Aufgaben und Honigchargen. Kombinierbare Filter
(Status, Standort, Bienenstand) in den bestehenden List-Endpunkten.
2026-08-22 05:29:47 +02:00
Nick Szaunig 55b9bc2587 feat(frontend): Dashboard-Ansicht (Modul 13)
/dashboard als Startseite nach Login mit Kennzahlen-Kacheln und
Hinweisen (überfällige Aufgaben, auffällige Völker, Lagerartikel
unter Mindestbestand).
2026-08-22 05:22:50 +02:00
Nick Szaunig 09407d4378 feat(backend): Dashboard-Modul (Modul 13)
DashboardModule mit Kennzahlen-Übersicht sowie Alerts fuer
überfällige Aufgaben, auffällige Völker und Lagerartikel unter
Mindestbestand.
2026-08-22 05:15:25 +02:00
Nick Szaunig 7ec20199d3 feat(frontend): Lagerverwaltung-Ansicht (Modul 12)
/storage mit Lagerbehälter- und Lagerartikelverwaltung inkl.
Bestandsbewegungen (Zugang/Abgang/Umlagerung/Korrektur).
2026-08-22 05:12:23 +02:00
Nick Szaunig 15b6ea4edb feat(backend): Lagerverwaltung-Modul (Modul 12)
StorageModule mit Lagerbehaeltern, Lagerartikeln und
Bestandsbewegungen (Zugang/Abgang/Umlagerung/Korrektur), die den
Artikelbestand automatisch fortschreiben.
2026-08-22 05:03:48 +02:00
Nick Szaunig f3d99524ff feat(frontend): Honigchargen-Ansichten (Modul 11)
Chargenliste mit Anlage-Dialog sowie Detailseite mit
Honigmessungen und Rückverfolgbarkeits-Ansicht bis zu den
Ursprungsvölkern.
2026-08-22 04:58:47 +02:00
Nick Szaunig cf625552d1 feat(backend): Honigchargen-Modul (Modul 11)
HoneyBatchesModule inkl. Honigmessungen und
Rückverfolgbarkeits-Endpunkt, der eine Charge über die Schleuderung
und Ernte bis zu den Ursprungsvölkern zurückverfolgt.
2026-08-22 00:37:09 +02:00
Nick Szaunig 5c2c0608d1 feat(frontend): Ernten- und Schleuderungs-Ansichten (Modul 10)
/harvests/new (Bienenstand + Mehrfachauswahl Völker), /harvests/[id]
und /extractions/new (Mehrfachauswahl Ernten). Bienenstand- und
Ernte-Detailseiten verlinken direkt in die Kette.
2026-08-22 00:33:13 +02:00
Nick Szaunig 340397b34d feat(backend): Ernten- und Schleuderungs-Module (Modul 10)
HarvestsModule und ExtractionsModule als Fundament der
Rückverfolgbarkeitskette: eine Ernte kann mehrere Völker umfassen,
eine Schleuderung mehrere Ernten zusammenführen.
2026-08-22 00:25:36 +02:00
Nick Szaunig d71785d531 feat(frontend): Fütterungen-Modul (Modul 9)
Mobile-optimiertes Erfassungsformular unter /feedings/new sowie
"Fütterung"-Ansicht mit Mengensumme auf der Volk-Detailseite.
2026-08-22 00:20:35 +02:00
Nick Szaunig cc0aaf0a31 feat(backend): Fütterungen-Modul (Modul 9)
FeedingsModule mit Erfassung, Colony-Historie und
Mengensummen-Endpunkt je Einheit.
2026-08-22 00:14:41 +02:00
Nick Szaunig 1f71ac9e3c feat(frontend): Behandlungen-Modul (Modul 8)
Mobile-optimiertes Erfassungsformular unter /treatments/new sowie
"Behandlungen"-Ansicht auf der Volk-Detailseite.
2026-08-22 00:10:46 +02:00
Nick Szaunig 61e0bbe794 feat(backend): Behandlungen-Modul (Modul 8)
TreatmentsModule mit Erfassung und Colony-Historie-Endpunkt fuer
Tierarzneimittel-Behandlungen.
2026-08-22 00:04:50 +02:00
Nick Szaunig 66c14cf306 feat(frontend): Varroamessungen-Modul (Modul 7)
Mobile-optimiertes Erfassungsformular unter /varroa/new sowie
"Varroa"-Ansicht mit Verlaufsgrafik auf der Volk-Detailseite.
2026-08-22 00:01:43 +02:00
Nick Szaunig f363bffed1 feat(backend): Varroamessungen-Modul (Modul 7)
VarroaModule mit Erfassung und Colony-Historie-Endpunkt fuer
Varroamessungen.
2026-08-21 23:56:06 +02:00
Nick Szaunig 632d382474 feat(frontend): Aufgaben-Modul (Modul 6)
Aufgabenliste mit Status-/Prioritätsfilter und Erledigen-Aktion
sowie Formular zum Anlegen neuer Aufgaben.
2026-08-21 23:52:32 +02:00
Nick Szaunig 7bba168bd6 feat(backend): Aufgaben-Modul (Modul 6)
TasksModule mit CRUD, Status-/Prioritätsfilter und
Erledigen-Endpunkt für polymorph verknüpfte Aufgaben.
2026-08-21 23:43:47 +02:00
Nick Szaunig 829753ec90 feat(frontend): Durchsichten-Modul (Modul 5)
Mobile-optimiertes Schnellformular unter /inspections/new sowie
"Durchsichten"-Ansicht auf der Volk-Detailseite.
2026-08-21 18:51:47 +02:00
Nick Szaunig 9b59e4bb35 feat(backend): Durchsichten-Modul (Modul 5)
InspectionsModule mit CRUD, Colony-Historie-Endpunkt und
Folgeaufgaben-Erstellung aus einer Durchsicht heraus.
2026-08-21 18:43:08 +02:00
Nick Szaunig 49ce23147c feat(frontend): Koeniginnen-Views (Modul 4)
/queens: Liste; /queens/new: Anlegeformular; /queens/[id]: Bearbeiten
(Zucht/Herkunft/Status/Notizen), "Volk zuweisen"-Dialog (nur ACTIVE
Voelker zur Auswahl) und Volk-Historie. Bearbeitungsformular initialisiert
Zustand direkt aus der geladenen Queen (gleiches Key-Reset-Pattern wie
bei Apiaries/Colonies).

Nav-Leiste und proxy.ts-Matcher um /queens erweitert.

10 Vitest-Tests, Lint, Typecheck und Build gruen. Kompletter Flow
(Anlegen, Volk zuweisen, Historie) live im Browser gegen den echten
Server verifiziert.
2026-08-21 18:35:33 +02:00
Nick Szaunig b18d61640d feat(backend): Koeniginnen-Modul (Modul 4)
QueensModule: CRUD + assign-colony + history, entsprechend Lastenheft
§4.5 (Koeniginnenverwaltung). assign-colony schliesst die offene
QueenColonyHistory der bisherigen Koenigin am Zielvolk (falls vorhanden)
sowie eine evtl. noch offene History dieser Koenigin an einem anderen
Volk, bevor Colony.currentQueenId gesetzt und ein neuer History-Eintrag
angelegt wird - damit bleibt jeder Koeniginnenwechsel nachvollziehbar,
wie gefordert. Rollen wie bei Apiaries/Colonies: Lesen offen, Schreiben
auf ADMIN/BEEKEEPER beschraenkt.

5 Unit-Tests + 2 e2e-Tests, Lint, Typecheck und Build gruen.
2026-08-21 18:26:55 +02:00
Nick Szaunig 6e328cdacb feat(frontend): Voelker-Views (Modul 3) + Refresh-Token-Race-Fix
/colonies: Liste; /colonies/new: Anlegeformular mit Bienenstand-/
Volkstyp-Auswahl; /colonies/[id]: Bearbeiten, Aktionen (Umsetzen,
Ableger bilden, Vereinigen, Aufloesen je als Dialog/Button) und
Historie-Anzeige. Aktionen deaktiviert, sobald das Volk nicht mehr
ACTIVE ist (Frontend-Pendant zum neuen Backend-Guard).

Beim Live-Testen im Browser gefunden und behoben:
- Echter Root-Cause fuer den fruehren Session-Abbruch nach Reload: der
  Refresh-Token ist einmalig/rotierend, und React StrictMode ruft den
  Session-Restore-Effect im Dev-Modus doppelt auf. Der vorherige
  AbortController-Fix verhinderte nur, dass der Client die verworfene
  Antwort nutzt - er verhinderte NICHT, dass die zweite Anfrage bereits
  serverseitig verarbeitet wurde, bevor abort() griff. Fix: echtes
  Request-Deduplizieren in refreshRequest() (gemeinsames In-Flight-
  Promise) statt Abbruchversuch - der Standardansatz fuer rotierende
  Refresh-Token.
- Historie-Text beim Vereinigen war fuer die aufnehmende Seite falsch
  ("Entstanden durch Vereinigung" impliziert Neuanlage) - jetzt
  "Anderes Volk eingegliedert".

Nav-Leiste und proxy.ts-Matcher um /colonies erweitert.

9 Vitest-Tests, Lint, Typecheck und Build gruen. Kompletter Flow
(Anlegen, Umsetzen, Ableger bilden, Vereinigen, Aufloesen, Historie)
live im Browser gegen den echten Server verifiziert, inkl. Rollen-
filterung in den Aktions-Dialogen.
2026-08-21 18:21:10 +02:00
Nick Szaunig a71b31df38 fix(backend): Status-Guard fuer Move/Split/Merge auf inaktiven Voelkern
Beim Live-Test im Browser gefunden: move/split/merge liessen sich auch
auf einem bereits DISSOLVED/MERGED-Volk aufrufen und wuerden dort
klaglos durchlaufen - ein Datenintegritaetsproblem, kein reines
UI-Detail. assertActive() wirft jetzt ConflictException (409), wenn
das betroffene Volk (bzw. bei merge: Ziel- und alle zu vereinigenden
Voelker) nicht ACTIVE ist.

Neuer Unit-Test + neuer e2e-Test (dissolve, danach move -> 409).
2026-08-21 18:20:48 +02:00
Nick Szaunig d3f321f1b5 feat(backend): Voelker-Modul (Modul 3)
ColoniesModule: CRUD + Move/Split/Merge/Dissolve/History, entsprechend
Lastenheft §4.4 (Lebenszyklus eines Volkes):
- move: Standortwechsel, protokolliert in ColonyApiaryHistory.
- split: erzeugt Ableger als neues Volk, Abstammung ueber
  ColonyLineage(SPLIT, von Mutter- zu Ablegervolk).
- merge: vereinigtes Volk bleibt historisch erhalten (status MERGED,
  deactivatedAt gesetzt statt geloescht), ColonyLineage(MERGE) je
  vereinigtem Volk zum Zielvolk.
- dissolve: Volk wird deaktiviert (status DISSOLVED), Dokumentation
  bleibt erhalten.
- history: aggregiert Standort-Historie, Koeniginnen-Historie und
  Abstammungslinien (als Quelle und als Ergebnis).

Move/Split/Merge laufen transaktional (prisma.$transaction). Rollen wie
bei Apiaries: Lesen offen, Schreiben auf ADMIN/BEEKEEPER beschraenkt.

9 Unit-Tests + 6 e2e-Tests (inkl. vollstaendigem Move->Split->Merge->
Dissolve-Flow und Rollenpruefung), Lint, Typecheck und Build gruen.
2026-08-21 09:06:51 +02:00
Nick Szaunig d59153a697 feat(frontend): Bienenstaende-Views (Modul 2)
/apiaries: Liste mit Status-Badge; /apiaries/new: Anlegen-Formular;
/apiaries/[id]: Bearbeiten (Name/Standort/Adresse/Notizen) + Deaktivieren.
Editierbares Formular liest den Ausgangszustand direkt aus der geladenen
Apiary (kein useEffect+setState-Sync, React-Key-Reset-Pattern statt
Effect - vermeidet kaskadierende Renders).

(app)/layout.tsx: einfache Nav-Leiste (Bienenstände fuer alle, Benutzer
nur fuer Admins) - Platzhalter bis zur echten Bottom-Nav/Sidebar aus
Modul 15. proxy.ts-Matcher um /apiaries/* erweitert.

Live im Browser gefunden+behoben: Base-UI-Button meldete fehlende
native Button-Semantik beim Rendern als Link (`nativeButton={false}`
noetig, wenn Button auf ein <a> zeigt).

8 Vitest-Tests, Lint, Typecheck und Build gruen. Kompletter Flow
(Anlegen, Liste, Bearbeiten, Deaktivieren, Navigation) live verifiziert.
2026-08-21 08:59:18 +02:00
Nick Szaunig 5c92c810e1 feat(backend): Bienenstaende-Modul (Modul 2)
ApiariesModule: CRUD + Deactivate. Lesen fuer alle authentifizierten
Rollen offen, Schreiben (Anlegen/Aendern/Deaktivieren) auf ADMIN und
BEEKEEPER beschraenkt (Lastenheft §3.2: Imker verwaltet Bienenstaende).
Audit-Log-Eintraege bei Create/Update/Deactivate ueber AuditLogService.

4 Unit-Tests + 4 e2e-Tests (inkl. Rollenpruefung READONLY vs. BEEKEEPER),
Lint, Typecheck und Build gruen.
2026-08-21 08:50:50 +02:00
Nick Szaunig 029484f9d5 feat(frontend): Login + Benutzerverwaltung (Modul 1)
/login: Formular gegen AuthProvider (Access-Token im Memory, Refresh
per httpOnly-Cookie), Fehlermeldung bei falschen Zugangsdaten.

(app)/layout.tsx: geschuetztes Layout, leitet ohne Session zu /login um,
Header mit Nutzer-E-Mail + Abmelden. settings/layout.tsx: zusaetzliches
Admin-Gate fuer /settings/users.

/settings/users: Liste, Anlegen-Dialog (shadcn/Base UI Dialog+Select),
Deaktivieren - alles ueber TanStack Query gegen die Users-API.

proxy.ts (Next 16 Nachfolger von middleware.ts): optimistische
Cookie-Praesenz-Pruefung fuer /login und /settings/*, echte
Autorisierung bleibt serverseitig (RolesGuard).

Backend-Fixes waehrend Browser-Verifikation gefunden und behoben:
- CORS fehlte komplett (app.enableCors, FRONTEND_ORIGIN) - jeder
  Cross-Origin-Request von :3000 nach :3001 wurde geblockt.
- React-StrictMode-Doppel-Effect liess zwei parallele Refresh-Aufrufe
  laufen; da Refresh-Tokens rotierend/einmalig sind, gewann einer und
  der andere bekam 401 -> Nutzer flog nach jedem Reload aus der Session.
  Fix: AbortController im Session-Restore-Effect.
- Manifest verwies auf nicht existente PNG-Icons (404); durch
  public/icon.svg ersetzt.

7 Vitest-Tests (Login-Formular, Users-Liste, JWT-Decode), Lint,
Typecheck und Build gruen. Kompletter Flow (Login, Reload-Persistenz,
Benutzer anlegen/deaktivieren, Abmelden, Redirect ohne Session) live
im Browser gegen den echten Server verifiziert.
2026-08-21 08:45:37 +02:00
Nick Szaunig 370102387f fix(backend): Refresh-Token als httpOnly-Cookie statt Response-Body
ARCHITECTURE.md §3 legt fest: Access-Token im Memory/Context, Refresh-Token
als httpOnly-Cookie. Vorherige Implementierung gab beide Tokens im JSON-Body
zurueck - vor dem Frontend-Aufbau (das genau gegen diesen Vertrag baut)
korrigiert.

/api/v1/auth/login und /refresh setzen jetzt ein httpOnly-Cookie
(Path /api/v1/auth, SameSite=Lax, secure in production) und liefern nur
noch { accessToken } im Body. /refresh und /logout lesen den Refresh-Token
aus dem Cookie statt aus dem Request-Body.

e2e-Tests und Live-Smoke-Test (curl mit Cookie-Jar) verifizieren
Login/Rotation/Logout-Invalidierung. 12 e2e- + 26 Unit-Tests, Lint,
Typecheck und Build gruen.
2026-08-21 08:27:58 +02:00
Nick Szaunig ae8f6d4435 feat(backend): Auth/Users/Audit-Modul (Modul 1)
AuthModule: Login/Refresh/Logout mit JWT-Access-Token + rotierendem,
gehashtem Refresh-Token (RefreshToken-Tabelle). JwtAuthGuard + RolesGuard
global via APP_GUARD registriert (gilt fuer jeden kuenftigen Endpunkt),
@Public()-Decorator als Ausnahme (Auth-Endpunkte, /health).

UsersModule: CRUD + Deactivate, nur ADMIN-Rolle, Passwoerter mit bcrypt
gehasht, nie im Response enthalten.

AuditModule: AuditLogService (Grundstruktur), wird von UsersService bei
create/update/deactivate injiziert und geschrieben.

Geteilte App-Konfiguration (Prefix/Versioning/Pipes/Helmet) aus main.ts
in src/app.config.ts extrahiert; e2e-Tests nutzten das vorher nicht und
bekamen dadurch 404 auf jede /api/v1/... Route - jetzt ueber
test/utils/create-test-app.ts geteilt, betrifft alle kuenftigen
e2e-Tests gleichermassen.

prisma/seed.ts legt einen Admin-User fuer lokale Entwicklung an.

26 Unit-Tests + 11 e2e-Tests (gegen echte Postgres-Testdatenbank) gruen,
Lint/Typecheck/Build gruen, Login/Refresh/Logout/Rollenpruefung manuell
gegen laufenden Server verifiziert.
2026-08-21 08:22:16 +02:00
464 changed files with 32826 additions and 298 deletions

No files matched your search

+13
View File
@@ -95,7 +95,20 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN. 4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung. 5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
## 7. Kubernetes ## 7. Kubernetes
`deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`). `deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`).
## 8. Backup & Restore
**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. `migrate deploy` läuft als eigener Schritt vor dem Rollout (z. B. Kubernetes-`Job` oder manueller Schritt in der Deploy-Pipeline), nicht im `CMD` des Runtime-Images, damit mehrere Pod-Replicas nicht gleichzeitig Migrationen ausführen.
**Datensicherung:** Die PostgreSQL-Instanz selbst ist bestehende Cluster-Infrastruktur (siehe CLAUDE.md, "keine eigene DB-Infrastruktur"); Backup-Ausführung liegt beim Cluster-/DBA-Betrieb. Empfohlene Strategie dort:
- Tägliches logisches Backup via `pg_dump -Fc` (custom format, komprimiert, für `pg_restore` geeignet), Aufbewahrung nach Retention-Policy des Clusters.
- Kontinuierliches WAL-Archiving/PITR (Point-in-Time Recovery), falls vom Cluster unterstützt, für Wiederherstellung auf einen beliebigen Zeitpunkt zwischen den täglichen Dumps.
- Restore-Test: `pg_restore` eines Dumps in eine isolierte Datenbank, danach `npx prisma migrate deploy` zur Kontrolle, dass der Migrationsstand konsistent ist (`prisma migrate status`).
**Restore-Ablauf (Kurzfassung):** 1) Zielinstanz/-datenbank bereitstellen. 2) `pg_restore` (oder WAL-Replay bis zum Zielzeitpunkt bei PITR). 3) `npx prisma migrate status` prüfen. 4) `npx prisma migrate deploy` falls seit dem Dump neue Migrationen hinzugekommen sind. 5) `GET /health/ready` gegen die wiederhergestellte Instanz aufrufen, um DB-Erreichbarkeit zu bestätigen, bevor Verkehr umgeschaltet wird.
+11
View File
@@ -8,3 +8,14 @@ JWT_ACCESS_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="7d" JWT_REFRESH_EXPIRES_IN="7d"
PORT=3001 PORT=3001
FRONTEND_ORIGIN="http://localhost:3000"
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
# local HTTP development - every real deployment must leave this unset (or
# "true").
COOKIE_SECURE=false
# Lokales Verzeichnis fuer hochgeladene Dokumente/Bilder (Modul 29).
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
DOCUMENTS_STORAGE_PATH="./uploads"
+7
View File
@@ -2,6 +2,9 @@ node_modules
dist dist
coverage coverage
*.tsbuildinfo *.tsbuildinfo
prisma/seed.js
prisma/seed.js.map
prisma/seed.d.ts
# Keep environment variables out of version control # Keep environment variables out of version control
.env .env
@@ -11,3 +14,7 @@ coverage
# Local dev/test PostgreSQL data dir (not for production use) # Local dev/test PostgreSQL data dir (not for production use)
.devdb/ .devdb/
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
/uploads/
/test-uploads/
+403 -17
View File
@@ -16,14 +16,18 @@
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.7", "@nestjs/swagger": "^11.4.7",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.9.1", "@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.3.0", "helmet": "^8.3.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -34,10 +38,14 @@
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@@ -2657,11 +2665,33 @@
} }
} }
}, },
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": "^14.21.3 || >=16"
@@ -3133,6 +3163,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@@ -3268,6 +3307,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": { "node_modules/@types/cookiejar": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -3507,6 +3556,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -3548,6 +3607,16 @@
"@types/passport": "*" "@types/passport": "*"
} }
}, },
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@@ -3559,6 +3628,16 @@
"pg-types": "^2.2.0" "pg-types": "^2.2.0"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.1", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -4777,7 +4856,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -4787,7 +4865,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -4996,7 +5073,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5102,6 +5178,24 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -5318,7 +5412,6 @@
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -5569,7 +5662,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -5582,7 +5674,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": { "node_modules/combined-stream": {
@@ -5699,6 +5790,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -5943,6 +6053,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -6072,6 +6191,12 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -6082,6 +6207,12 @@
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.4.1", "version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
@@ -6199,7 +6330,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/empathic": { "node_modules/empathic": {
@@ -6735,7 +6865,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": { "node_modules/fast-diff": {
@@ -6925,6 +7054,32 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkit/node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -7123,7 +7278,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@@ -7611,7 +7765,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -8547,6 +8700,12 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -8734,6 +8893,25 @@
"integrity": "sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==", "integrity": "sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -9636,7 +9814,6 @@
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -9649,6 +9826,12 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9730,7 +9913,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -9815,6 +9997,20 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
@@ -10041,6 +10237,23 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postgres": { "node_modules/postgres": {
"version": "3.4.7", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
@@ -10255,6 +10468,127 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.3", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -10399,7 +10733,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -10415,6 +10748,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-cwd": { "node_modules/resolve-cwd": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
@@ -10469,6 +10808,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/ret": { "node_modules/ret": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
@@ -10660,6 +11005,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -10915,7 +11266,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -10946,7 +11296,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -11346,6 +11695,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -11747,6 +12102,32 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -12135,6 +12516,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -12156,7 +12543,6 @@
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
+9
View File
@@ -13,6 +13,7 @@
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"typecheck": "tsc --noEmit",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
@@ -27,14 +28,18 @@
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.7", "@nestjs/swagger": "^11.4.7",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.9.1", "@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.3.0", "helmet": "^8.3.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -45,10 +50,14 @@
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
migrations: { migrations: {
path: "prisma/migrations", path: "prisma/migrations",
seed: "ts-node prisma/seed.ts",
}, },
datasource: { datasource: {
url: process.env["DATABASE_URL"], url: process.env["DATABASE_URL"],
@@ -0,0 +1,59 @@
-- CreateEnum
CREATE TYPE "ProductStatus" AS ENUM ('ACTIVE', 'DISCONTINUED');
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sku" TEXT,
"packagingSize" DOUBLE PRECISION,
"weight" DOUBLE PRECISION,
"salesUnit" TEXT,
"status" "ProductStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BottlingBatch" (
"id" TEXT NOT NULL,
"honeyBatchId" TEXT NOT NULL,
"productId" TEXT,
"date" TIMESTAMP(3) NOT NULL,
"jarSize" DOUBLE PRECISION NOT NULL,
"jarCount" INTEGER NOT NULL,
"totalAmount" DOUBLE PRECISION NOT NULL,
"bestBeforeDate" TIMESTAMP(3) NOT NULL,
"lotNumber" TEXT NOT NULL,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BottlingBatch_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Product_sku_key" ON "Product"("sku");
-- CreateIndex
CREATE INDEX "Product_status_idx" ON "Product"("status");
-- CreateIndex
CREATE UNIQUE INDEX "BottlingBatch_lotNumber_key" ON "BottlingBatch"("lotNumber");
-- CreateIndex
CREATE INDEX "BottlingBatch_honeyBatchId_idx" ON "BottlingBatch"("honeyBatchId");
-- CreateIndex
CREATE INDEX "BottlingBatch_productId_idx" ON "BottlingBatch"("productId");
-- AddForeignKey
ALTER TABLE "BottlingBatch" ADD CONSTRAINT "BottlingBatch_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BottlingBatch" ADD CONSTRAINT "BottlingBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BottlingBatch" ADD CONSTRAINT "BottlingBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
-- CreateEnum
CREATE TYPE "EquipmentCondition" AS ENUM ('NEW', 'GOOD', 'WORN', 'DEFECTIVE');
-- CreateTable
CREATE TABLE "Equipment" (
"id" TEXT NOT NULL,
"inventoryNumber" TEXT NOT NULL,
"name" TEXT NOT NULL,
"manufacturer" TEXT,
"purchaseDate" TIMESTAMP(3),
"condition" "EquipmentCondition" NOT NULL DEFAULT 'GOOD',
"location" TEXT,
"maintenanceDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Equipment_inventoryNumber_key" ON "Equipment"("inventoryNumber");
-- CreateIndex
CREATE INDEX "Equipment_condition_idx" ON "Equipment"("condition");
@@ -0,0 +1,25 @@
-- CreateEnum
CREATE TYPE "NotificationPriority" AS ENUM ('INFO', 'HINWEIS', 'WARNUNG', 'KRITISCH');
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT,
"priority" "NotificationPriority" NOT NULL,
"message" TEXT NOT NULL,
"relatedType" TEXT,
"relatedId" TEXT,
"read" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read");
-- CreateIndex
CREATE INDEX "Notification_priority_idx" ON "Notification"("priority");
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "QueenEvaluation" (
"id" TEXT NOT NULL,
"queenId" TEXT NOT NULL,
"colonyId" TEXT,
"date" TIMESTAMP(3) NOT NULL,
"colonyStrength" INTEGER NOT NULL,
"gentleness" INTEGER NOT NULL,
"combBuild" INTEGER NOT NULL,
"swarmingTendency" INTEGER NOT NULL,
"broodPattern" INTEGER NOT NULL,
"honeyYield" INTEGER NOT NULL,
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "QueenEvaluation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "QueenEvaluation_queenId_idx" ON "QueenEvaluation"("queenId");
-- CreateIndex
CREATE INDEX "QueenEvaluation_colonyId_idx" ON "QueenEvaluation"("colonyId");
-- AddForeignKey
ALTER TABLE "QueenEvaluation" ADD CONSTRAINT "QueenEvaluation_queenId_fkey" FOREIGN KEY ("queenId") REFERENCES "Queen"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QueenEvaluation" ADD CONSTRAINT "QueenEvaluation_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QueenEvaluation" ADD CONSTRAINT "QueenEvaluation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "WebhookSubscription" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"events" TEXT[],
"secret" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WebhookSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WebhookSubscription_active_idx" ON "WebhookSubscription"("active");
-- AddForeignKey
ALTER TABLE "WebhookSubscription" ADD CONSTRAINT "WebhookSubscription_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "DocumentEntityType" AS ENUM ('APIARY', 'COLONY', 'QUEEN', 'INSPECTION', 'HONEY_BATCH');
-- CreateTable
CREATE TABLE "Document" (
"id" TEXT NOT NULL,
"entityType" "DocumentEntityType" NOT NULL,
"entityId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"storagePath" TEXT NOT NULL,
"uploadedByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Document_entityType_entityId_idx" ON "Document"("entityType", "entityId");
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedByUserId_fkey" FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE "HoneyBatchRelocation" (
"id" TEXT NOT NULL,
"honeyBatchId" TEXT NOT NULL,
"fromStorageContainerId" TEXT,
"toStorageContainerId" TEXT NOT NULL,
"userId" TEXT,
"notes" TEXT,
"relocatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HoneyBatchRelocation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "HoneyBatchRelocation_honeyBatchId_relocatedAt_idx" ON "HoneyBatchRelocation"("honeyBatchId", "relocatedAt");
-- AddForeignKey
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_fromStorageContainerId_fkey" FOREIGN KEY ("fromStorageContainerId") REFERENCES "StorageContainer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_toStorageContainerId_fkey" FOREIGN KEY ("toStorageContainerId") REFERENCES "StorageContainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "AutomationTriggerType" AS ENUM ('QUEENLESS_NO_EGGS', 'VARROA_THRESHOLD', 'FEED_STATUS_CRITICAL');
-- CreateTable
CREATE TABLE "AutomationRule" (
"id" TEXT NOT NULL,
"triggerType" "AutomationTriggerType" NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"config" JSONB,
"actionTemplate" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutomationRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AutomationRule_triggerType_key" ON "AutomationRule"("triggerType");
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Queen" ADD COLUMN "motherQueenId" TEXT;
-- CreateIndex
CREATE INDEX "Queen_motherQueenId_idx" ON "Queen"("motherQueenId");
-- AddForeignKey
ALTER TABLE "Queen" ADD CONSTRAINT "Queen_motherQueenId_fkey" FOREIGN KEY ("motherQueenId") REFERENCES "Queen"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,37 @@
-- CreateEnum
CREATE TYPE "TaskTemplateRecurrenceType" AS ENUM ('YEARLY', 'MONTHLY', 'INTERVAL_DAYS');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceTemplateId" TEXT;
-- CreateTable
CREATE TABLE "TaskTemplate" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"priority" "TaskPriority" NOT NULL DEFAULT 'NORMAL',
"assignedUserId" TEXT,
"createdByUserId" TEXT,
"recurrenceType" "TaskTemplateRecurrenceType" NOT NULL,
"recurrenceMonth" INTEGER,
"recurrenceDayOfMonth" INTEGER,
"intervalDays" INTEGER,
"nextDueDate" TIMESTAMP(3) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TaskTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TaskTemplate_active_nextDueDate_idx" ON "TaskTemplate"("active", "nextDueDate");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_sourceTemplateId_fkey" FOREIGN KEY ("sourceTemplateId") REFERENCES "TaskTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskTemplate" ADD CONSTRAINT "TaskTemplate_assignedUserId_fkey" FOREIGN KEY ("assignedUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskTemplate" ADD CONSTRAINT "TaskTemplate_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- CreateEnum
CREATE TYPE "SwarmMood" AS ENUM ('NONE', 'LOW', 'HIGH');
-- CreateEnum
CREATE TYPE "SwarmControlAction" AS ENUM ('NONE', 'QUEEN_CELLS_REMOVED', 'ARTIFICIAL_SWARM', 'COLONY_DIVIDED', 'OTHER');
-- CreateTable
CREATE TABLE "SwarmControlCheck" (
"id" TEXT NOT NULL,
"colonyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"queenCellsFound" INTEGER,
"queenCellsCapped" BOOLEAN NOT NULL DEFAULT false,
"swarmMood" "SwarmMood" NOT NULL DEFAULT 'NONE',
"actionTaken" "SwarmControlAction" NOT NULL DEFAULT 'NONE',
"notes" TEXT,
"nextCheckDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SwarmControlCheck_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SwarmControlCheck_colonyId_date_idx" ON "SwarmControlCheck"("colonyId", "date");
-- AddForeignKey
ALTER TABLE "SwarmControlCheck" ADD CONSTRAINT "SwarmControlCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SwarmControlCheck" ADD CONSTRAINT "SwarmControlCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AuditAction" ADD VALUE 'LOGIN';
ALTER TYPE "AuditAction" ADD VALUE 'LOGIN_FAILED';
ALTER TYPE "AuditAction" ADD VALUE 'LOGOUT';
ALTER TYPE "AuditAction" ADD VALUE 'TOKEN_REFRESHED';
+320 -16
View File
@@ -37,18 +37,27 @@ model User {
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
inspections Inspection[] inspections Inspection[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
treatments Treatment[] swarmControlChecks SwarmControlCheck[]
feedings Feeding[] treatments Treatment[]
harvests Harvest[] feedings Feeding[]
extractions Extraction[] harvests Harvest[]
honeyMeasurements HoneyMeasurement[] extractions Extraction[]
stockMovements StockMovement[] honeyMeasurements HoneyMeasurement[]
assignedTasks Task[] @relation("TaskAssignee") stockMovements StockMovement[]
createdTasks Task[] @relation("TaskCreator") assignedTasks Task[] @relation("TaskAssignee")
colonyMoves ColonyApiaryHistory[] createdTasks Task[] @relation("TaskCreator")
auditLogs AuditLog[] assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
@@index([role]) @@index([role])
} }
@@ -70,6 +79,10 @@ enum AuditAction {
UPDATE UPDATE
DEACTIVATE DEACTIVATE
DELETE DELETE
LOGIN
LOGIN_FAILED
LOGOUT
TOKEN_REFRESHED
} }
model AuditLog { model AuditLog {
@@ -167,9 +180,11 @@ model Colony {
lineageAsResult ColonyLineage[] @relation("LineageTo") lineageAsResult ColonyLineage[] @relation("LineageTo")
inspections Inspection[] inspections Inspection[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
treatments Treatment[] treatments Treatment[]
feedings Feeding[] feedings Feeding[]
harvests HarvestColony[] harvests HarvestColony[]
queenEvaluations QueenEvaluation[]
@@index([apiaryId]) @@index([apiaryId])
@@index([status]) @@index([status])
@@ -242,16 +257,47 @@ model Queen {
inseminationData String? inseminationData String?
motherLine String? motherLine String?
fatherLine String? fatherLine String?
motherQueenId String?
status QueenStatus @default(ACTIVE) status QueenStatus @default(ACTIVE)
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deactivatedAt DateTime? deactivatedAt DateTime?
colony Colony? @relation("ColonyCurrentQueen") motherQueen Queen? @relation("QueenMotherline", fields: [motherQueenId], references: [id])
colonyHistory QueenColonyHistory[] daughterQueens Queen[] @relation("QueenMotherline")
colony Colony? @relation("ColonyCurrentQueen")
colonyHistory QueenColonyHistory[]
evaluations QueenEvaluation[]
@@index([status]) @@index([status])
@@index([motherQueenId])
}
// ---------------------------------------------------------------------------
// Königinnenbewertung (Abschnitt 4.7) – v1.x Modul 31
// ---------------------------------------------------------------------------
model QueenEvaluation {
id String @id @default(cuid())
queenId String
queen Queen @relation(fields: [queenId], references: [id])
colonyId String?
colony Colony? @relation(fields: [colonyId], references: [id])
date DateTime
colonyStrength Int
gentleness Int
combBuild Int
swarmingTendency Int
broodPattern Int
honeyYield Int
notes String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([queenId])
@@index([colonyId])
} }
// Königinnenwechsel historisch nachvollziehbar (Abschnitt 4.5) // Königinnenwechsel historisch nachvollziehbar (Abschnitt 4.5)
@@ -348,6 +394,8 @@ model Task {
relatedId String? relatedId String?
sourceInspectionId String? sourceInspectionId String?
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id]) sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
sourceTemplateId String?
sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
completedAt DateTime? completedAt DateTime?
@@ -355,6 +403,37 @@ model Task {
@@index([relatedType, relatedId]) @@index([relatedType, relatedId])
} }
enum TaskTemplateRecurrenceType {
YEARLY
MONTHLY
INTERVAL_DAYS
}
// Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task,
// sobald nextDueDate erreicht ist (siehe TasksService.generateDueTasks).
model TaskTemplate {
id String @id @default(cuid())
title String
description String?
priority TaskPriority @default(NORMAL)
assignedUserId String?
assignedUser User? @relation("TaskTemplateAssignee", fields: [assignedUserId], references: [id])
createdByUserId String?
createdByUser User? @relation("TaskTemplateCreator", fields: [createdByUserId], references: [id])
recurrenceType TaskTemplateRecurrenceType
recurrenceMonth Int?
recurrenceDayOfMonth Int?
intervalDays Int?
nextDueDate DateTime
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
generatedTasks Task[]
@@index([active, nextDueDate])
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Varroamanagement (Abschnitt 4.10) // Varroamanagement (Abschnitt 4.10)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -384,6 +463,43 @@ model VarroaMeasurement {
@@index([colonyId, date]) @@index([colonyId, date])
} }
// ---------------------------------------------------------------------------
// Schwarmkontrolle (jenseits Lastenheft: strukturiertes Protokoll für
// Weiselzellenkontrollen während der Schwarmzeit)
// ---------------------------------------------------------------------------
enum SwarmMood {
NONE
LOW
HIGH
}
enum SwarmControlAction {
NONE
QUEEN_CELLS_REMOVED
ARTIFICIAL_SWARM
COLONY_DIVIDED
OTHER
}
model SwarmControlCheck {
id String @id @default(cuid())
colonyId String
colony Colony @relation(fields: [colonyId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
date DateTime
queenCellsFound Int?
queenCellsCapped Boolean @default(false)
swarmMood SwarmMood @default(NONE)
actionTaken SwarmControlAction @default(NONE)
notes String?
nextCheckDate DateTime?
createdAt DateTime @default(now())
@@index([colonyId, date])
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Behandlung / Tierarzneimittel (Abschnitt 4.11) // Behandlung / Tierarzneimittel (Abschnitt 4.11)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -528,7 +644,9 @@ model HoneyBatch {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
measurements HoneyMeasurement[] measurements HoneyMeasurement[]
bottlingBatches BottlingBatch[]
relocations HoneyBatchRelocation[]
@@index([status]) @@index([status])
@@index([extractionId]) @@index([extractionId])
@@ -574,11 +692,31 @@ model StorageContainer {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
honeyBatches HoneyBatch[] honeyBatches HoneyBatch[]
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
@@index([status]) @@index([status])
} }
// Umlagerungshistorie einer Honigcharge zwischen Lagerbehältern (Abschnitt 26, v1.x Modul 32)
model HoneyBatchRelocation {
id String @id @default(cuid())
honeyBatchId String
honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id])
fromStorageContainerId String?
fromStorageContainer StorageContainer? @relation("RelocationFrom", fields: [fromStorageContainerId], references: [id])
toStorageContainerId String
toStorageContainer StorageContainer @relation("RelocationTo", fields: [toStorageContainerId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id])
notes String?
relocatedAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([honeyBatchId, relocatedAt])
}
enum StockCategory { enum StockCategory {
HONEY HONEY
CONSUMABLE CONSUMABLE
@@ -624,3 +762,169 @@ model StockMovement {
@@index([stockItemId, date]) @@index([stockItemId, date])
} }
// ---------------------------------------------------------------------------
// Abfüllungen und Produkte (Abschnitt 4.19, 4.20) – v1.x Modul 23
// ---------------------------------------------------------------------------
enum ProductStatus {
ACTIVE
DISCONTINUED
}
model Product {
id String @id @default(cuid())
name String
description String?
sku String? @unique
packagingSize Float?
weight Float?
salesUnit String?
status ProductStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[]
@@index([status])
}
model BottlingBatch {
id String @id @default(cuid())
honeyBatchId String
honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id])
productId String?
product Product? @relation(fields: [productId], references: [id])
date DateTime
jarSize Float
jarCount Int
totalAmount Float
bestBeforeDate DateTime
lotNumber String @unique
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([honeyBatchId])
@@index([productId])
}
// ---------------------------------------------------------------------------
// Inventar (Abschnitt 4.22) – v1.x Modul 24
// ---------------------------------------------------------------------------
enum EquipmentCondition {
NEW
GOOD
WORN
DEFECTIVE
}
model Equipment {
id String @id @default(cuid())
inventoryNumber String @unique
name String
manufacturer String?
purchaseDate DateTime?
condition EquipmentCondition @default(GOOD)
location String?
maintenanceDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([condition])
}
// ---------------------------------------------------------------------------
// Benachrichtigungssystem (Abschnitt 24) – v1.x Modul 26
// ---------------------------------------------------------------------------
enum NotificationPriority {
INFO
HINWEIS
WARNUNG
KRITISCH
}
model Notification {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id])
priority NotificationPriority
message String
relatedType String?
relatedId String?
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, read])
@@index([priority])
}
// ---------------------------------------------------------------------------
// Webhooks (Abschnitt 27) – v1.x Modul 28
// ---------------------------------------------------------------------------
model WebhookSubscription {
id String @id @default(cuid())
url String
events String[]
secret String
active Boolean @default(true)
createdByUserId String?
createdByUser User? @relation(fields: [createdByUserId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([active])
}
// ---------------------------------------------------------------------------
// Dokumente und Bilder (Abschnitt 4.28) – v1.x Modul 29
// ---------------------------------------------------------------------------
enum DocumentEntityType {
APIARY
COLONY
QUEEN
INSPECTION
HONEY_BATCH
}
model Document {
id String @id @default(cuid())
entityType DocumentEntityType
entityId String
fileName String
mimeType String
sizeBytes Int
storagePath String
uploadedByUserId String?
uploadedByUser User? @relation(fields: [uploadedByUserId], references: [id])
createdAt DateTime @default(now())
@@index([entityType, entityId])
}
// ---------------------------------------------------------------------------
// Automatisierungen (Abschnitt 26) – v1.x Modul 30
// ---------------------------------------------------------------------------
// Fest hinterlegte Trigger (Code, nicht nutzerdefiniert) – die Tabelle erlaubt
// nur, jeden Trigger zu aktivieren/deaktivieren und seine Schwellenwerte
// (config) sowie den Aufgabentext (actionTemplate) anzupassen.
enum AutomationTriggerType {
QUEENLESS_NO_EGGS
VARROA_THRESHOLD
FEED_STATUS_CRITICAL
}
model AutomationRule {
id String @id @default(cuid())
triggerType AutomationTriggerType @unique
active Boolean @default(true)
config Json?
actionTemplate String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+35
View File
@@ -0,0 +1,35 @@
import 'dotenv/config';
import * as bcrypt from 'bcryptjs';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma';
const BCRYPT_SALT_ROUNDS = 12;
const DEFAULT_ADMIN_EMAIL = '[email protected]';
const DEFAULT_ADMIN_PASSWORD = 'ChangeMe123!';
async function main(): Promise<void> {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
const password = process.env.SEED_ADMIN_PASSWORD ?? DEFAULT_ADMIN_PASSWORD;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
console.log(`Seed: admin user ${email} already exists, skipping.`);
await prisma.$disconnect();
return;
}
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
await prisma.user.create({
data: { email, passwordHash, name: 'Administrator', role: 'ADMIN' },
});
console.log(`Seed: created admin user ${email}.`);
await prisma.$disconnect();
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,68 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiaryStatus, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
import { ApiariesService } from './apiaries.service';
@ApiTags('apiaries')
@ApiBearerAuth()
@Controller('apiaries')
export class ApiariesController {
constructor(private readonly apiariesService: ApiariesService) {}
@Get()
findAll(
@Query('status') status?: ApiaryStatus,
@Query('location') location?: string,
) {
return this.apiariesService.findAll({ status, location });
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.apiariesService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@Body() dto: CreateApiaryDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiariesService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateApiaryDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiariesService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post(':id/deactivate')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.apiariesService.deactivate(id, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post(':id/reactivate')
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.apiariesService.reactivate(id, actor.id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service';
@Module({
controllers: [ApiariesController],
providers: [ApiariesService],
exports: [ApiariesService],
})
export class ApiariesModule {}
@@ -0,0 +1,109 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { ApiariesService } from './apiaries.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('ApiariesService', () => {
let service: ApiariesService;
let prisma: {
apiary: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let auditLog: { record: jest.Mock };
const existingApiary = {
id: 'apiary-1',
name: 'Heimstand',
status: 'ACTIVE',
deactivatedAt: null,
};
beforeEach(() => {
prisma = {
apiary: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('findById throws NotFoundException when missing', async () => {
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('create persists the apiary and records an audit entry', async () => {
prisma.apiary.create.mockResolvedValue(existingApiary);
const result = await service.create({ name: 'Heimstand' }, 'user-1');
expect(result).toEqual(existingApiary);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Apiary',
action: 'CREATE',
userId: 'user-1',
}),
);
});
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
const result = await service.deactivate('apiary-1', 'user-1');
expect(result.status).toBe('INACTIVE');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
);
});
it('reactivate throws ConflictException when already active', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
await expect(
service.reactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
prisma.apiary.findUnique.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
prisma.apiary.update.mockResolvedValue({
...existingApiary,
status: 'ACTIVE',
deactivatedAt: null,
});
const result = await service.reactivate('apiary-1', 'user-1');
expect(result.status).toBe('ACTIVE');
expect(prisma.apiary.update).toHaveBeenCalledWith({
where: { id: 'apiary-1' },
data: { status: 'ACTIVE', deactivatedAt: null },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
);
});
});
@@ -0,0 +1,113 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Apiary, ApiaryStatus } from '../../generated/prisma';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
export interface ApiaryFilter {
status?: ApiaryStatus;
location?: string;
}
@Injectable()
export class ApiariesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
return this.prisma.apiary.findMany({
where: {
status: filter.status,
location: filter.location
? { contains: filter.location, mode: 'insensitive' }
: undefined,
},
orderBy: { name: 'asc' },
});
}
async findById(id: string): Promise<Apiary> {
const apiary = await this.prisma.apiary.findUnique({ where: { id } });
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
return apiary;
}
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Apiary',
entityId: apiary.id,
action: 'CREATE',
userId: actingUserId,
afterData: apiary,
});
return apiary;
}
async update(
id: string,
dto: UpdateApiaryDto,
actingUserId: string,
): Promise<Apiary> {
const before = await this.findById(id);
const apiary = await this.prisma.apiary.update({
where: { id },
data: { ...dto },
});
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
}
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
const apiary = await this.prisma.apiary.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
});
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
}
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
if (before.status === 'ACTIVE') {
throw new ConflictException('Apiary is already active');
}
const apiary = await this.prisma.apiary.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
}
}
@@ -0,0 +1,38 @@
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
export class CreateApiaryDto {
@ApiProperty()
@IsString()
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
location?: string;
@ApiPropertyOptional()
@IsOptional()
@IsLatitude()
latitude?: number;
@ApiPropertyOptional()
@IsOptional()
@IsLongitude()
longitude?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateApiaryDto } from './create-apiary.dto';
export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {}
+34
View File
@@ -0,0 +1,34 @@
import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
export function configureApp(app: NestExpressApplication): void {
// Trust the single reverse-proxy hop (K8s ingress) 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', 1);
app.use(helmet());
app.use(cookieParser());
app.enableCors({
origin: process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000',
credentials: true,
});
app.setGlobalPrefix('api', {
exclude: [
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/ready', method: RequestMethod.GET },
],
});
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
}
@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
-12
View File
@@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+75 -4
View File
@@ -1,17 +1,88 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller'; import { APP_GUARD } from '@nestjs/core';
import { AppService } from './app.service'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { ApiariesModule } from './apiaries/apiaries.module';
import { ColoniesModule } from './colonies/colonies.module';
import { QueensModule } from './queens/queens.module';
import { InspectionsModule } from './inspections/inspections.module';
import { TasksModule } from './tasks/tasks.module';
import { VarroaModule } from './varroa/varroa.module';
import { TreatmentsModule } from './treatments/treatments.module';
import { FeedingsModule } from './feedings/feedings.module';
import { HarvestsModule } from './harvests/harvests.module';
import { ExtractionsModule } from './extractions/extractions.module';
import { HoneyBatchesModule } from './honey-batches/honey-batches.module';
import { StorageModule } from './storage/storage.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { SearchModule } from './search/search.module';
import { ExportModule } from './export/export.module';
import { QrModule } from './qr/qr.module';
import { ProductsModule } from './products/products.module';
import { BottlingModule } from './bottling/bottling.module';
import { EquipmentModule } from './equipment/equipment.module';
import { CalendarModule } from './calendar/calendar.module';
import { NotificationsModule } from './notifications/notifications.module';
import { StatisticsModule } from './statistics/statistics.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { DocumentsModule } from './documents/documents.module';
import { AutomationModule } from './automation/automation.module';
import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
PrismaModule, PrismaModule,
AuditModule,
HealthModule, HealthModule,
UsersModule,
AuthModule,
ApiariesModule,
ColoniesModule,
QueensModule,
InspectionsModule,
TasksModule,
VarroaModule,
TreatmentsModule,
FeedingsModule,
HarvestsModule,
ExtractionsModule,
HoneyBatchesModule,
StorageModule,
DashboardModule,
SearchModule,
ExportModule,
QrModule,
ProductsModule,
BottlingModule,
EquipmentModule,
CalendarModule,
NotificationsModule,
StatisticsModule,
WebhooksModule,
DocumentsModule,
AutomationModule,
ImportModule,
PublicModule,
TaskTemplatesModule,
SwarmControlModule,
],
controllers: [],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
], ],
controllers: [AppController],
providers: [AppService],
}) })
export class AppModule {} export class AppModule {}
-8
View File
@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
@@ -0,0 +1,55 @@
import { AuditLogService } from './audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
describe('AuditLogService', () => {
let service: AuditLogService;
let prisma: {
auditLog: { create: jest.Mock; findMany: jest.Mock };
};
beforeEach(() => {
prisma = {
auditLog: {
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
},
};
service = new AuditLogService(prisma as unknown as PrismaService);
});
it('record writes the entity type, action, and actor', async () => {
await service.record({
entityType: 'Colony',
entityId: 'colony-1',
action: 'CREATE',
userId: 'user-1',
});
const [[createArg]] = prisma.auditLog.create.mock.calls as [
[
{
data: {
entityType: string;
entityId: string;
action: string;
userId?: string;
};
},
],
];
expect(createArg.data.entityType).toBe('Colony');
expect(createArg.data.entityId).toBe('colony-1');
expect(createArg.data.action).toBe('CREATE');
expect(createArg.data.userId).toBe('user-1');
});
it('findAll filters by entity type and id', async () => {
await service.findAll({ entityType: 'Colony', entityId: 'colony-1' });
const [[queryArg]] = prisma.auditLog.findMany.mock.calls as [
[{ where: { entityType?: string; entityId?: string } }],
];
expect(queryArg.where.entityType).toBe('Colony');
expect(queryArg.where.entityId).toBe('colony-1');
});
});
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditAction, AuditLog, Prisma } from '../../generated/prisma';
interface RecordAuditLogInput {
entityType: string;
entityId: string;
action: AuditAction;
userId?: string;
beforeData?: unknown;
afterData?: unknown;
}
export interface AuditLogFilter {
entityType?: string;
entityId?: string;
}
@Injectable()
export class AuditLogService {
constructor(private readonly prisma: PrismaService) {}
async record(input: RecordAuditLogInput): Promise<void> {
await this.prisma.auditLog.create({
data: {
entityType: input.entityType,
entityId: input.entityId,
action: input.action,
userId: input.userId,
beforeData: input.beforeData as Prisma.InputJsonValue,
afterData: input.afterData as Prisma.InputJsonValue,
},
});
}
async findAll(filter: AuditLogFilter = {}): Promise<AuditLog[]> {
return this.prisma.auditLog.findMany({
where: { entityType: filter.entityType, entityId: filter.entityId },
orderBy: { timestamp: 'desc' },
take: 200,
});
}
}
@@ -0,0 +1,21 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { AuditLogService } from './audit-log.service';
@ApiTags('audit-log')
@ApiBearerAuth()
@Controller('audit-log')
export class AuditController {
constructor(private readonly auditLogService: AuditLogService) {}
@Roles(Role.ADMIN)
@Get()
findAll(
@Query('entityType') entityType?: string,
@Query('entityId') entityId?: string,
) {
return this.auditLogService.findAll({ entityType, entityId });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { AuditController } from './audit.controller';
import { AuditLogService } from './audit-log.service';
@Global()
@Module({
controllers: [AuditController],
providers: [AuditLogService],
exports: [AuditLogService],
})
export class AuditModule {}
@@ -0,0 +1,99 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from './decorators/public.decorator';
import { LoginDto } from './dto/login.dto';
import { AccessTokenDto } from './dto/access-token.dto';
import { AuthService } from './auth.service';
import { parseDurationToMs } from './util/duration';
const REFRESH_TOKEN_COOKIE = 'refreshToken';
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
// Stricter than the global default: bounds brute-force/credential-stuffing
// attempts against login and refresh-token guessing specifically.
const AUTH_THROTTLE = { default: { limit: 10, ttl: 60000 } };
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
httpOnly: true,
// Explicit opt-out for local HTTP dev only; every real deployment must
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
// over plaintext HTTP.
secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax',
path: REFRESH_TOKEN_COOKIE_PATH,
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
});
}
private clearRefreshTokenCookie(res: Response): void {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
}
private extractRefreshToken(req: Request): string {
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
REFRESH_TOKEN_COOKIE
];
if (!refreshToken) {
throw new UnauthorizedException('Missing refresh token');
}
return refreshToken;
}
@Public()
@Throttle(AUTH_THROTTLE)
@Post('login')
@HttpCode(HttpStatus.OK)
async login(
@Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
const { accessToken, refreshToken } = await this.authService.login(
dto.email,
dto.password,
);
this.setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
@Public()
@Throttle(AUTH_THROTTLE)
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
const { accessToken, refreshToken } = await this.authService.refresh(
this.extractRefreshToken(req),
);
this.setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
@Public()
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
await this.authService.logout(this.extractRefreshToken(req));
this.clearRefreshTokenCookie(res);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [PassportModule, JwtModule.register({}), UsersModule],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
@@ -0,0 +1,209 @@
import { UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('AuthService', () => {
let authService: AuthService;
let usersService: { findByEmail: jest.Mock; findById: jest.Mock };
let prisma: {
refreshToken: {
create: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
};
};
let jwtService: { sign: jest.Mock };
let auditLog: { record: jest.Mock };
const activeUser = {
id: 'user-1',
email: '[email protected]',
role: 'STAFF',
isActive: true,
passwordHash: '',
};
beforeEach(async () => {
activeUser.passwordHash = await bcrypt.hash('correct-password', 4);
usersService = { findByEmail: jest.fn(), findById: jest.fn() };
prisma = {
refreshToken: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
},
};
jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') };
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
authService = new AuthService(
usersService as unknown as UsersService,
prisma as unknown as PrismaService,
jwtService as never,
auditLog as unknown as AuditLogService,
);
});
describe('login', () => {
it('throws when the user does not exist', async () => {
usersService.findByEmail.mockResolvedValue(null);
await expect(
authService.login('[email protected]', 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws when the user is deactivated', async () => {
usersService.findByEmail.mockResolvedValue({
...activeUser,
isActive: false,
});
await expect(
authService.login(activeUser.email, 'correct-password'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws when the password does not match', async () => {
usersService.findByEmail.mockResolvedValue(activeUser);
await expect(
authService.login(activeUser.email, 'wrong-password'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns tokens and stores a hashed refresh token on success', async () => {
usersService.findByEmail.mockResolvedValue(activeUser);
prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.login(
activeUser.email,
'correct-password',
);
expect(result.accessToken).toBe('signed.jwt.token');
expect(result.refreshToken).toEqual(expect.any(String));
const [[createArg]] = prisma.refreshToken.create.mock.calls as [
[{ data: { userId: string } }],
];
expect(createArg.data.userId).toBe(activeUser.id);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }),
);
});
it('records a LOGIN_FAILED audit entry on wrong password', async () => {
usersService.findByEmail.mockResolvedValue(activeUser);
await expect(
authService.login(activeUser.email, 'wrong-password'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'LOGIN_FAILED',
userId: activeUser.id,
}),
);
});
it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => {
usersService.findByEmail.mockResolvedValue(null);
await expect(
authService.login('[email protected]', 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'LOGIN_FAILED',
entityId: '[email protected]',
userId: undefined,
}),
);
});
});
describe('refresh', () => {
it('throws when the refresh token is unknown', async () => {
prisma.refreshToken.findUnique.mockResolvedValue(null);
await expect(authService.refresh('unknown-token')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('throws when the refresh token is revoked', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: new Date(),
expiresAt: new Date(Date.now() + 1000),
});
await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('throws when the refresh token is expired', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: null,
expiresAt: new Date(Date.now() - 1000),
});
await expect(authService.refresh('expired-token')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('rotates the refresh token on success', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: null,
expiresAt: new Date(Date.now() + 1000),
});
usersService.findById.mockResolvedValue(activeUser);
prisma.refreshToken.update.mockResolvedValue({});
prisma.refreshToken.create.mockResolvedValue({});
const result = await authService.refresh('valid-token');
expect(result.accessToken).toBe('signed.jwt.token');
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'rt-1' } }),
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'TOKEN_REFRESHED',
userId: activeUser.id,
}),
);
});
});
describe('logout', () => {
it('revokes the matching refresh token and records a LOGOUT audit entry', async () => {
prisma.refreshToken.findUnique.mockResolvedValue({
id: 'rt-1',
userId: activeUser.id,
revokedAt: null,
});
prisma.refreshToken.update.mockResolvedValue({});
await authService.logout('some-token');
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'rt-1' } }),
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'LOGOUT', userId: activeUser.id }),
);
});
it('does nothing when the token is unknown or already revoked', async () => {
prisma.refreshToken.findUnique.mockResolvedValue(null);
await authService.logout('unknown-token');
expect(prisma.refreshToken.update).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
});
});
+144
View File
@@ -0,0 +1,144 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { AuthenticatedUser } from './types/authenticated-user';
import { parseDurationToMs } from './util/duration';
interface AuthTokens {
accessToken: string;
refreshToken: string;
}
const REFRESH_TOKEN_BYTES = 40;
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly auditLog: AuditLogService,
) {}
private hashRefreshToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private signAccessToken(user: AuthenticatedUser): string {
const expiresInSeconds = Math.floor(
parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000,
);
return this.jwtService.sign(
{ sub: user.id, email: user.email, role: user.role },
{
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: expiresInSeconds,
},
);
}
private async issueRefreshToken(userId: string): Promise<string> {
const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex');
const expiresAt = new Date(
Date.now() +
parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
);
await this.prisma.refreshToken.create({
data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt },
});
return token;
}
async login(email: string, password: string): Promise<AuthTokens> {
const user = await this.usersService.findByEmail(email);
if (!user || !user.isActive) {
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user?.id ?? email,
action: 'LOGIN_FAILED',
userId: user?.id,
});
throw new UnauthorizedException('Invalid credentials');
}
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) {
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException('Invalid credentials');
}
const authenticatedUser: AuthenticatedUser = {
id: user.id,
email: user.email,
role: user.role,
};
const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN',
userId: user.id,
});
return { accessToken, refreshToken };
}
async refresh(refreshToken: string): Promise<AuthTokens> {
const tokenHash = this.hashRefreshToken(refreshToken);
const stored = await this.prisma.refreshToken.findUnique({
where: { tokenHash },
});
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.usersService.findById(stored.userId);
if (!user.isActive) {
throw new UnauthorizedException('Invalid refresh token');
}
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
const authenticatedUser: AuthenticatedUser = {
id: user.id,
email: user.email,
role: user.role,
};
const accessToken = this.signAccessToken(authenticatedUser);
const newRefreshToken = await this.issueRefreshToken(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'TOKEN_REFRESHED',
userId: user.id,
});
return { accessToken, refreshToken: newRefreshToken };
}
async logout(refreshToken: string): Promise<void> {
const tokenHash = this.hashRefreshToken(refreshToken);
const stored = await this.prisma.refreshToken.findUnique({
where: { tokenHash },
});
if (!stored || stored.revokedAt) {
return;
}
await this.prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
await this.auditLog.record({
entityType: 'AuthSession',
entityId: stored.userId,
action: 'LOGOUT',
userId: stored.userId,
});
}
}
@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedUser } from '../types/authenticated-user';
interface RequestWithUser {
user: AuthenticatedUser;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
const request = ctx.switchToHttp().getRequest<RequestWithUser>();
return request.user;
},
);
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '../../../generated/prisma';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class AccessTokenDto {
@ApiProperty()
accessToken!: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@ApiProperty()
@IsEmail()
email!: string;
@ApiProperty()
@IsString()
@MinLength(8)
password!: string;
}
@@ -0,0 +1,22 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
@@ -0,0 +1,48 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
import { Role } from '../../../generated/prisma';
function createContext(user: { role: Role } | undefined): ExecutionContext {
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as unknown as ExecutionContext;
}
describe('RolesGuard', () => {
it('allows access when no roles are required', () => {
const reflector = {
getAllAndOverride: () => undefined,
} as unknown as Reflector;
const guard = new RolesGuard(reflector);
expect(guard.canActivate(createContext(undefined))).toBe(true);
});
it('denies access when the user role does not match', () => {
const reflector = {
getAllAndOverride: () => [Role.ADMIN],
} as unknown as Reflector;
const guard = new RolesGuard(reflector);
expect(guard.canActivate(createContext({ role: Role.STAFF }))).toBe(false);
});
it('allows access when the user role matches', () => {
const reflector = {
getAllAndOverride: () => [Role.ADMIN],
} as unknown as Reflector;
const guard = new RolesGuard(reflector);
expect(guard.canActivate(createContext({ role: Role.ADMIN }))).toBe(true);
});
it('denies access when no user is attached to the request', () => {
const reflector = {
getAllAndOverride: () => [Role.ADMIN],
} as unknown as Reflector;
const guard = new RolesGuard(reflector);
expect(guard.canActivate(createContext(undefined))).toBe(false);
});
});
@@ -0,0 +1,27 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../../../generated/prisma';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { AuthenticatedUser } from '../types/authenticated-user';
interface RequestWithUser {
user?: AuthenticatedUser;
}
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
return !!user && requiredRoles.includes(user.role);
}
}
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthenticatedUser } from '../types/authenticated-user';
interface AccessTokenPayload {
sub: string;
email: string;
role: AuthenticatedUser['role'];
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_ACCESS_SECRET as string,
});
}
validate(payload: AccessTokenPayload): AuthenticatedUser {
return { id: payload.sub, email: payload.email, role: payload.role };
}
}
@@ -0,0 +1,7 @@
import { Role } from '../../../generated/prisma';
export interface AuthenticatedUser {
id: string;
email: string;
role: Role;
}
@@ -0,0 +1,18 @@
import { parseDurationToMs } from './duration';
describe('parseDurationToMs', () => {
it.each([
['15s', 15_000],
['15m', 900_000],
['2h', 7_200_000],
['7d', 604_800_000],
])('parses %s to %i ms', (spec, expected) => {
expect(parseDurationToMs(spec)).toBe(expected);
});
it('throws on an invalid spec', () => {
expect(() => parseDurationToMs('not-a-duration')).toThrow(
'Invalid duration spec',
);
});
});
+15
View File
@@ -0,0 +1,15 @@
const UNIT_MS: Record<string, number> = {
s: 1000,
m: 60_000,
h: 3_600_000,
d: 86_400_000,
};
export function parseDurationToMs(spec: string): number {
const match = /^(\d+)(s|m|h|d)$/.exec(spec.trim());
if (!match) {
throw new Error(`Invalid duration spec: ${spec}`);
}
const [, amount, unit] = match;
return Number(amount) * UNIT_MS[unit];
}
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { AutomationService } from './automation.service';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
@ApiTags('automation-rules')
@ApiBearerAuth()
@Roles(Role.ADMIN)
@Controller('automation-rules')
export class AutomationController {
constructor(private readonly automationService: AutomationService) {}
@Get()
findAll() {
return this.automationService.findAll();
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateAutomationRuleDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.automationService.update(id, dto, actor.id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationController } from './automation.controller';
import { AutomationService } from './automation.service';
@Module({
imports: [NotificationsModule],
controllers: [AutomationController],
providers: [AutomationService],
exports: [AutomationService],
})
export class AutomationModule {}
@@ -0,0 +1,210 @@
import { 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';
describe('AutomationService', () => {
let service: AutomationService;
let prisma: {
automationRule: {
upsert: jest.Mock;
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
task: { create: jest.Mock };
};
let auditLog: { record: jest.Mock };
let notifications: { record: jest.Mock };
let webhooks: { dispatch: jest.Mock };
const queenlessRule = {
id: 'rule-1',
triggerType: 'QUEENLESS_NO_EGGS',
active: true,
config: {},
actionTemplate: 'Kontrolle empfohlen',
};
const feedRule = {
id: 'rule-2',
triggerType: 'FEED_STATUS_CRITICAL',
active: true,
config: {},
actionTemplate: 'Fütterung erforderlich',
};
const varroaRule = {
id: 'rule-3',
triggerType: 'VARROA_THRESHOLD',
active: true,
config: { thresholdMitesPerDay: 10 },
actionTemplate: 'Behandlung prüfen',
};
beforeEach(() => {
prisma = {
automationRule: {
upsert: jest.fn().mockResolvedValue(undefined),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
task: { create: jest.fn().mockResolvedValue({ id: 'task-1' }) },
};
auditLog = { record: jest.fn() };
notifications = { record: jest.fn() };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
service = new AutomationService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
notifications as unknown as NotificationsService,
webhooks as unknown as WebhooksService,
);
});
it('findById throws NotFoundException when missing', async () => {
prisma.automationRule.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('findAll seeds the three default rules before returning them', async () => {
prisma.automationRule.findMany.mockResolvedValue([
queenlessRule,
varroaRule,
feedRule,
]);
const result = await service.findAll();
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(3);
expect(result).toHaveLength(3);
});
describe('evaluateInspection', () => {
it('creates a task and notification when queen not seen and no eggs present', async () => {
prisma.automationRule.findMany.mockResolvedValue([queenlessRule]);
await service.evaluateInspection({
id: 'inspection-1',
colonyId: 'colony-1',
queenSeen: false,
eggsPresent: false,
feedStatus: null,
} as never);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
const [[taskArg]] = prisma.task.create.mock.calls as [
[{ data: { relatedId: string; sourceInspectionId?: string } }],
];
expect(taskArg.data.relatedId).toBe('colony-1');
expect(taskArg.data.sourceInspectionId).toBe('inspection-1');
expect(notifications.record).toHaveBeenCalledTimes(1);
});
it('does not trigger when the queen was seen', async () => {
prisma.automationRule.findMany.mockResolvedValue([queenlessRule]);
await service.evaluateInspection({
id: 'inspection-2',
colonyId: 'colony-1',
queenSeen: true,
eggsPresent: false,
feedStatus: null,
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
});
it('triggers when feed status is kritisch, case-insensitive', async () => {
prisma.automationRule.findMany.mockResolvedValue([feedRule]);
await service.evaluateInspection({
id: 'inspection-3',
colonyId: 'colony-1',
queenSeen: true,
eggsPresent: true,
feedStatus: 'Kritisch',
} as never);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
});
it('does not trigger a deactivated rule', async () => {
prisma.automationRule.findMany.mockResolvedValue([]);
await service.evaluateInspection({
id: 'inspection-4',
colonyId: 'colony-1',
queenSeen: false,
eggsPresent: false,
feedStatus: 'kritisch',
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
describe('evaluateVarroaMeasurement', () => {
it('creates a task when the calculated value meets the threshold', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
await service.evaluateVarroaMeasurement({
id: 'measurement-1',
colonyId: 'colony-1',
calculatedValue: 12,
miteCount: null,
} as never);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
});
it('does not trigger when below the threshold', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
await service.evaluateVarroaMeasurement({
id: 'measurement-2',
colonyId: 'colony-1',
calculatedValue: 5,
miteCount: null,
} as never);
expect(prisma.task.create).not.toHaveBeenCalled();
});
it('falls back to miteCount when calculatedValue is null', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
await service.evaluateVarroaMeasurement({
id: 'measurement-3',
colonyId: 'colony-1',
calculatedValue: null,
miteCount: 15,
} as never);
expect(prisma.task.create).toHaveBeenCalledTimes(1);
});
});
describe('update', () => {
it('records an audit log entry with before/after data', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue({
...varroaRule,
active: false,
});
await service.update('rule-3', { active: false }, 'user-1');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'AutomationRule',
entityId: 'rule-3',
action: 'UPDATE',
}),
);
});
});
});
@@ -0,0 +1,179 @@
import { Injectable, NotFoundException } from '@nestjs/common';
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 {
AutomationRule,
AutomationTriggerType,
Inspection,
Prisma,
VarroaMeasurement,
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
}
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
QUEENLESS_NO_EGGS: {
config: {},
actionTemplate:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
},
VARROA_THRESHOLD: {
config: { thresholdMitesPerDay: 10 },
actionTemplate: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
},
FEED_STATUS_CRITICAL: {
config: {},
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
},
};
@Injectable()
export class AutomationService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly notifications: NotificationsService,
private readonly webhooks: WebhooksService,
) {}
private async ensureDefaults(): Promise<void> {
for (const triggerType of Object.keys(
DEFAULT_RULES,
) as AutomationTriggerType[]) {
const defaults = DEFAULT_RULES[triggerType];
await this.prisma.automationRule.upsert({
where: { triggerType },
update: {},
create: {
triggerType,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
});
}
}
async findAll(): Promise<AutomationRule[]> {
await this.ensureDefaults();
return this.prisma.automationRule.findMany({
orderBy: { triggerType: 'asc' },
});
}
async findById(id: string): Promise<AutomationRule> {
const rule = await this.prisma.automationRule.findUnique({
where: { id },
});
if (!rule) {
throw new NotFoundException('Automation rule not found');
}
return rule;
}
async update(
id: string,
dto: UpdateAutomationRuleDto,
actingUserId: string,
): Promise<AutomationRule> {
const before = await this.findById(id);
const rule = await this.prisma.automationRule.update({
where: { id },
data: {
...dto,
config: dto.config as Prisma.InputJsonValue | undefined,
},
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: rule,
});
return rule;
}
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,
},
});
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,
inspection.colonyId,
inspection.id,
);
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
await this.triggerAction(feedRule, 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;
}
const config = rule.config as { thresholdMitesPerDay?: number } | null;
const threshold = config?.thresholdMitesPerDay ?? 10;
const value = measurement.calculatedValue ?? measurement.miteCount;
if (value !== null && value >= threshold) {
await this.triggerAction(rule, measurement.colonyId);
}
}
private async triggerAction(
rule: AutomationRule,
colonyId: string,
sourceInspectionId?: string,
): Promise<void> {
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const task = await this.prisma.task.create({
data: {
title,
priority: 'HIGH',
relatedType: 'COLONY',
relatedId: colonyId,
sourceInspectionId,
},
});
await this.webhooks.dispatch('task.created', task);
await this.notifications.record({
priority: 'WARNUNG',
message: title,
relatedType: 'COLONY',
relatedId: colonyId,
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: { taskId: task.id, colonyId, triggerType: rule.triggerType },
});
}
}
@@ -0,0 +1,19 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateAutomationRuleDto {
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
active?: boolean;
@ApiPropertyOptional({ type: Object })
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
actionTemplate?: string;
}
@@ -0,0 +1,53 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
import { BottlingService } from './bottling.service';
@ApiTags('bottling')
@ApiBearerAuth()
@Controller('bottling-batches')
export class BottlingController {
constructor(private readonly bottlingService: BottlingService) {}
@Get()
findAll(@Query('honeyBatchId') honeyBatchId?: string) {
return this.bottlingService.findAll({ honeyBatchId });
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.bottlingService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@Body() dto: CreateBottlingBatchDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.bottlingService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateBottlingBatchDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.bottlingService.update(id, dto, actor.id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service';
@Module({
controllers: [BottlingController],
providers: [BottlingService],
})
export class BottlingModule {}
@@ -0,0 +1,160 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BottlingService } from './bottling.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
describe('BottlingService', () => {
let service: BottlingService;
let prisma: {
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
product: { findUnique: jest.Mock };
bottlingBatch: {
findUnique: jest.Mock;
findMany: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let tx: {
bottlingBatch: { create: jest.Mock };
honeyBatch: { update: jest.Mock };
};
beforeEach(() => {
tx = {
bottlingBatch: { create: jest.fn() },
honeyBatch: { update: jest.fn() },
};
prisma = {
honeyBatch: { findUnique: jest.fn(), update: jest.fn() },
product: { findUnique: jest.fn() },
bottlingBatch: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
service = new BottlingService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
);
});
it('throws NotFoundException when the honey batch does not exist', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce(null);
await expect(
service.create(
{
honeyBatchId: 'missing',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects when the bottled amount exceeds the available honey batch amount', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 5,
});
await expect(
service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 20,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('creates a bottling batch and reduces the honey batch amount transactionally', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 25,
});
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
totalAmount: 25,
});
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
id: 'b1',
totalAmount: 25,
});
const result = await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 50,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(result).toEqual({ id: 'b1', totalAmount: 25 });
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 0, status: 'BOTTLED' },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
action: 'CREATE',
}),
);
});
it('update writes an audit log entry with before and after data', async () => {
const before = { id: 'b1', lotNumber: 'L-1' };
const updated = { id: 'b1', lotNumber: 'L-2' };
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(updated);
prisma.bottlingBatch.update.mockResolvedValue(updated);
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(result).toEqual(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
entityId: 'b1',
action: 'UPDATE',
beforeData: before,
afterData: updated,
}),
);
});
it('rejects updating to a product that does not exist', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
prisma.product.findUnique.mockResolvedValueOnce(null);
await expect(
service.update('b1', { productId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -0,0 +1,145 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { BottlingBatch } from '../../generated/prisma';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
const bottlingBatchInclude = {
honeyBatch: true,
product: true,
} as const;
export interface BottlingBatchFilter {
honeyBatchId?: string;
}
@Injectable()
export class BottlingService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
) {}
async findAll(filter: BottlingBatchFilter = {}) {
return this.prisma.bottlingBatch.findMany({
where: { honeyBatchId: filter.honeyBatchId },
include: bottlingBatchInclude,
orderBy: { date: 'desc' },
});
}
async findById(id: string) {
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
where: { id },
include: bottlingBatchInclude,
});
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
}
return bottlingBatch;
}
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
const honeyBatch = await this.prisma.honeyBatch.findUnique({
where: { id: dto.honeyBatchId },
});
if (!honeyBatch) {
throw new NotFoundException('Honey batch not found');
}
if (dto.productId) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
}
const totalAmount = (dto.jarSize * dto.jarCount) / 1000;
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
throw new BadRequestException(
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
);
}
const bottlingBatch: BottlingBatch = await this.prisma.$transaction(
async (tx) => {
const created = await tx.bottlingBatch.create({
data: {
honeyBatchId: dto.honeyBatchId,
productId: dto.productId,
date: new Date(dto.date),
jarSize: dto.jarSize,
jarCount: dto.jarCount,
totalAmount,
bestBeforeDate: new Date(dto.bestBeforeDate),
lotNumber: dto.lotNumber,
userId: actingUserId,
},
});
await tx.honeyBatch.update({
where: { id: dto.honeyBatchId },
data: {
amount:
honeyBatch.amount !== null
? honeyBatch.amount - totalAmount
: null,
status: 'BOTTLED',
},
});
return created;
},
);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: bottlingBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: bottlingBatch,
});
const created = await this.findById(bottlingBatch.id);
await this.webhooks.dispatch('batch.updated', created.honeyBatch);
return created;
}
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
const before = await this.findById(id);
if (dto.productId) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
}
await this.prisma.bottlingBatch.update({
where: { id },
data: {
...dto,
date: dto.date ? new Date(dto.date) : undefined,
bestBeforeDate: dto.bestBeforeDate
? new Date(dto.bestBeforeDate)
: undefined,
},
});
const bottlingBatch = await this.findById(id);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: bottlingBatch,
});
return bottlingBatch;
}
}
@@ -0,0 +1,42 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsPositive,
IsString,
} from 'class-validator';
export class CreateBottlingBatchDto {
@ApiProperty()
@IsString()
honeyBatchId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
productId?: string;
@ApiProperty()
@IsDateString()
date!: string;
@ApiProperty({ description: 'Glasgröße in Gramm' })
@IsNumber()
@IsPositive()
jarSize!: number;
@ApiProperty()
@IsInt()
@IsPositive()
jarCount!: number;
@ApiProperty()
@IsDateString()
bestBeforeDate!: string;
@ApiProperty()
@IsString()
lotNumber!: string;
}
@@ -0,0 +1,11 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateBottlingBatchDto } from './create-bottling-batch.dto';
export class UpdateBottlingBatchDto extends PartialType(
PickType(CreateBottlingBatchDto, [
'productId',
'date',
'bestBeforeDate',
'lotNumber',
] as const),
) {}
@@ -0,0 +1,28 @@
import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CalendarService } from './calendar.service';
@ApiTags('calendar')
@ApiBearerAuth()
@Controller('calendar')
export class CalendarController {
constructor(private readonly calendarService: CalendarService) {}
@Get()
getEvents(
@Query('from') from: string,
@Query('to') to: string,
@Query('apiaryId') apiaryId?: string,
) {
if (!from || !to) {
throw new BadRequestException(
'Query parameters from and to are required',
);
}
return this.calendarService.getEvents({
from: new Date(from),
to: new Date(to),
apiaryId,
});
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CalendarController } from './calendar.controller';
import { CalendarService } from './calendar.service';
@Module({
controllers: [CalendarController],
providers: [CalendarService],
})
export class CalendarModule {}
@@ -0,0 +1,85 @@
import { CalendarService } from './calendar.service';
import { PrismaService } from '../prisma/prisma.service';
describe('CalendarService', () => {
let service: CalendarService;
let prisma: {
task: { findMany: jest.Mock };
inspection: { findMany: jest.Mock };
treatment: { findMany: jest.Mock };
feeding: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
queenColonyHistory: { findMany: jest.Mock };
};
beforeEach(() => {
prisma = {
task: { findMany: jest.fn().mockResolvedValue([]) },
inspection: { findMany: jest.fn().mockResolvedValue([]) },
treatment: { findMany: jest.fn().mockResolvedValue([]) },
feeding: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new CalendarService(prisma as unknown as PrismaService);
});
it('merges tasks, inspections, and harvests into one sorted event list', async () => {
prisma.task.findMany.mockResolvedValueOnce([
{ id: 't1', title: 'Kontrolle', dueDate: new Date('2026-06-05') },
]);
prisma.inspection.findMany.mockResolvedValueOnce([
{
id: 'i1',
date: new Date('2026-06-01'),
colonyId: 'c1',
colony: { code: 'V-001' },
},
]);
prisma.harvest.findMany.mockResolvedValueOnce([
{ id: 'h1', date: new Date('2026-06-10'), apiaryId: 'a1' },
]);
const events = await service.getEvents({
from: new Date('2026-06-01'),
to: new Date('2026-06-30'),
});
expect(events.map((event) => event.type)).toEqual([
'INSPECTION',
'TASK',
'HARVEST',
]);
});
it('filters colony-linked events by apiary when apiaryId is given', async () => {
await service.getEvents({
from: new Date('2026-06-01'),
to: new Date('2026-06-30'),
apiaryId: 'a1',
});
const [[inspectionArg]] = prisma.inspection.findMany.mock.calls as [
[{ where: { colony?: { apiaryId?: string } } }],
];
expect(inspectionArg.where.colony).toEqual({ apiaryId: 'a1' });
const [[harvestArg]] = prisma.harvest.findMany.mock.calls as [
[{ where: { apiaryId?: string } }],
];
expect(harvestArg.where.apiaryId).toBe('a1');
});
it('excludes tasks without a due date', async () => {
prisma.task.findMany.mockResolvedValueOnce([
{ id: 't1', title: 'Ohne Datum', dueDate: null },
]);
const events = await service.getEvents({
from: new Date('2026-06-01'),
to: new Date('2026-06-30'),
});
expect(events).toHaveLength(0);
});
});
@@ -0,0 +1,125 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export type CalendarEventType =
| 'TASK'
| 'INSPECTION'
| 'TREATMENT'
| 'FEEDING'
| 'HARVEST'
| 'QUEEN_ASSIGNMENT';
export interface CalendarEvent {
id: string;
type: CalendarEventType;
date: Date;
title: string;
relatedType: string;
relatedId: string;
}
export interface CalendarQuery {
from: Date;
to: Date;
apiaryId?: string;
}
@Injectable()
export class CalendarService {
constructor(private readonly prisma: PrismaService) {}
async getEvents(query: CalendarQuery): Promise<CalendarEvent[]> {
const { from, to, apiaryId } = query;
const colonyFilter = apiaryId ? { colony: { apiaryId } } : {};
const [
tasks,
inspections,
treatments,
feedings,
harvests,
queenAssignments,
] = await Promise.all([
this.prisma.task.findMany({
where: { dueDate: { gte: from, lte: to } },
}),
this.prisma.inspection.findMany({
where: { date: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true },
}),
this.prisma.treatment.findMany({
where: { date: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true },
}),
this.prisma.feeding.findMany({
where: { date: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true },
}),
this.prisma.harvest.findMany({
where: {
date: { gte: from, lte: to },
...(apiaryId ? { apiaryId } : {}),
},
}),
this.prisma.queenColonyHistory.findMany({
where: { startDate: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true, queen: true },
}),
]);
const events: CalendarEvent[] = [
...tasks
.filter((task) => task.dueDate !== null)
.map((task) => ({
id: task.id,
type: 'TASK' as const,
date: task.dueDate!,
title: task.title,
relatedType: 'Task',
relatedId: task.id,
})),
...inspections.map((inspection) => ({
id: inspection.id,
type: 'INSPECTION' as const,
date: inspection.date,
title: `Durchsicht ${inspection.colony.code}`,
relatedType: 'Colony',
relatedId: inspection.colonyId,
})),
...treatments.map((treatment) => ({
id: treatment.id,
type: 'TREATMENT' as const,
date: treatment.date,
title: `Behandlung ${treatment.colony.code} · ${treatment.product}`,
relatedType: 'Colony',
relatedId: treatment.colonyId,
})),
...feedings.map((feeding) => ({
id: feeding.id,
type: 'FEEDING' as const,
date: feeding.date,
title: `Fütterung ${feeding.colony.code}`,
relatedType: 'Colony',
relatedId: feeding.colonyId,
})),
...harvests.map((harvest) => ({
id: harvest.id,
type: 'HARVEST' as const,
date: harvest.date,
title: 'Ernte',
relatedType: 'Apiary',
relatedId: harvest.apiaryId,
})),
...queenAssignments.map((assignment) => ({
id: assignment.id,
type: 'QUEEN_ASSIGNMENT' as const,
date: assignment.startDate,
title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`,
relatedType: 'Queen',
relatedId: assignment.queenId,
})),
];
return events.sort((a, b) => a.date.getTime() - b.date.getTime());
}
}
@@ -0,0 +1,106 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ColonyStatus, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto';
import { MoveColonyDto } from './dto/move-colony.dto';
import { SplitColonyDto } from './dto/split-colony.dto';
import { MergeColonyDto } from './dto/merge-colony.dto';
import { ColoniesService } from './colonies.service';
@ApiTags('colonies')
@ApiBearerAuth()
@Controller('colonies')
export class ColoniesController {
constructor(private readonly coloniesService: ColoniesService) {}
@Get()
findAll(
@Query('status') status?: ColonyStatus,
@Query('apiaryId') apiaryId?: string,
) {
return this.coloniesService.findAll({ status, apiaryId });
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.coloniesService.findById(id);
}
@Get(':id/history')
getHistory(@Param('id') id: string) {
return this.coloniesService.getHistory(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@Body() dto: CreateColonyDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.coloniesService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateColonyDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.coloniesService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post(':id/move')
move(
@Param('id') id: string,
@Body() dto: MoveColonyDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.coloniesService.move(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post(':id/split')
split(
@Param('id') id: string,
@Body() dto: SplitColonyDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.coloniesService.split(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post(':id/merge')
merge(
@Param('id') id: string,
@Body() dto: MergeColonyDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.coloniesService.merge(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post(':id/dissolve')
dissolve(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.coloniesService.dissolve(id, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post(':id/reactivate')
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.coloniesService.reactivate(id, actor.id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ColoniesController } from './colonies.controller';
import { ColoniesService } from './colonies.service';
@Module({
controllers: [ColoniesController],
providers: [ColoniesService],
exports: [ColoniesService],
})
export class ColoniesModule {}
@@ -0,0 +1,204 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { ColoniesService } from './colonies.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
describe('ColoniesService', () => {
let service: ColoniesService;
let tx: {
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
colonyApiaryHistory: { create: jest.Mock };
colonyLineage: { create: jest.Mock };
};
let prisma: {
colony: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
colonyApiaryHistory: { findMany: jest.Mock };
queenColonyHistory: { findMany: jest.Mock };
colonyLineage: { findMany: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
const parentColony = {
id: 'colony-1',
code: 'V-001',
apiaryId: 'apiary-1',
status: 'ACTIVE',
deactivatedAt: null,
};
beforeEach(() => {
tx = {
colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
colonyApiaryHistory: { create: jest.fn() },
colonyLineage: { create: jest.fn() },
};
prisma = {
colony: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
colonyApiaryHistory: { findMany: jest.fn() },
queenColonyHistory: { findMany: jest.fn() },
colonyLineage: { findMany: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
service = new ColoniesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
);
});
it('findById throws NotFoundException when missing', async () => {
prisma.colony.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('move updates the apiary and records apiary history', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
tx.colonyApiaryHistory.create.mockResolvedValue({});
const result = await service.move(
'colony-1',
{ apiaryId: 'apiary-2' },
'user-1',
);
expect(result.apiaryId).toBe('apiary-2');
const [[historyArg]] = tx.colonyApiaryHistory.create.mock.calls as [
[{ data: { colonyId: string; apiaryId: string } }],
];
expect(historyArg.data.colonyId).toBe('colony-1');
expect(historyArg.data.apiaryId).toBe('apiary-2');
});
it('split creates a new colony and a SPLIT lineage entry', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
const child = { id: 'colony-2', code: 'V-001-A', apiaryId: 'apiary-1' };
tx.colony.create.mockResolvedValue(child);
tx.colonyLineage.create.mockResolvedValue({});
const result = await service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
);
expect(result).toEqual(child);
const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [
[{ data: { type: string; fromColonyId: string; toColonyId: string } }],
];
expect(lineageArg.data.type).toBe('SPLIT');
expect(lineageArg.data.fromColonyId).toBe('colony-1');
expect(lineageArg.data.toColonyId).toBe('colony-2');
});
it('merge marks the merged colony as MERGED and records a MERGE lineage entry', async () => {
prisma.colony.findUnique
.mockResolvedValueOnce(parentColony)
.mockResolvedValueOnce({
...parentColony,
id: 'colony-1',
});
tx.colony.findUnique.mockResolvedValue({
id: 'colony-3',
status: 'ACTIVE',
});
tx.colony.update.mockResolvedValue({});
tx.colonyLineage.create.mockResolvedValue({});
await service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3'] },
'user-1',
);
const [[updateArg]] = tx.colony.update.mock.calls as [
[{ where: { id: string }; data: { status: string } }],
];
expect(updateArg.where.id).toBe('colony-3');
expect(updateArg.data.status).toBe('MERGED');
const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [
[{ data: { type: string; fromColonyId: string; toColonyId: string } }],
];
expect(lineageArg.data.type).toBe('MERGE');
expect(lineageArg.data.fromColonyId).toBe('colony-3');
expect(lineageArg.data.toColonyId).toBe('colony-1');
});
it('move rejects a non-active colony', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('dissolve sets status DISSOLVED and records DEACTIVATE', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.update.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
const result = await service.dissolve('colony-1', 'user-1');
expect(result.status).toBe('DISSOLVED');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEACTIVATE' }),
);
});
it('reactivate throws ConflictException when already active', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
await expect(
service.reactivate('colony-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('reactivate sets status ACTIVE and clears deactivatedAt', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
deactivatedAt: new Date(),
});
prisma.colony.update.mockResolvedValue({
...parentColony,
status: 'ACTIVE',
deactivatedAt: null,
});
const result = await service.reactivate('colony-1', 'user-1');
expect(result.status).toBe('ACTIVE');
expect(prisma.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { status: 'ACTIVE', deactivatedAt: null },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'UPDATE' }),
);
});
});
@@ -0,0 +1,252 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { Colony, ColonyStatus } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto';
import { MoveColonyDto } from './dto/move-colony.dto';
import { SplitColonyDto } from './dto/split-colony.dto';
import { MergeColonyDto } from './dto/merge-colony.dto';
export interface ColonyFilter {
status?: ColonyStatus;
apiaryId?: string;
}
@Injectable()
export class ColoniesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
return this.prisma.colony.findMany({
where: { status: filter.status, apiaryId: filter.apiaryId },
orderBy: { code: 'asc' },
});
}
async findById(id: string): Promise<Colony> {
const colony = await this.prisma.colony.findUnique({ where: { id } });
if (!colony) {
throw new NotFoundException('Colony not found');
}
return colony;
}
private assertActive(colony: Colony): void {
if (colony.status !== 'ACTIVE') {
throw new ConflictException(
`Colony ${colony.id} is not active (status: ${colony.status})`,
);
}
}
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
const colony = await this.prisma.colony.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Colony',
entityId: colony.id,
action: 'CREATE',
userId: actingUserId,
afterData: colony,
});
await this.webhooks.dispatch('colony.created', colony);
return colony;
}
async update(
id: string,
dto: UpdateColonyDto,
actingUserId: string,
): Promise<Colony> {
const before = await this.findById(id);
const colony = await this.prisma.colony.update({
where: { id },
data: { ...dto },
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
async move(
id: string,
dto: MoveColonyDto,
actingUserId: string,
): Promise<Colony> {
const before = await this.findById(id);
this.assertActive(before);
const colony = await this.prisma.$transaction(async (tx) => {
const updated = await tx.colony.update({
where: { id },
data: { apiaryId: dto.apiaryId },
});
await tx.colonyApiaryHistory.create({
data: {
colonyId: id,
apiaryId: dto.apiaryId,
movedByUserId: actingUserId,
notes: dto.notes,
},
});
return updated;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
async split(
id: string,
dto: SplitColonyDto,
actingUserId: string,
): Promise<Colony> {
const parent = await this.findById(id);
this.assertActive(parent);
const child = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({
data: {
code: dto.code,
apiaryId: parent.apiaryId,
colonyType: dto.colonyType,
notes: dto.notes,
},
});
await tx.colonyLineage.create({
data: {
type: 'SPLIT',
fromColonyId: parent.id,
toColonyId: created.id,
date: new Date(),
notes: dto.notes,
},
});
return created;
});
await this.auditLog.record({
entityType: 'Colony',
entityId: child.id,
action: 'CREATE',
userId: actingUserId,
afterData: { ...child, splitFromColonyId: parent.id },
});
return child;
}
async merge(
id: string,
dto: MergeColonyDto,
actingUserId: string,
): Promise<Colony> {
const survivor = await this.findById(id);
this.assertActive(survivor);
await this.prisma.$transaction(async (tx) => {
for (const mergedColonyId of dto.mergedColonyIds) {
const merged = await tx.colony.findUnique({
where: { id: mergedColonyId },
});
if (!merged) {
throw new NotFoundException(`Colony ${mergedColonyId} not found`);
}
this.assertActive(merged);
await tx.colony.update({
where: { id: mergedColonyId },
data: { status: 'MERGED', deactivatedAt: new Date() },
});
await tx.colonyLineage.create({
data: {
type: 'MERGE',
fromColonyId: mergedColonyId,
toColonyId: survivor.id,
date: new Date(),
notes: dto.notes,
},
});
}
});
await this.auditLog.record({
entityType: 'Colony',
entityId: survivor.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: survivor,
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
});
return this.findById(survivor.id);
}
async dissolve(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id);
const colony = await this.prisma.colony.update({
where: { id },
data: { status: 'DISSOLVED', deactivatedAt: new Date() },
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
async reactivate(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id);
if (before.status === 'ACTIVE') {
throw new ConflictException('Colony is already active');
}
const colony = await this.prisma.colony.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
await this.auditLog.record({
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: colony,
});
return colony;
}
async getHistory(id: string) {
await this.findById(id);
const [apiaryHistory, queenHistory, lineageAsSource, lineageAsResult] =
await Promise.all([
this.prisma.colonyApiaryHistory.findMany({
where: { colonyId: id },
orderBy: { movedAt: 'desc' },
}),
this.prisma.queenColonyHistory.findMany({
where: { colonyId: id },
orderBy: { startDate: 'desc' },
}),
this.prisma.colonyLineage.findMany({ where: { fromColonyId: id } }),
this.prisma.colonyLineage.findMany({ where: { toColonyId: id } }),
]);
return { apiaryHistory, queenHistory, lineageAsSource, lineageAsResult };
}
}
@@ -0,0 +1,52 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator';
import { ColonyType } from '../../../generated/prisma';
export class CreateColonyDto {
@ApiProperty()
@IsString()
code!: string;
@ApiProperty()
@IsString()
apiaryId!: string;
@ApiProperty({ enum: ColonyType })
@IsEnum(ColonyType)
colonyType!: ColonyType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
hiveType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
originText?: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
strength?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
broodStatus?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
feedStatus?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
healthStatus?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator';
export class MergeColonyDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
mergedColonyIds!: string[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class MoveColonyDto {
@ApiProperty()
@IsString()
apiaryId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,18 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ColonyType } from '../../../generated/prisma';
export class SplitColonyDto {
@ApiProperty()
@IsString()
code!: string;
@ApiProperty({ enum: ColonyType })
@IsEnum(ColonyType)
colonyType!: ColonyType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,24 @@
import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { ColonyStatus } from '../../../generated/prisma';
import { CreateColonyDto } from './create-colony.dto';
class UpdatableColonyFieldsDto extends PartialType(
PickType(CreateColonyDto, [
'hiveType',
'originText',
'colonyType',
'strength',
'broodStatus',
'feedStatus',
'healthStatus',
'notes',
] as const),
) {}
export class UpdateColonyDto extends UpdatableColonyFieldsDto {
@ApiPropertyOptional({ enum: ColonyStatus })
@IsOptional()
@IsEnum(ColonyStatus)
status?: ColonyStatus;
}
@@ -0,0 +1,20 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
@ApiTags('dashboard')
@ApiBearerAuth()
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get('summary')
summary() {
return this.dashboardService.summary();
}
@Get('alerts')
alerts() {
return this.dashboardService.alerts();
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}
@@ -0,0 +1,66 @@
import { DashboardService } from './dashboard.service';
import { PrismaService } from '../prisma/prisma.service';
describe('DashboardService', () => {
let service: DashboardService;
let prisma: {
apiary: { count: jest.Mock };
colony: { count: jest.Mock; findMany: jest.Mock };
queen: { count: jest.Mock };
task: { count: jest.Mock; findMany: jest.Mock };
honeyBatch: { count: jest.Mock };
stockItem: { findMany: jest.Mock };
};
beforeEach(() => {
prisma = {
apiary: { count: jest.fn().mockResolvedValue(2) },
colony: {
count: jest.fn().mockResolvedValue(5),
findMany: jest.fn().mockResolvedValue([]),
},
queen: { count: jest.fn().mockResolvedValue(4) },
task: {
count: jest.fn().mockResolvedValue(3),
findMany: jest.fn().mockResolvedValue([]),
},
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new DashboardService(prisma as unknown as PrismaService);
});
it('summary counts low-stock items below their minimum threshold', async () => {
prisma.stockItem.findMany.mockResolvedValue([
{ id: 'item-1', quantity: 2, minThreshold: 5 },
{ id: 'item-2', quantity: 10, minThreshold: 5 },
]);
const summary = await service.summary();
expect(summary.stockItemsLowCount).toBe(1);
expect(summary.apiariesActive).toBe(2);
expect(summary.coloniesActive).toBe(5);
});
it('alerts flags overdue tasks, attention colonies, and low stock', async () => {
prisma.task.findMany.mockResolvedValue([
{ id: 'task-1', title: 'Beute prüfen' },
]);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-1', code: 'V-001', status: 'WEAK' },
]);
prisma.stockItem.findMany.mockResolvedValue([
{ id: 'item-1', name: 'Zuckerlösung', quantity: 2, minThreshold: 5 },
]);
const alerts = await service.alerts();
expect(alerts).toHaveLength(3);
expect(alerts.map((alert) => alert.type)).toEqual([
'OVERDUE_TASK',
'COLONY_ATTENTION',
'LOW_STOCK',
]);
});
});
@@ -0,0 +1,107 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface DashboardSummary {
apiariesActive: number;
coloniesActive: number;
queensActive: number;
openTasks: number;
honeyBatchesTotal: number;
stockItemsLowCount: number;
}
export interface DashboardAlert {
type: 'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK';
message: string;
relatedType: string;
relatedId: string;
}
const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const;
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
async summary(): Promise<DashboardSummary> {
const [
apiariesActive,
coloniesActive,
queensActive,
openTasks,
honeyBatchesTotal,
stockItems,
] = await Promise.all([
this.prisma.apiary.count({ where: { status: 'ACTIVE' } }),
this.prisma.colony.count({ where: { status: 'ACTIVE' } }),
this.prisma.queen.count({
where: { status: { in: ['ACTIVE', 'RESERVE'] } },
}),
this.prisma.task.count({
where: { status: { in: ['OPEN', 'IN_PROGRESS'] } },
}),
this.prisma.honeyBatch.count(),
this.prisma.stockItem.findMany({
where: { minThreshold: { not: null } },
}),
]);
const stockItemsLowCount = stockItems.filter(
(item) => item.minThreshold !== null && item.quantity < item.minThreshold,
).length;
return {
apiariesActive,
coloniesActive,
queensActive,
openTasks,
honeyBatchesTotal,
stockItemsLowCount,
};
}
async alerts(): Promise<DashboardAlert[]> {
const [overdueTasks, attentionColonies, stockItems] = await Promise.all([
this.prisma.task.findMany({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
dueDate: { lt: new Date() },
},
}),
this.prisma.colony.findMany({
where: { status: { in: [...ATTENTION_COLONY_STATUSES] } },
}),
this.prisma.stockItem.findMany({
where: { minThreshold: { not: null } },
}),
]);
const alerts: DashboardAlert[] = [];
for (const task of overdueTasks) {
alerts.push({
type: 'OVERDUE_TASK',
message: `Aufgabe "${task.title}" ist überfällig`,
relatedType: 'Task',
relatedId: task.id,
});
}
for (const colony of attentionColonies) {
alerts.push({
type: 'COLONY_ATTENTION',
message: `Volk ${colony.code} benötigt Aufmerksamkeit (${colony.status})`,
relatedType: 'Colony',
relatedId: colony.id,
});
}
for (const item of stockItems) {
if (item.minThreshold !== null && item.quantity < item.minThreshold) {
alerts.push({
type: 'LOW_STOCK',
message: `Lagerartikel "${item.name}" unter Mindestbestand`,
relatedType: 'StockItem',
relatedId: item.id,
});
}
}
return alerts;
}
}
@@ -0,0 +1,10 @@
export const ALLOWED_DOCUMENT_MIME_TYPES: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'application/pdf': '.pdf',
};
export function isAllowedDocumentMimeType(mimeType: string): boolean {
return mimeType in ALLOWED_DOCUMENT_MIME_TYPES;
}
@@ -0,0 +1,111 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { DocumentEntityType, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { isAllowedDocumentMimeType } from './document-mime-types';
import { CreateDocumentDto } from './dto/create-document.dto';
import { DocumentsService } from './documents.service';
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
function sanitizeFilenameHeader(fileName: string): string {
return fileName.replace(/[\r\n"]/g, '_');
}
@ApiTags('documents')
@ApiBearerAuth()
@Controller('documents')
export class DocumentsController {
constructor(private readonly documentsService: DocumentsService) {}
@Get()
findAll(
@Query('entityType') entityType: string,
@Query('entityId') entityId: string,
) {
if (!entityType || !entityId) {
throw new BadRequestException('entityType and entityId are required');
}
if (
!Object.values(DocumentEntityType).includes(
entityType as DocumentEntityType,
)
) {
throw new BadRequestException('Invalid entityType');
}
return this.documentsService.findAll(
entityType as DocumentEntityType,
entityId,
);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Post()
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_FILE_SIZE_BYTES },
fileFilter: (_req, file, callback) => {
if (!isAllowedDocumentMimeType(file.mimetype)) {
callback(
new BadRequestException('Nicht unterstützter Dateityp'),
false,
);
return;
}
callback(null, true);
},
}),
)
create(
@UploadedFile() file: Express.Multer.File | undefined,
@Body() dto: CreateDocumentDto,
@CurrentUser() actor: AuthenticatedUser,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.documentsService.create(
file,
dto.entityType,
dto.entityId,
actor.id,
);
}
@Get(':id/download')
async download(@Param('id') id: string, @Res() res: Response): Promise<void> {
const { buffer, fileName, mimeType } =
await this.documentsService.readFile(id);
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
`attachment; filename="${sanitizeFilenameHeader(fileName)}"`,
);
res.send(buffer);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Delete(':id')
@HttpCode(204)
delete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.documentsService.delete(id, actor.id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DocumentsController } from './documents.controller';
import { DocumentsService } from './documents.service';
@Module({
controllers: [DocumentsController],
providers: [DocumentsService],
})
export class DocumentsModule {}
@@ -0,0 +1,170 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import * as fs from 'node:fs/promises';
import { DocumentsService } from './documents.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
jest.mock('node:fs/promises');
describe('DocumentsService', () => {
let service: DocumentsService;
let prisma: {
document: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
delete: jest.Mock;
};
apiary: { findUnique: jest.Mock };
colony: { findUnique: jest.Mock };
queen: { findUnique: jest.Mock };
inspection: { findUnique: jest.Mock };
honeyBatch: { findUnique: jest.Mock };
};
let auditLog: { record: jest.Mock };
const document = {
id: 'doc-1',
entityType: 'COLONY',
entityId: 'colony-1',
fileName: 'bild.jpg',
mimeType: 'image/jpeg',
sizeBytes: 1234,
storagePath: '/tmp/uploads/colony/abc.jpg',
uploadedByUserId: 'user-1',
createdAt: new Date('2026-01-01'),
};
beforeEach(() => {
prisma = {
document: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
apiary: { findUnique: jest.fn() },
colony: { findUnique: jest.fn() },
queen: { findUnique: jest.fn() },
inspection: { findUnique: jest.fn() },
honeyBatch: { findUnique: jest.fn() },
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new DocumentsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.readFile as jest.Mock).mockResolvedValue(Buffer.from('data'));
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
});
it('findAll strips storagePath from results', async () => {
prisma.document.findMany.mockResolvedValueOnce([document]);
const result = await service.findAll('COLONY', 'colony-1');
expect(result).toEqual([
{
id: 'doc-1',
entityType: 'COLONY',
entityId: 'colony-1',
fileName: 'bild.jpg',
mimeType: 'image/jpeg',
sizeBytes: 1234,
uploadedByUserId: 'user-1',
createdAt: document.createdAt,
},
]);
});
describe('create', () => {
const file = {
buffer: Buffer.from('binary'),
originalname: 'bild.jpg',
mimetype: 'image/jpeg',
size: 1234,
} as Express.Multer.File;
it('rejects an unsupported mime type', async () => {
const badFile = {
...file,
mimetype: 'application/zip',
};
await expect(
service.create(badFile, 'COLONY', 'colony-1', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('throws NotFoundException when the referenced entity does not exist', async () => {
prisma.colony.findUnique.mockResolvedValue(null);
await expect(
service.create(file, 'COLONY', 'missing', 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('writes the file to disk and persists a document row', async () => {
prisma.colony.findUnique.mockResolvedValue({ id: 'colony-1' });
prisma.document.create.mockResolvedValue(document);
const result = await service.create(file, 'COLONY', 'colony-1', 'user-1');
expect(fs.mkdir).toHaveBeenCalled();
expect(fs.writeFile).toHaveBeenCalled();
const [[createArg]] = prisma.document.create.mock.calls as [
[
{
data: {
entityType: string;
entityId: string;
fileName: string;
uploadedByUserId: string;
};
},
],
];
expect(createArg.data.entityType).toBe('COLONY');
expect(createArg.data.entityId).toBe('colony-1');
expect(createArg.data.fileName).toBe('bild.jpg');
expect(createArg.data.uploadedByUserId).toBe('user-1');
expect(result).not.toHaveProperty('storagePath');
});
});
describe('readFile', () => {
it('throws NotFoundException when the document does not exist', async () => {
prisma.document.findUnique.mockResolvedValue(null);
await expect(service.readFile('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('reads the stored file from disk', async () => {
prisma.document.findUnique.mockResolvedValue(document);
const result = await service.readFile('doc-1');
expect(fs.readFile).toHaveBeenCalledWith(document.storagePath);
expect(result.fileName).toBe('bild.jpg');
expect(result.mimeType).toBe('image/jpeg');
});
});
describe('delete', () => {
it('throws NotFoundException when the document does not exist', async () => {
prisma.document.findUnique.mockResolvedValue(null);
await expect(service.delete('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('deletes the db row and unlinks the file', async () => {
prisma.document.findUnique.mockResolvedValue(document);
await service.delete('doc-1', 'user-1');
expect(prisma.document.delete).toHaveBeenCalledWith({
where: { id: 'doc-1' },
});
expect(fs.unlink).toHaveBeenCalledWith(document.storagePath);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Document', action: 'DELETE' }),
);
});
});
});
@@ -0,0 +1,153 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Document, DocumentEntityType } from '../../generated/prisma';
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
export type DocumentSummary = Omit<Document, 'storagePath'>;
function storageRoot(): string {
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
}
function toSummary(document: Document): DocumentSummary {
return {
id: document.id,
entityType: document.entityType,
entityId: document.entityId,
fileName: document.fileName,
mimeType: document.mimeType,
sizeBytes: document.sizeBytes,
uploadedByUserId: document.uploadedByUserId,
createdAt: document.createdAt,
};
}
@Injectable()
export class DocumentsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(
entityType: DocumentEntityType,
entityId: string,
): Promise<DocumentSummary[]> {
const documents = await this.prisma.document.findMany({
where: { entityType, entityId },
orderBy: { createdAt: 'desc' },
});
return documents.map(toSummary);
}
private async assertEntityExists(
entityType: DocumentEntityType,
entityId: string,
): Promise<void> {
const exists = await (() => {
switch (entityType) {
case 'APIARY':
return this.prisma.apiary.findUnique({ where: { id: entityId } });
case 'COLONY':
return this.prisma.colony.findUnique({ where: { id: entityId } });
case 'QUEEN':
return this.prisma.queen.findUnique({ where: { id: entityId } });
case 'INSPECTION':
return this.prisma.inspection.findUnique({
where: { id: entityId },
});
case 'HONEY_BATCH':
return this.prisma.honeyBatch.findUnique({
where: { id: entityId },
});
default:
return Promise.resolve(null);
}
})();
if (!exists) {
throw new NotFoundException(`${entityType} ${entityId} not found`);
}
}
async create(
file: Express.Multer.File,
entityType: DocumentEntityType,
entityId: string,
actingUserId: string,
): Promise<DocumentSummary> {
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
throw new BadRequestException('Nicht unterstützter Dateityp');
}
await this.assertEntityExists(entityType, entityId);
const extension = ALLOWED_DOCUMENT_MIME_TYPES[file.mimetype];
const storageFileName = `${crypto.randomUUID()}${extension}`;
const directory = path.join(storageRoot(), entityType.toLowerCase());
await fs.mkdir(directory, { recursive: true });
const storagePath = path.join(directory, storageFileName);
await fs.writeFile(storagePath, file.buffer);
const document = await this.prisma.document.create({
data: {
entityType,
entityId,
fileName: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
storagePath,
uploadedByUserId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'Document',
entityId: document.id,
action: 'CREATE',
userId: actingUserId,
afterData: toSummary(document),
});
return toSummary(document);
}
async readFile(
id: string,
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
const document = await this.prisma.document.findUnique({
where: { id },
});
if (!document) {
throw new NotFoundException('Document not found');
}
const buffer = await fs.readFile(document.storagePath);
return {
buffer,
fileName: document.fileName,
mimeType: document.mimeType,
};
}
async delete(id: string, actingUserId: string): Promise<void> {
const document = await this.prisma.document.findUnique({
where: { id },
});
if (!document) {
throw new NotFoundException('Document not found');
}
await this.prisma.document.delete({ where: { id } });
await fs.unlink(document.storagePath).catch(() => undefined);
await this.auditLog.record({
entityType: 'Document',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: toSummary(document),
});
}
}
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { DocumentEntityType } from '../../../generated/prisma';
export class CreateDocumentDto {
@ApiProperty({ enum: DocumentEntityType })
@IsEnum(DocumentEntityType)
entityType!: DocumentEntityType;
@ApiProperty()
@IsString()
@IsNotEmpty()
entityId!: string;
}
@@ -0,0 +1,32 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString } from 'class-validator';
export class CreateEquipmentDto {
@ApiProperty()
@IsString()
inventoryNumber!: string;
@ApiProperty()
@IsString()
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
manufacturer?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
purchaseDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
location?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
maintenanceDate?: string;
}
@@ -0,0 +1,11 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { EquipmentCondition } from '../../../generated/prisma';
import { CreateEquipmentDto } from './create-equipment.dto';
export class UpdateEquipmentDto extends PartialType(CreateEquipmentDto) {
@ApiPropertyOptional({ enum: EquipmentCondition })
@IsOptional()
@IsEnum(EquipmentCondition)
condition?: EquipmentCondition;
}
@@ -0,0 +1,53 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { EquipmentCondition, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
import { EquipmentService } from './equipment.service';
@ApiTags('equipment')
@ApiBearerAuth()
@Controller('equipment')
export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {}
@Get()
findAll(@Query('condition') condition?: EquipmentCondition) {
return this.equipmentService.findAll({ condition });
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.equipmentService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@Body() dto: CreateEquipmentDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.equipmentService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateEquipmentDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.equipmentService.update(id, dto, actor.id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { EquipmentController } from './equipment.controller';
import { EquipmentService } from './equipment.service';
@Module({
controllers: [EquipmentController],
providers: [EquipmentService],
})
export class EquipmentModule {}
@@ -0,0 +1,81 @@
import { NotFoundException } from '@nestjs/common';
import { EquipmentService } from './equipment.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('EquipmentService', () => {
let service: EquipmentService;
let prisma: {
equipment: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
prisma = {
equipment: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
auditLog = { record: jest.fn() };
service = new EquipmentService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('creates equipment and records an audit log entry', async () => {
const created = {
id: 'e1',
inventoryNumber: 'INV-1',
name: 'Honigschleuder',
};
prisma.equipment.create.mockResolvedValueOnce(created);
const result = await service.create(
{ inventoryNumber: 'INV-1', name: 'Honigschleuder' },
'user-1',
);
expect(result).toEqual(created);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Equipment', action: 'CREATE' }),
);
});
it('throws NotFoundException when equipment does not exist', async () => {
prisma.equipment.findUnique.mockResolvedValueOnce(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('updates equipment condition and records before/after audit data', async () => {
const before = { id: 'e1', condition: 'GOOD' };
const after = { id: 'e1', condition: 'WORN' };
prisma.equipment.findUnique.mockResolvedValueOnce(before);
prisma.equipment.update.mockResolvedValueOnce(after);
const result = await service.update(
'e1',
{ condition: 'WORN' } as never,
'user-1',
);
expect(result).toEqual(after);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: before,
afterData: after,
}),
);
});
});
@@ -0,0 +1,69 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Equipment, EquipmentCondition } from '../../generated/prisma';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
export interface EquipmentFilter {
condition?: EquipmentCondition;
}
@Injectable()
export class EquipmentService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(filter: EquipmentFilter = {}): Promise<Equipment[]> {
return this.prisma.equipment.findMany({
where: { condition: filter.condition },
orderBy: { name: 'asc' },
});
}
async findById(id: string): Promise<Equipment> {
const equipment = await this.prisma.equipment.findUnique({ where: { id } });
if (!equipment) {
throw new NotFoundException('Equipment not found');
}
return equipment;
}
async create(
dto: CreateEquipmentDto,
actingUserId: string,
): Promise<Equipment> {
const equipment = await this.prisma.equipment.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Equipment',
entityId: equipment.id,
action: 'CREATE',
userId: actingUserId,
afterData: equipment,
});
return equipment;
}
async update(
id: string,
dto: UpdateEquipmentDto,
actingUserId: string,
): Promise<Equipment> {
const before = await this.findById(id);
const equipment = await this.prisma.equipment.update({
where: { id },
data: { ...dto },
});
await this.auditLog.record({
entityType: 'Equipment',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: equipment,
});
return equipment;
}
}
@@ -0,0 +1,372 @@
import { NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
function yearRange(year: number): { start: Date; end: Date } {
return {
start: new Date(Date.UTC(year, 0, 1)),
end: new Date(Date.UTC(year + 1, 0, 1)),
};
}
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE');
}
const SWARM_MOOD_LABELS: Record<string, string> = {
NONE: 'Keine',
LOW: 'Gering',
HIGH: 'Hoch',
};
const SWARM_ACTION_LABELS: Record<string, string> = {
NONE: 'Keine',
QUEEN_CELLS_REMOVED: 'Weiselzellen gebrochen',
ARTIFICIAL_SWARM: 'Kunstschwarm gebildet',
COLONY_DIVIDED: 'Volk geteilt',
OTHER: 'Sonstiges',
};
export interface ColonyAnnualReport {
colony: { id: string; code: string; colonyType: string; status: string };
apiary: { id: string; name: string } | null;
year: number;
inspections: {
date: Date;
colonyStrength: number | null;
queenSeen: boolean | null;
broodAmount: string | null;
}[];
treatments: { date: Date; product: string; amount: string | null }[];
varroaMeasurements: {
date: Date;
method: string;
miteCount: number | null;
calculatedValue: number | null;
}[];
swarmControlChecks: {
date: Date;
queenCellsFound: number | null;
queenCellsCapped: boolean;
swarmMood: string;
actionTaken: string;
}[];
harvests: {
date: Date;
honeyType: string | null;
netWeight: number | null;
}[];
}
export interface ApiaryAnnualReport {
apiary: { id: string; name: string };
year: number;
colonies: {
code: string;
inspectionCount: number;
treatmentCount: number;
harvestCount: number;
}[];
harvests: {
date: Date;
honeyType: string | null;
netWeight: number | null;
grossWeight: number | null;
}[];
}
export async function buildColonyAnnualReport(
prisma: PrismaService,
colonyId: string,
year: number,
): Promise<ColonyAnnualReport> {
const colony = await prisma.colony.findUnique({
where: { id: colonyId },
include: { apiary: true },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
const { start, end } = yearRange(year);
const dateInYear = { gte: start, lt: end };
const [
inspections,
treatments,
varroaMeasurements,
swarmControlChecks,
harvestLinks,
] = await Promise.all([
prisma.inspection.findMany({
where: { colonyId, date: dateInYear },
orderBy: { date: 'asc' },
}),
prisma.treatment.findMany({
where: { colonyId, date: dateInYear },
orderBy: { date: 'asc' },
}),
prisma.varroaMeasurement.findMany({
where: { colonyId, date: dateInYear },
orderBy: { date: 'asc' },
}),
prisma.swarmControlCheck.findMany({
where: { colonyId, date: dateInYear },
orderBy: { date: 'asc' },
}),
prisma.harvestColony.findMany({
where: { colonyId, harvest: { date: dateInYear } },
include: { harvest: true },
orderBy: { harvest: { date: 'asc' } },
}),
]);
return {
colony: {
id: colony.id,
code: colony.code,
colonyType: colony.colonyType,
status: colony.status,
},
apiary: colony.apiary
? { id: colony.apiary.id, name: colony.apiary.name }
: null,
year,
inspections: inspections.map((entry) => ({
date: entry.date,
colonyStrength: entry.colonyStrength,
queenSeen: entry.queenSeen,
broodAmount: entry.broodAmount,
})),
treatments: treatments.map((entry) => ({
date: entry.date,
product: entry.product,
amount: entry.amount,
})),
varroaMeasurements: varroaMeasurements.map((entry) => ({
date: entry.date,
method: entry.method,
miteCount: entry.miteCount,
calculatedValue: entry.calculatedValue,
})),
swarmControlChecks: swarmControlChecks.map((entry) => ({
date: entry.date,
queenCellsFound: entry.queenCellsFound,
queenCellsCapped: entry.queenCellsCapped,
swarmMood: entry.swarmMood,
actionTaken: entry.actionTaken,
})),
harvests: harvestLinks.map((link) => ({
date: link.harvest.date,
honeyType: link.harvest.honeyType,
netWeight: link.harvest.netWeight,
})),
};
}
export async function buildApiaryAnnualReport(
prisma: PrismaService,
apiaryId: string,
year: number,
): Promise<ApiaryAnnualReport> {
const apiary = await prisma.apiary.findUnique({ where: { id: apiaryId } });
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
const { start, end } = yearRange(year);
const dateInYear = { gte: start, lt: end };
const colonies = await prisma.colony.findMany({
where: { apiaryId },
orderBy: { code: 'asc' },
});
const harvests = await prisma.harvest.findMany({
where: { apiaryId, date: dateInYear },
orderBy: { date: 'asc' },
});
const colonySummaries = await Promise.all(
colonies.map(async (colony) => {
const [inspectionCount, treatmentCount, harvestCount] = await Promise.all(
[
prisma.inspection.count({
where: { colonyId: colony.id, date: dateInYear },
}),
prisma.treatment.count({
where: { colonyId: colony.id, date: dateInYear },
}),
prisma.harvestColony.count({
where: { colonyId: colony.id, harvest: { date: dateInYear } },
}),
],
);
return {
code: colony.code,
inspectionCount,
treatmentCount,
harvestCount,
};
}),
);
return {
apiary: { id: apiary.id, name: apiary.name },
year,
colonies: colonySummaries,
harvests: harvests.map((harvest) => ({
date: harvest.date,
honeyType: harvest.honeyType,
netWeight: harvest.netWeight,
grossWeight: harvest.grossWeight,
})),
};
}
export function renderColonyAnnualReportPdf(
report: ColonyAnnualReport,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', (error: Error) => reject(error));
doc.fontSize(18).text(`Jahresbericht Volk ${report.colony.code}`, {
underline: true,
});
doc.fontSize(12).text(`Jahr: ${report.year}`);
if (report.apiary) {
doc.text(`Stand: ${report.apiary.name}`);
}
doc.text(`Status: ${report.colony.status}`);
doc.moveDown();
doc.fontSize(14).text(`Durchsichten (${report.inspections.length})`);
doc.fontSize(12);
if (report.inspections.length === 0) {
doc.text('Keine Durchsichten im Berichtsjahr.');
}
for (const entry of report.inspections) {
doc.text(
`- ${formatDate(entry.date)}: Staerke ${entry.colonyStrength ?? '-'}, ` +
`Koenigin gesehen: ${entry.queenSeen === null ? '-' : entry.queenSeen ? 'ja' : 'nein'}, ` +
`Brut: ${entry.broodAmount ?? '-'}`,
);
}
doc.moveDown();
doc.fontSize(14).text(`Behandlungen (${report.treatments.length})`);
doc.fontSize(12);
if (report.treatments.length === 0) {
doc.text('Keine Behandlungen im Berichtsjahr.');
}
for (const entry of report.treatments) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.product} (${entry.amount ?? '-'})`,
);
}
doc.moveDown();
doc
.fontSize(14)
.text(`Varroakontrollen (${report.varroaMeasurements.length})`);
doc.fontSize(12);
if (report.varroaMeasurements.length === 0) {
doc.text('Keine Varroakontrollen im Berichtsjahr.');
}
for (const entry of report.varroaMeasurements) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.method}, ` +
`${entry.miteCount ?? '-'} Milben` +
(entry.calculatedValue !== null
? ` (${entry.calculatedValue.toFixed(1)} Milben/Tag)`
: ''),
);
}
doc.moveDown();
doc
.fontSize(14)
.text(`Schwarmkontrolle (${report.swarmControlChecks.length})`);
doc.fontSize(12);
if (report.swarmControlChecks.length === 0) {
doc.text('Keine Schwarmkontrolle im Berichtsjahr.');
}
for (const entry of report.swarmControlChecks) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.queenCellsFound ?? 0} Weiselzelle(n)` +
`${entry.queenCellsCapped ? ' (verdeckelt)' : ''}, ` +
`Stimmung: ${SWARM_MOOD_LABELS[entry.swarmMood] ?? entry.swarmMood}, ` +
`Massnahme: ${SWARM_ACTION_LABELS[entry.actionTaken] ?? entry.actionTaken}`,
);
}
doc.moveDown();
doc.fontSize(14).text(`Ernten (${report.harvests.length})`);
doc.fontSize(12);
if (report.harvests.length === 0) {
doc.text('Keine Ernten im Berichtsjahr.');
}
for (const entry of report.harvests) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.honeyType ?? '-'}, ` +
`${entry.netWeight ?? '-'} kg (Stand-Ernte, ggf. mit anderen Voelkern geteilt)`,
);
}
doc.end();
});
}
export function renderApiaryAnnualReportPdf(
report: ApiaryAnnualReport,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', (error: Error) => reject(error));
doc.fontSize(18).text(`Jahresbericht Stand ${report.apiary.name}`, {
underline: true,
});
doc.fontSize(12).text(`Jahr: ${report.year}`);
doc.moveDown();
doc.fontSize(14).text(`Voelker (${report.colonies.length})`);
doc.fontSize(12);
if (report.colonies.length === 0) {
doc.text('Keine Voelker an diesem Stand.');
}
for (const colony of report.colonies) {
doc.text(
`- ${colony.code}: ${colony.inspectionCount} Durchsicht(en), ` +
`${colony.treatmentCount} Behandlung(en), ${colony.harvestCount} Ernte(n)`,
);
}
doc.moveDown();
const totalNetWeight = report.harvests.reduce(
(sum, harvest) => sum + (harvest.netWeight ?? 0),
0,
);
doc
.fontSize(14)
.text(
`Ernten (${report.harvests.length}), gesamt ${totalNetWeight.toFixed(1)} kg`,
);
doc.fontSize(12);
if (report.harvests.length === 0) {
doc.text('Keine Ernten im Berichtsjahr.');
}
for (const entry of report.harvests) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.honeyType ?? '-'}, ` +
`${entry.netWeight ?? '-'} kg netto / ${entry.grossWeight ?? '-'} kg brutto`,
);
}
doc.end();
});
}
@@ -0,0 +1,67 @@
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { ExportService } from './export.service';
@ApiTags('export')
@ApiBearerAuth()
@Controller('export')
export class ExportController {
constructor(private readonly exportService: ExportService) {}
@Get('colonies.csv')
async colonies(@Res() res: Response): Promise<void> {
const csv = await this.exportService.exportColoniesCsv();
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="colonies.csv"');
res.send(csv);
}
@Get('honey-batches.csv')
async honeyBatches(@Res() res: Response): Promise<void> {
const csv = await this.exportService.exportHoneyBatchesCsv();
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader(
'Content-Disposition',
'attachment; filename="honey-batches.csv"',
);
res.send(csv);
}
@Get(':entity.json')
async entityJson(
@Param('entity') entity: string,
@Res() res: Response,
): Promise<void> {
const data = await this.exportService.exportEntityJson(entity);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader(
'Content-Disposition',
`attachment; filename="${entity}.json"`,
);
res.send(JSON.stringify(data, null, 2));
}
@Get(':report.pdf')
async reportPdf(
@Param('report') report: string,
@Query('honeyBatchId') honeyBatchId: string | undefined,
@Query('colonyId') colonyId: string | undefined,
@Query('apiaryId') apiaryId: string | undefined,
@Query('year') year: string | undefined,
@Res() res: Response,
): Promise<void> {
const pdf = await this.exportService.exportReportPdf(report, {
honeyBatchId,
colonyId,
apiaryId,
year,
});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${report}.pdf"`,
);
res.send(pdf);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { HoneyBatchesModule } from '../honey-batches/honey-batches.module';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [HoneyBatchesModule],
controllers: [ExportController],
providers: [ExportService],
})
export class ExportModule {}
@@ -0,0 +1,192 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExportService } from './export.service';
import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
describe('ExportService', () => {
let service: ExportService;
let prisma: {
colony: { findMany: jest.Mock; findUnique: jest.Mock };
honeyBatch: { findMany: jest.Mock };
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
inspection: { findMany: jest.Mock; count: jest.Mock };
treatment: { findMany: jest.Mock; count: jest.Mock };
varroaMeasurement: { findMany: jest.Mock };
swarmControlCheck: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
harvestColony: { findMany: jest.Mock; count: jest.Mock };
};
let honeyBatchesService: { traceability: jest.Mock };
beforeEach(() => {
prisma = {
colony: { findMany: jest.fn(), findUnique: jest.fn() },
honeyBatch: { findMany: jest.fn() },
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
queen: { findMany: jest.fn() },
task: { findMany: jest.fn() },
inspection: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
treatment: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
harvestColony: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
};
honeyBatchesService = { traceability: jest.fn() };
service = new ExportService(
prisma as unknown as PrismaService,
honeyBatchesService as unknown as HoneyBatchesService,
);
});
it('exports colonies as CSV with header and escaped fields', async () => {
prisma.colony.findMany.mockResolvedValueOnce([
{
id: 'c1',
code: 'V-1, Ost',
apiaryId: 'a1',
colonyType: 'PRODUCTION_COLONY',
status: 'ACTIVE',
strength: 8,
healthStatus: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
]);
const csv = await service.exportColoniesCsv();
const lines = csv.split('\n');
expect(lines[0]).toBe(
'id,code,apiaryId,colonyType,status,strength,healthStatus,createdAt',
);
expect(lines[1]).toContain('"V-1, Ost"');
});
it('exports honey batches as CSV', async () => {
prisma.honeyBatch.findMany.mockResolvedValueOnce([
{
id: 'h1',
batchNumber: 'HC-1',
honeyType: 'Blüte',
amount: 20,
waterContent: 17.2,
status: 'RAW',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
]);
const csv = await service.exportHoneyBatchesCsv();
expect(csv.split('\n')[0]).toBe(
'id,batchNumber,honeyType,amount,waterContent,status,createdAt',
);
});
it('exports a supported entity as JSON', async () => {
prisma.apiary.findMany.mockResolvedValueOnce([{ id: 'a1' }]);
const data = await service.exportEntityJson('apiaries');
expect(data).toEqual([{ id: 'a1' }]);
});
it('rejects an unsupported JSON entity', async () => {
await expect(service.exportEntityJson('unknown')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('rejects an unsupported PDF report', async () => {
await expect(
service.exportReportPdf('unknown', { honeyBatchId: 'honey-batch-1' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects the traceability report without a honeyBatchId', async () => {
await expect(
service.exportReportPdf('honey-batch-traceability'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('renders a PDF buffer for the traceability report', async () => {
honeyBatchesService.traceability.mockResolvedValueOnce({
honeyBatch: {
id: 'h1',
batchNumber: 'HC-1',
honeyType: 'Blüte',
amount: 20,
status: 'RAW',
},
extraction: { id: 'e1', date: new Date('2026-01-01'), room: 'Raum 1' },
harvests: [
{
id: 'harvest-1',
date: new Date('2025-12-01'),
apiaryId: 'a1',
colonies: [{ id: 'c1', code: 'V-1' }],
},
],
});
const pdf = await service.exportReportPdf('honey-batch-traceability', {
honeyBatchId: 'h1',
});
expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
it('rejects the colony annual report without a colonyId', async () => {
await expect(
service.exportReportPdf('colony-annual-report'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects the colony annual report when the colony does not exist', async () => {
prisma.colony.findUnique.mockResolvedValueOnce(null);
await expect(
service.exportReportPdf('colony-annual-report', { colonyId: 'missing' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('renders a PDF buffer for the colony annual report', async () => {
prisma.colony.findUnique.mockResolvedValueOnce({
id: 'c1',
code: 'V-1',
colonyType: 'PRODUCTION_COLONY',
status: 'ACTIVE',
apiary: { id: 'a1', name: 'Stand Ost' },
});
const pdf = await service.exportReportPdf('colony-annual-report', {
colonyId: 'c1',
year: '2026',
});
expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
it('rejects the apiary annual report without an apiaryId', async () => {
await expect(
service.exportReportPdf('apiary-annual-report'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('renders a PDF buffer for the apiary annual report', async () => {
prisma.apiary.findUnique.mockResolvedValueOnce({
id: 'a1',
name: 'Stand Ost',
});
prisma.colony.findMany.mockResolvedValueOnce([{ id: 'c1', code: 'V-1' }]);
const pdf = await service.exportReportPdf('apiary-annual-report', {
apiaryId: 'a1',
});
expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
});
@@ -0,0 +1,232 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
import {
buildApiaryAnnualReport,
buildColonyAnnualReport,
renderApiaryAnnualReportPdf,
renderColonyAnnualReportPdf,
} from './annual-reports';
const JSON_ENTITIES = [
'apiaries',
'colonies',
'queens',
'honey-batches',
'tasks',
] as const;
type JsonEntity = (typeof JSON_ENTITIES)[number];
const PDF_REPORTS = [
'honey-batch-traceability',
'colony-annual-report',
'apiary-annual-report',
] as const;
type PdfReport = (typeof PDF_REPORTS)[number];
export interface ExportReportOptions {
honeyBatchId?: string;
colonyId?: string;
apiaryId?: string;
year?: string;
}
function resolveYear(year: string | undefined): number {
if (!year) {
return new Date().getUTCFullYear();
}
const parsed = Number(year);
if (!Number.isInteger(parsed)) {
throw new BadRequestException('Query parameter year must be an integer');
}
return parsed;
}
function isJsonEntity(value: string): value is JsonEntity {
return (JSON_ENTITIES as readonly string[]).includes(value);
}
function isPdfReport(value: string): value is PdfReport {
return (PDF_REPORTS as readonly string[]).includes(value);
}
function stringifyCsvValue(value: unknown): string {
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
function toCsvField(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
const text = stringifyCsvValue(value);
if (/[",\n]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
function toCsv(rows: Record<string, unknown>[], columns: string[]): string {
const header = columns.join(',');
const lines = rows.map((row) =>
columns.map((column) => toCsvField(row[column])).join(','),
);
return [header, ...lines].join('\n');
}
type TraceabilityReport = Awaited<
ReturnType<HoneyBatchesService['traceability']>
>;
@Injectable()
export class ExportService {
constructor(
private readonly prisma: PrismaService,
private readonly honeyBatchesService: HoneyBatchesService,
) {}
async exportColoniesCsv(): Promise<string> {
const colonies = await this.prisma.colony.findMany({
orderBy: { code: 'asc' },
});
return toCsv(colonies, [
'id',
'code',
'apiaryId',
'colonyType',
'status',
'strength',
'healthStatus',
'createdAt',
]);
}
async exportHoneyBatchesCsv(): Promise<string> {
const honeyBatches = await this.prisma.honeyBatch.findMany({
orderBy: { batchNumber: 'asc' },
});
return toCsv(honeyBatches, [
'id',
'batchNumber',
'honeyType',
'amount',
'waterContent',
'status',
'createdAt',
]);
}
async exportEntityJson(entity: string): Promise<unknown> {
if (!isJsonEntity(entity)) {
throw new BadRequestException(`Unsupported export entity: ${entity}`);
}
switch (entity) {
case 'apiaries':
return this.prisma.apiary.findMany();
case 'colonies':
return this.prisma.colony.findMany();
case 'queens':
return this.prisma.queen.findMany();
case 'honey-batches':
return this.prisma.honeyBatch.findMany();
case 'tasks':
return this.prisma.task.findMany();
}
}
async exportReportPdf(
report: string,
options: ExportReportOptions = {},
): Promise<Buffer> {
if (!isPdfReport(report)) {
throw new BadRequestException(`Unsupported export report: ${report}`);
}
switch (report) {
case 'honey-batch-traceability': {
if (!options.honeyBatchId) {
throw new BadRequestException(
'Query parameter honeyBatchId is required for this report',
);
}
const trace = await this.honeyBatchesService.traceability(
options.honeyBatchId,
);
return this.renderTraceabilityPdf(trace);
}
case 'colony-annual-report': {
if (!options.colonyId) {
throw new BadRequestException(
'Query parameter colonyId is required for this report',
);
}
const colonyReport = await buildColonyAnnualReport(
this.prisma,
options.colonyId,
resolveYear(options.year),
);
return renderColonyAnnualReportPdf(colonyReport);
}
case 'apiary-annual-report': {
if (!options.apiaryId) {
throw new BadRequestException(
'Query parameter apiaryId is required for this report',
);
}
const apiaryReport = await buildApiaryAnnualReport(
this.prisma,
options.apiaryId,
resolveYear(options.year),
);
return renderApiaryAnnualReportPdf(apiaryReport);
}
}
}
private renderTraceabilityPdf(trace: TraceabilityReport): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', (error: Error) => reject(error));
doc.fontSize(18).text('Rueckverfolgbarkeit Honigcharge', {
underline: true,
});
doc.moveDown();
doc.fontSize(12).text(`Charge: ${trace.honeyBatch.batchNumber}`);
doc.text(`Sorte: ${trace.honeyBatch.honeyType ?? '-'}`);
doc.text(`Menge: ${trace.honeyBatch.amount ?? '-'} kg`);
doc.text(`Status: ${trace.honeyBatch.status}`);
doc.moveDown();
doc.fontSize(14).text('Schleuderung');
doc
.fontSize(12)
.text(
`Datum: ${new Date(trace.extraction.date).toLocaleDateString('de-DE')}`,
);
doc.text(`Raum: ${trace.extraction.room ?? '-'}`);
doc.moveDown();
doc.fontSize(14).text('Ernten');
doc.fontSize(12);
for (const harvest of trace.harvests) {
const colonyCodes = harvest.colonies
.map((colony) => colony.code)
.join(', ');
doc.text(
`- ${new Date(harvest.date).toLocaleDateString('de-DE')}: ${colonyCodes}`,
);
}
doc.end();
});
}
}
@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
export class CreateExtractionDto {
@ApiProperty()
@IsDateString()
date!: string;
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
harvestIds!: string[];
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
amount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
room?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
waterContent?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
temperature?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,13 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateExtractionDto } from './create-extraction.dto';
export class UpdateExtractionDto extends PartialType(
PickType(CreateExtractionDto, [
'date',
'amount',
'room',
'waterContent',
'temperature',
'notes',
] as const),
) {}
@@ -0,0 +1,45 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateExtractionDto } from './dto/create-extraction.dto';
import { UpdateExtractionDto } from './dto/update-extraction.dto';
import { ExtractionsService } from './extractions.service';
@ApiTags('extractions')
@ApiBearerAuth()
@Controller('extractions')
export class ExtractionsController {
constructor(private readonly extractionsService: ExtractionsService) {}
@Get()
findAll() {
return this.extractionsService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.extractionsService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@Body() dto: CreateExtractionDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.extractionsService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateExtractionDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.extractionsService.update(id, dto, actor.id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExtractionsController } from './extractions.controller';
import { ExtractionsService } from './extractions.service';
@Module({
controllers: [ExtractionsController],
providers: [ExtractionsService],
exports: [ExtractionsService],
})
export class ExtractionsModule {}
Loaded 100 of 464 files, more files were not shown because too many files have changed in this diff. Show more