From dd88b6a92c7825859d4185ba0d99b50c7f628c3d Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 10:51:42 +0200 Subject: [PATCH] feat(backend): QR-Code-System (Modul 22) QrModule mit GET /qr/:entityType/:id.svg fuer colonies (Deep-Link direkt zu /inspections/new), apiaries, queens, honey-batches und storage-containers. Nutzt pdfkit-Nachbar qrcode zur SVG-Erzeugung. --- ApiCore-Backend/package-lock.json | 189 ++++++++++++++++++++-- ApiCore-Backend/package.json | 2 + ApiCore-Backend/src/app.module.ts | 2 + ApiCore-Backend/src/qr/qr.controller.ts | 22 +++ ApiCore-Backend/src/qr/qr.module.ts | 9 ++ ApiCore-Backend/src/qr/qr.service.spec.ts | 50 ++++++ ApiCore-Backend/src/qr/qr.service.ts | 85 ++++++++++ ApiCore-Backend/test/qr.e2e-spec.ts | 81 ++++++++++ TASKS.md | 2 +- 9 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 ApiCore-Backend/src/qr/qr.controller.ts create mode 100644 ApiCore-Backend/src/qr/qr.module.ts create mode 100644 ApiCore-Backend/src/qr/qr.service.spec.ts create mode 100644 ApiCore-Backend/src/qr/qr.service.ts create mode 100644 ApiCore-Backend/test/qr.e2e-spec.ts diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index 5dd5c1b..ddf6d5f 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -26,6 +26,7 @@ "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" }, @@ -42,6 +43,7 @@ "@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", @@ -3603,6 +3605,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", @@ -4821,7 +4833,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" @@ -4831,7 +4842,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" @@ -5379,7 +5389,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" @@ -5630,7 +5639,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" @@ -5643,7 +5651,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": { @@ -6023,6 +6030,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", @@ -6168,6 +6184,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", @@ -6285,7 +6307,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": { @@ -7234,7 +7255,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.*" @@ -7722,7 +7742,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" @@ -9772,7 +9791,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" @@ -9872,7 +9890,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" @@ -10205,6 +10222,15 @@ "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", @@ -10419,6 +10445,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", @@ -10563,7 +10710,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" @@ -10579,6 +10725,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", @@ -10830,6 +10982,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", @@ -11085,7 +11243,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", @@ -11116,7 +11273,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" @@ -12337,6 +12493,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", @@ -12358,7 +12520,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", diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index d11c759..3842f9a 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -38,6 +38,7 @@ "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" }, @@ -54,6 +55,7 @@ "@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", diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 8aa30ab..16ac55b 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -21,6 +21,7 @@ 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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -47,6 +48,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; DashboardModule, SearchModule, ExportModule, + QrModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/qr/qr.controller.ts b/ApiCore-Backend/src/qr/qr.controller.ts new file mode 100644 index 0000000..89ab2f6 --- /dev/null +++ b/ApiCore-Backend/src/qr/qr.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Param, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { QrService } from './qr.service'; + +@ApiTags('qr') +@ApiBearerAuth() +@Controller('qr') +export class QrController { + constructor(private readonly qrService: QrService) {} + + @Get(':entityType/:id.svg') + async svg( + @Param('entityType') entityType: string, + @Param('id') id: string, + @Res() res: Response, + ): Promise { + const svg = await this.qrService.generateSvg(entityType, id); + res.setHeader('Content-Type', 'image/svg+xml'); + res.send(svg); + } +} diff --git a/ApiCore-Backend/src/qr/qr.module.ts b/ApiCore-Backend/src/qr/qr.module.ts new file mode 100644 index 0000000..3155803 --- /dev/null +++ b/ApiCore-Backend/src/qr/qr.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { QrController } from './qr.controller'; +import { QrService } from './qr.service'; + +@Module({ + controllers: [QrController], + providers: [QrService], +}) +export class QrModule {} diff --git a/ApiCore-Backend/src/qr/qr.service.spec.ts b/ApiCore-Backend/src/qr/qr.service.spec.ts new file mode 100644 index 0000000..eff6bbd --- /dev/null +++ b/ApiCore-Backend/src/qr/qr.service.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { QrService } from './qr.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('QrService', () => { + let service: QrService; + let prisma: { + colony: { findUnique: jest.Mock }; + apiary: { findUnique: jest.Mock }; + queen: { findUnique: jest.Mock }; + honeyBatch: { findUnique: jest.Mock }; + storageContainer: { findUnique: jest.Mock }; + }; + + beforeEach(() => { + prisma = { + colony: { findUnique: jest.fn() }, + apiary: { findUnique: jest.fn() }, + queen: { findUnique: jest.fn() }, + honeyBatch: { findUnique: jest.fn() }, + storageContainer: { findUnique: jest.fn() }, + }; + service = new QrService(prisma as unknown as PrismaService); + }); + + it('rejects an unsupported entity type', async () => { + await expect(service.generateSvg('unknown', 'id-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('throws NotFoundException when the object does not exist', async () => { + prisma.colony.findUnique.mockResolvedValueOnce(null); + await expect( + service.generateSvg('colonies', 'missing'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('generates an SVG QR code for an existing colony', async () => { + prisma.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' }); + const svg = await service.generateSvg('colonies', 'colony-1'); + expect(svg).toContain(' { + prisma.storageContainer.findUnique.mockResolvedValueOnce({ id: 'sc-1' }); + const svg = await service.generateSvg('storage-containers', 'sc-1'); + expect(svg).toContain(' { + if (!isEntityType(entityType)) { + throw new BadRequestException( + `Unsupported QR entity type: ${entityType}`, + ); + } + + const exists = await this.entityExists(entityType, id); + if (!exists) { + throw new NotFoundException('Object not found'); + } + + const frontendOrigin = + process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; + const url = `${frontendOrigin}${this.deepLink(entityType, id)}`; + return QRCode.toString(url, { type: 'svg', margin: 1, width: 256 }); + } + + private async entityExists( + entityType: EntityType, + id: string, + ): Promise { + switch (entityType) { + case 'colonies': + return ( + (await this.prisma.colony.findUnique({ where: { id } })) !== null + ); + case 'apiaries': + return ( + (await this.prisma.apiary.findUnique({ where: { id } })) !== null + ); + case 'queens': + return (await this.prisma.queen.findUnique({ where: { id } })) !== null; + case 'honey-batches': + return ( + (await this.prisma.honeyBatch.findUnique({ where: { id } })) !== null + ); + case 'storage-containers': + return ( + (await this.prisma.storageContainer.findUnique({ where: { id } })) !== + null + ); + } + } + + private deepLink(entityType: EntityType, id: string): string { + switch (entityType) { + case 'colonies': + return `/inspections/new?colonyId=${id}`; + case 'apiaries': + return `/apiaries/${id}`; + case 'queens': + return `/queens/${id}`; + case 'honey-batches': + return `/honey-batches/${id}`; + case 'storage-containers': + return `/storage`; + } + } +} diff --git a/ApiCore-Backend/test/qr.e2e-spec.ts b/ApiCore-Backend/test/qr.e2e-spec.ts new file mode 100644 index 0000000..613ba98 --- /dev/null +++ b/ApiCore-Backend/test/qr.e2e-spec.ts @@ -0,0 +1,81 @@ +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('QR codes (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `qr-e2e-beekeeper-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let beekeeperToken: string; + let apiaryId: string; + let colonyId: 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; + }); + + afterAll(async () => { + 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('returns an SVG QR code for an existing colony', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/qr/colonies/${colonyId}.svg`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect(response.headers['content-type']).toContain('image/svg+xml'); + const svgBody = response.body as Buffer; + expect(svgBody.toString('utf-8')).toContain(' { + return request(app.getHttpServer()) + .get('/api/v1/qr/colonies/does-not-exist.svg') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(404); + }); + + it('returns 400 for an unsupported entity type', () => { + return request(app.getHttpServer()) + .get(`/api/v1/qr/unknown/${colonyId}.svg`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(400); + }); +}); diff --git a/TASKS.md b/TASKS.md index cca83d1..282e0be 100644 --- a/TASKS.md +++ b/TASKS.md @@ -179,7 +179,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Backend: `QrModule` – erzeugt QR-Code (PNG/SVG) pro Objekt, kodiert Deep-Link (`/colonies/:id`, bei Volk direkt `/inspections/new?colonyId=:id` gemäß Lastenheft-Begründung "reduziert Auswahlaufwand") - Endpunkte: `GET /api/v1/qr/:entityType/:id.svg` für `entityType` ∈ `colonies|apiaries|queens|honey-batches|storage-containers` - Frontend: "QR-Code"-Button/Dialog auf den jeweiligen Detailseiten, druckfreundliche Ansicht -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 23. Abfüllungen und Produkte