Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f7c4ed404 | ||
|
|
f1298eff08 | ||
|
|
1a7331b6b2 | ||
|
|
4609a0f9ec | ||
|
|
e458a7cf07 | ||
|
|
68f459001a | ||
|
|
63ab0e842d | ||
|
|
53b781aa04 | ||
|
|
f346859121 | ||
|
|
d56550c0e5 | ||
|
|
711536f299 | ||
|
|
eb66c4bd53 | ||
|
|
20239885b4 | ||
|
|
aa3a777381 | ||
|
|
4b9f5c7f47 | ||
|
|
c50c674e94 | ||
|
|
dfea017f75 | ||
|
|
4f2f31c05f | ||
|
|
e051ad956b | ||
|
|
5d32026092 | ||
|
|
3f47a6ae8d | ||
|
|
42a2be31b4 | ||
|
|
15f5ab57ae | ||
|
|
59a320ae36 | ||
|
|
fa13543578 | ||
|
|
a21563cc76 | ||
|
|
9a32c6c11d | ||
|
|
c0922387fd | ||
|
|
e889edd6c5 | ||
|
|
ebfbd2ef6d | ||
|
|
fc88c272ae | ||
|
|
5be352c4a0 | ||
|
|
6c764bf885 | ||
|
|
805effb60b | ||
|
|
60b59c120a | ||
|
|
3af56c96fa | ||
|
|
6a97ebc594 | ||
|
|
5cacc3c60d | ||
|
|
6ab028d1be | ||
|
|
57979f0371 | ||
|
|
6346bfab1d | ||
|
|
4a9fdd5c71 | ||
|
|
e4d5af5fa7 | ||
|
|
79010a926b | ||
|
|
a0f7573af5 | ||
|
|
2458927d38 | ||
|
|
59fdc816a0 | ||
|
|
8856d7d0f5 | ||
|
|
e8177432b0 | ||
|
|
8080a7f5ec | ||
|
|
ecb3afe1f5 | ||
|
|
fb2b09a929 | ||
|
|
50268fa591 | ||
|
|
0fc13952fb | ||
|
|
75f0d4fd51 | ||
|
|
a57cde1f9a | ||
|
|
9b078022ff | ||
|
|
fef1177a2c | ||
|
|
8b42a86115 | ||
|
|
3f20133d02 | ||
|
|
f4df719df7 | ||
|
|
e82cae257b | ||
|
|
8b7fd61c1c | ||
|
|
dd88b6a92c | ||
|
|
a7ded02015 | ||
|
|
e7348b0b68 | ||
|
|
26b2eb0829 | ||
|
|
ed108045af | ||
|
|
c281d04b16 | ||
|
|
ab167d4e53 | ||
|
|
ad787f8155 | ||
|
|
8a6769dbfd | ||
|
|
4945e92985 | ||
|
|
45646833b9 | ||
|
|
166979ff3c | ||
|
|
146b56d683 | ||
|
|
55b9bc2587 | ||
|
|
09407d4378 | ||
|
|
7ec20199d3 | ||
|
|
15b6ea4edb | ||
|
|
f3d99524ff | ||
|
|
cf625552d1 | ||
|
|
5c2c0608d1 | ||
|
|
340397b34d | ||
|
|
d71785d531 | ||
|
|
cc0aaf0a31 | ||
|
|
1f71ac9e3c | ||
|
|
61e0bbe794 | ||
|
|
66c14cf306 | ||
|
|
f363bffed1 | ||
|
|
632d382474 | ||
|
|
7bba168bd6 | ||
|
|
829753ec90 | ||
|
|
9b59e4bb35 | ||
|
|
49ce23147c | ||
|
|
b18d61640d | ||
|
|
6e328cdacb | ||
|
|
a71b31df38 | ||
|
|
d3f321f1b5 | ||
|
|
d59153a697 | ||
|
|
5c92c810e1 | ||
|
|
029484f9d5 | ||
|
|
370102387f | ||
|
|
ae8f6d4435 |
No files matched your search
@@ -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.
|
||||
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.
|
||||
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
|
||||
|
||||
`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.
|
||||
@@ -8,3 +8,14 @@ JWT_ACCESS_EXPIRES_IN="15m"
|
||||
JWT_REFRESH_EXPIRES_IN="7d"
|
||||
|
||||
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"
|
||||
@@ -2,6 +2,9 @@ node_modules
|
||||
dist
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
prisma/seed.js
|
||||
prisma/seed.js.map
|
||||
prisma/seed.d.ts
|
||||
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
@@ -11,3 +14,7 @@ coverage
|
||||
|
||||
# Local dev/test PostgreSQL data dir (not for production use)
|
||||
.devdb/
|
||||
|
||||
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
|
||||
/uploads/
|
||||
/test-uploads/
|
||||
Generated
+403
-17
@@ -16,14 +16,18 @@
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.3.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.19.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -34,10 +38,14 @@
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"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": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
@@ -3133,6 +3163,15 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
@@ -3268,6 +3307,16 @@
|
||||
"@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": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
@@ -3507,6 +3556,16 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"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": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
@@ -3548,6 +3607,16 @@
|
||||
"@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": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
@@ -3559,6 +3628,16 @@
|
||||
"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": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
@@ -4777,7 +4856,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4787,7 +4865,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -4996,7 +5073,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5102,6 +5178,24 @@
|
||||
"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": {
|
||||
"version": "4.28.8",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||
@@ -5318,7 +5412,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -5569,7 +5662,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -5582,7 +5674,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
@@ -5699,6 +5790,25 @@
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"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": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@@ -6072,6 +6191,12 @@
|
||||
"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": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
@@ -6082,6 +6207,12 @@
|
||||
"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": {
|
||||
"version": "17.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
|
||||
@@ -6199,7 +6330,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/empathic": {
|
||||
@@ -6735,7 +6865,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
@@ -6925,6 +7054,32 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -7123,7 +7278,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -7611,7 +7765,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -8547,6 +8700,12 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -9636,7 +9814,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -9649,6 +9826,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -9730,7 +9913,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -9815,6 +9997,20 @@
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
@@ -10041,6 +10237,23 @@
|
||||
"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": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
||||
@@ -10255,6 +10468,127 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -10399,7 +10733,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -10415,6 +10748,12 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
||||
@@ -10469,6 +10808,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
|
||||
@@ -10660,6 +11005,12 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -10915,7 +11266,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -10946,7 +11296,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -11346,6 +11695,12 @@
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -11747,6 +12102,32 @@
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
@@ -12135,6 +12516,12 @@
|
||||
"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": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -12156,7 +12543,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
@@ -27,14 +28,18 @@
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/adapter-pg": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.3.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.19.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -45,10 +50,14 @@
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
seed: "ts-node prisma/seed.ts",
|
||||
},
|
||||
datasource: {
|
||||
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;
|
||||
+28
@@ -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;
|
||||
+31
@@ -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';
|
||||
@@ -37,18 +37,27 @@ model User {
|
||||
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests Harvest[]
|
||||
extractions Extraction[]
|
||||
honeyMeasurements HoneyMeasurement[]
|
||||
stockMovements StockMovement[]
|
||||
assignedTasks Task[] @relation("TaskAssignee")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
colonyMoves ColonyApiaryHistory[]
|
||||
auditLogs AuditLog[]
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests Harvest[]
|
||||
extractions Extraction[]
|
||||
honeyMeasurements HoneyMeasurement[]
|
||||
stockMovements StockMovement[]
|
||||
assignedTasks Task[] @relation("TaskAssignee")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
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])
|
||||
}
|
||||
@@ -70,6 +79,10 @@ enum AuditAction {
|
||||
UPDATE
|
||||
DEACTIVATE
|
||||
DELETE
|
||||
LOGIN
|
||||
LOGIN_FAILED
|
||||
LOGOUT
|
||||
TOKEN_REFRESHED
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
@@ -167,9 +180,11 @@ model Colony {
|
||||
lineageAsResult ColonyLineage[] @relation("LineageTo")
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests HarvestColony[]
|
||||
queenEvaluations QueenEvaluation[]
|
||||
|
||||
@@index([apiaryId])
|
||||
@@index([status])
|
||||
@@ -242,16 +257,47 @@ model Queen {
|
||||
inseminationData String?
|
||||
motherLine String?
|
||||
fatherLine String?
|
||||
motherQueenId String?
|
||||
status QueenStatus @default(ACTIVE)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deactivatedAt DateTime?
|
||||
|
||||
colony Colony? @relation("ColonyCurrentQueen")
|
||||
colonyHistory QueenColonyHistory[]
|
||||
motherQueen Queen? @relation("QueenMotherline", fields: [motherQueenId], references: [id])
|
||||
daughterQueens Queen[] @relation("QueenMotherline")
|
||||
colony Colony? @relation("ColonyCurrentQueen")
|
||||
colonyHistory QueenColonyHistory[]
|
||||
evaluations QueenEvaluation[]
|
||||
|
||||
@@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)
|
||||
@@ -348,6 +394,8 @@ model Task {
|
||||
relatedId String?
|
||||
sourceInspectionId String?
|
||||
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
|
||||
sourceTemplateId String?
|
||||
sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
@@ -355,6 +403,37 @@ model Task {
|
||||
@@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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -384,6 +463,43 @@ model VarroaMeasurement {
|
||||
@@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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -528,7 +644,9 @@ model HoneyBatch {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
measurements HoneyMeasurement[]
|
||||
measurements HoneyMeasurement[]
|
||||
bottlingBatches BottlingBatch[]
|
||||
relocations HoneyBatchRelocation[]
|
||||
|
||||
@@index([status])
|
||||
@@index([extractionId])
|
||||
@@ -574,11 +692,31 @@ model StorageContainer {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
honeyBatches HoneyBatch[]
|
||||
honeyBatches HoneyBatch[]
|
||||
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
|
||||
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
|
||||
|
||||
@@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 {
|
||||
HONEY
|
||||
CONSUMABLE
|
||||
@@ -624,3 +762,169 @@ model StockMovement {
|
||||
|
||||
@@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
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,88 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { PrismaModule } from './prisma/prisma.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({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
|
||||
PrismaModule,
|
||||
AuditModule,
|
||||
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 {}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user