feat(backend): Datenexport-Modul (Modul 21)

ExportModule mit GET /export/colonies.csv, /export/honey-batches.csv,
/export/:entity.json (apiaries/colonies/queens/honey-batches/tasks)
und /export/:report.pdf (honey-batch-traceability via pdfkit).
Ausserdem fehlendes "typecheck"-Skript in package.json ergaenzt
(von CLAUDE.md dokumentiert, war aber nicht vorhanden).
This commit is contained in:
Nick Szaunig committed 2026-08-22 10:05:46 +02:00
1 parent ab167d4e53
commit c281d04b16
9 files changed
+677 -4

No files matched your search

+174 -3
View File
@@ -25,6 +25,7 @@
"helmet": "^8.3.0", "helmet": "^8.3.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -40,6 +41,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@@ -2659,11 +2661,22 @@
} }
} }
}, },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": "^14.21.3 || >=16"
@@ -3135,6 +3148,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@@ -3560,6 +3582,16 @@
"@types/passport": "*" "@types/passport": "*"
} }
}, },
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@@ -5008,7 +5040,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5114,6 +5145,24 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -6103,6 +6152,12 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -6766,7 +6821,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": { "node_modules/fast-diff": {
@@ -6956,6 +7010,32 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkit/node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -8578,6 +8658,12 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -8765,6 +8851,25 @@
"integrity": "sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==", "integrity": "sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -9680,6 +9785,12 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9846,6 +9957,20 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
@@ -10072,6 +10197,14 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/postgres": { "node_modules/postgres": {
"version": "3.4.7", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
@@ -10500,6 +10633,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/ret": { "node_modules/ret": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
@@ -11377,6 +11516,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -11778,6 +11923,32 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+3
View File
@@ -13,6 +13,7 @@
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"typecheck": "tsc --noEmit",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
@@ -36,6 +37,7 @@
"helmet": "^8.3.0", "helmet": "^8.3.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfkit": "^0.19.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -51,6 +53,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
+2
View File
@@ -20,6 +20,7 @@ import { HoneyBatchesModule } from './honey-batches/honey-batches.module';
import { StorageModule } from './storage/storage.module'; import { StorageModule } from './storage/storage.module';
import { DashboardModule } from './dashboard/dashboard.module'; import { DashboardModule } from './dashboard/dashboard.module';
import { SearchModule } from './search/search.module'; import { SearchModule } from './search/search.module';
import { ExportModule } from './export/export.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard'; import { RolesGuard } from './auth/guards/roles.guard';
@@ -45,6 +46,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
StorageModule, StorageModule,
DashboardModule, DashboardModule,
SearchModule, SearchModule,
ExportModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [
@@ -0,0 +1,59 @@
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,
@Res() res: Response,
): Promise<void> {
const pdf = await this.exportService.exportReportPdf(report, honeyBatchId);
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,121 @@
import { BadRequestException } 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 };
honeyBatch: { findMany: jest.Mock };
apiary: { findMany: jest.Mock };
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
};
let honeyBatchesService: { traceability: jest.Mock };
beforeEach(() => {
prisma = {
colony: { findMany: jest.fn() },
honeyBatch: { findMany: jest.fn() },
apiary: { findMany: jest.fn() },
queen: { findMany: jest.fn() },
task: { findMany: jest.fn() },
};
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', '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', 'h1');
expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
});
@@ -0,0 +1,172 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
const JSON_ENTITIES = [
'apiaries',
'colonies',
'queens',
'honey-batches',
'tasks',
] as const;
type JsonEntity = (typeof JSON_ENTITIES)[number];
const PDF_REPORTS = ['honey-batch-traceability'] as const;
type PdfReport = (typeof PDF_REPORTS)[number];
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,
honeyBatchId?: string,
): Promise<Buffer> {
if (!isPdfReport(report)) {
throw new BadRequestException(`Unsupported export report: ${report}`);
}
if (!honeyBatchId) {
throw new BadRequestException(
'Query parameter honeyBatchId is required for this report',
);
}
const trace = await this.honeyBatchesService.traceability(honeyBatchId);
return this.renderTraceabilityPdf(trace);
}
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();
});
}
}
+134
View File
@@ -0,0 +1,134 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
describe('Export (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `export-e2e-beekeeper-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let beekeeperToken: string;
let apiaryId: string;
let colonyId: string;
let harvestId: string;
let extractionId: string;
let honeyBatchId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
},
});
beekeeperId = beekeeper.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
const apiary = await prisma.apiary.create({
data: { name: `Stand ${Date.now()}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: {
code: `V-${Date.now()}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
const harvest = await prisma.harvest.create({
data: { apiaryId, date: new Date(), userId: beekeeperId },
});
harvestId = harvest.id;
await prisma.harvestColony.create({ data: { harvestId, colonyId } });
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId: beekeeperId },
});
extractionId = extraction.id;
await prisma.extractionHarvest.create({
data: { extractionId, harvestId },
});
const honeyBatch = await prisma.honeyBatch.create({
data: { batchNumber: `HC-${Date.now()}`, extractionId, amount: 20 },
});
honeyBatchId = honeyBatch.id;
});
afterAll(async () => {
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
await prisma.extractionHarvest.deleteMany({ where: { extractionId } });
await prisma.extraction.deleteMany({ where: { id: extractionId } });
await prisma.harvestColony.deleteMany({ where: { harvestId } });
await prisma.harvest.deleteMany({ where: { id: harvestId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
await prisma.user.deleteMany({ where: { id: beekeeperId } });
await app.close();
});
it('exports colonies as CSV', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/export/colonies.csv')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect(response.headers['content-type']).toContain('text/csv');
expect(response.text.split('\n')[0]).toContain('code');
expect(response.text).toContain(colonyId);
});
it('exports honey batches as CSV', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/export/honey-batches.csv')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect(response.text).toContain(honeyBatchId);
});
it('exports colonies as JSON', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/export/colonies.json')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const body = response.body as { id: string }[];
expect(body.some((colony) => colony.id === colonyId)).toBe(true);
});
it('rejects an unsupported JSON entity with 400', () => {
return request(app.getHttpServer())
.get('/api/v1/export/bogus.json')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(400);
});
it('exports the honey batch traceability report as PDF', async () => {
const response = await request(app.getHttpServer())
.get(
`/api/v1/export/honey-batch-traceability.pdf?honeyBatchId=${honeyBatchId}`,
)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect(response.headers['content-type']).toContain('application/pdf');
const pdfBody = response.body as Buffer;
expect(pdfBody.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
it('rejects the traceability report without honeyBatchId with 400', () => {
return request(app.getHttpServer())
.get('/api/v1/export/honey-batch-traceability.pdf')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(400);
});
});
+1 -1
View File
@@ -153,7 +153,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
- Backend: `ExportModule` - Backend: `ExportModule`
- Endpunkte: `GET /api/v1/export/colonies.csv`, `GET /api/v1/export/honey-batches.csv`, `GET /api/v1/export/:report.pdf`, `GET /api/v1/export/:entity.json` - Endpunkte: `GET /api/v1/export/colonies.csv`, `GET /api/v1/export/honey-batches.csv`, `GET /api/v1/export/:report.pdf`, `GET /api/v1/export/:entity.json`
- Frontend: Export-Buttons in den jeweiligen Listen-Views - Frontend: Export-Buttons in den jeweiligen Listen-Views
- [ ] Backend-Modul fertig, getestet, grün, committet - [x] Backend-Modul fertig, getestet, grün, committet
- [ ] Frontend-Views fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet
### MVP-Abschluss ### MVP-Abschluss