diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index e83c476..5dd5c1b 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -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", diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index 140109f..d11c759 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -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", diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 2946459..8aa30ab 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -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: [ diff --git a/ApiCore-Backend/src/export/export.controller.ts b/ApiCore-Backend/src/export/export.controller.ts new file mode 100644 index 0000000..09b708f --- /dev/null +++ b/ApiCore-Backend/src/export/export.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/ApiCore-Backend/src/export/export.module.ts b/ApiCore-Backend/src/export/export.module.ts new file mode 100644 index 0000000..6c8e95f --- /dev/null +++ b/ApiCore-Backend/src/export/export.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/export/export.service.spec.ts b/ApiCore-Backend/src/export/export.service.spec.ts new file mode 100644 index 0000000..06267c1 --- /dev/null +++ b/ApiCore-Backend/src/export/export.service.spec.ts @@ -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'); + }); +}); diff --git a/ApiCore-Backend/src/export/export.service.ts b/ApiCore-Backend/src/export/export.service.ts new file mode 100644 index 0000000..80f21db --- /dev/null +++ b/ApiCore-Backend/src/export/export.service.ts @@ -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[], 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 +>; + +@Injectable() +export class ExportService { + constructor( + private readonly prisma: PrismaService, + private readonly honeyBatchesService: HoneyBatchesService, + ) {} + + async exportColoniesCsv(): Promise { + const colonies = await this.prisma.colony.findMany({ + orderBy: { code: 'asc' }, + }); + return toCsv(colonies, [ + 'id', + 'code', + 'apiaryId', + 'colonyType', + 'status', + 'strength', + 'healthStatus', + 'createdAt', + ]); + } + + async exportHoneyBatchesCsv(): Promise { + 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 { + 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 { + 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 { + 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(); + }); + } +} diff --git a/ApiCore-Backend/test/export.e2e-spec.ts b/ApiCore-Backend/test/export.e2e-spec.ts new file mode 100644 index 0000000..6986a4e --- /dev/null +++ b/ApiCore-Backend/test/export.e2e-spec.ts @@ -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; + 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); + }); +}); diff --git a/TASKS.md b/TASKS.md index 4e001f1..6c10fde 100644 --- a/TASKS.md +++ b/TASKS.md @@ -153,7 +153,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - 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` - 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 ### MVP-Abschluss