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:
1 parent
ab167d4e53
commit
c281d04b16
9 files changed
+677
-4
No files matched your search
Generated
+174
-3
@@ -25,6 +25,7 @@
|
||||
"helmet": "^8.3.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.19.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -40,6 +41,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"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": {
|
||||
"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"
|
||||
@@ -3135,6 +3148,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",
|
||||
@@ -3560,6 +3582,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",
|
||||
@@ -5008,7 +5040,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",
|
||||
@@ -5114,6 +5145,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",
|
||||
@@ -6103,6 +6152,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",
|
||||
@@ -6766,7 +6821,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": {
|
||||
@@ -6956,6 +7010,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",
|
||||
@@ -8578,6 +8658,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",
|
||||
@@ -8765,6 +8851,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",
|
||||
@@ -9680,6 +9785,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",
|
||||
@@ -9846,6 +9957,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",
|
||||
@@ -10072,6 +10197,14 @@
|
||||
"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": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
||||
@@ -10500,6 +10633,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",
|
||||
@@ -11377,6 +11516,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",
|
||||
@@ -11778,6 +11923,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -36,6 +37,7 @@
|
||||
"helmet": "^8.3.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.19.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -51,6 +53,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/guards/roles.guard';
|
||||
|
||||
@@ -45,6 +46,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
|
||||
StorageModule,
|
||||
DashboardModule,
|
||||
SearchModule,
|
||||
ExportModule,
|
||||
],
|
||||
controllers: [],
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user