From d56550c0e52f6ea074233e508234377619aaeb1e Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 25 Aug 2026 01:35:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=C3=B6ffentliche=20Herkunftsseiten=20f?= =?UTF-8?q?=C3=BCr=20Honigchargen=20+=20Kunden-QR-Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer unauthentifizierter Endpunkt GET /api/v1/public/batches/:lot (Abschnitt 27 der Lastenheft) liefert kontrolliert freigegebene Rückverfolgbarkeitsdaten einer Abfüllcharge – Produkt, Erntemonat, Bienenstand-Namen, Schleuderdatum, Wassergehalt – ohne interne IDs, Adressen oder Koordinaten preiszugeben. Die neue Seite /herkunft/{lot} liegt außerhalb der (app)-Route-Group und damit ohne Login/Sidebar, gedacht als Ziel eines QR-Codes auf dem Honigglas. Der bestehende QR-Dienst kennt jetzt den Entity-Typ "bottling-batches" und verlinkt intern auf diese neue öffentliche Seite; ein entsprechender QR-Code-Button samt Klartext-Link steht ab sofort auf der Abfüllungs-Detailseite bereit. --- ApiCore-Backend/src/app.module.ts | 2 + .../src/public/public.controller.ts | 16 ++ ApiCore-Backend/src/public/public.module.ts | 9 ++ .../src/public/public.service.spec.ts | 69 ++++++++ ApiCore-Backend/src/public/public.service.ts | 77 +++++++++ ApiCore-Backend/src/qr/qr.service.spec.ts | 10 ++ ApiCore-Backend/src/qr/qr.service.ts | 78 +++++----- ApiCore-Backend/test/public.e2e-spec.ts | 147 ++++++++++++++++++ .../src/app/(app)/bottling/[id]/page.tsx | 35 ++++- .../src/app/herkunft/[lot]/page.tsx | 116 ++++++++++++++ ApiCore-Frontend/src/lib/public-batch/api.ts | 6 + .../src/lib/public-batch/types.ts | 18 +++ ApiCore-Frontend/src/lib/qr/api.ts | 3 +- 13 files changed, 538 insertions(+), 48 deletions(-) create mode 100644 ApiCore-Backend/src/public/public.controller.ts create mode 100644 ApiCore-Backend/src/public/public.module.ts create mode 100644 ApiCore-Backend/src/public/public.service.spec.ts create mode 100644 ApiCore-Backend/src/public/public.service.ts create mode 100644 ApiCore-Backend/test/public.e2e-spec.ts create mode 100644 ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx create mode 100644 ApiCore-Frontend/src/lib/public-batch/api.ts create mode 100644 ApiCore-Frontend/src/lib/public-batch/types.ts diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index a3aff13..566ebf5 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -32,6 +32,7 @@ import { WebhooksModule } from './webhooks/webhooks.module'; import { DocumentsModule } from './documents/documents.module'; import { AutomationModule } from './automation/automation.module'; import { ImportModule } from './import/import.module'; +import { PublicModule } from './public/public.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -69,6 +70,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; DocumentsModule, AutomationModule, ImportModule, + PublicModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/public/public.controller.ts b/ApiCore-Backend/src/public/public.controller.ts new file mode 100644 index 0000000..81680b5 --- /dev/null +++ b/ApiCore-Backend/src/public/public.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Public } from '../auth/decorators/public.decorator'; +import { PublicBatch, PublicService } from './public.service'; + +@ApiTags('public') +@Public() +@Controller('public/batches') +export class PublicController { + constructor(private readonly publicService: PublicService) {} + + @Get(':lotNumber') + findByLot(@Param('lotNumber') lotNumber: string): Promise { + return this.publicService.findBatchByLot(lotNumber); + } +} diff --git a/ApiCore-Backend/src/public/public.module.ts b/ApiCore-Backend/src/public/public.module.ts new file mode 100644 index 0000000..e0fbe03 --- /dev/null +++ b/ApiCore-Backend/src/public/public.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PublicController } from './public.controller'; +import { PublicService } from './public.service'; + +@Module({ + controllers: [PublicController], + providers: [PublicService], +}) +export class PublicModule {} diff --git a/ApiCore-Backend/src/public/public.service.spec.ts b/ApiCore-Backend/src/public/public.service.spec.ts new file mode 100644 index 0000000..36a015b --- /dev/null +++ b/ApiCore-Backend/src/public/public.service.spec.ts @@ -0,0 +1,69 @@ +import { NotFoundException } from '@nestjs/common'; +import { PublicService } from './public.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('PublicService', () => { + let service: PublicService; + let prisma: { bottlingBatch: { findUnique: jest.Mock } }; + + beforeEach(() => { + prisma = { bottlingBatch: { findUnique: jest.fn() } }; + service = new PublicService(prisma as unknown as PrismaService); + }); + + it('throws NotFoundException when the lot does not exist', async () => { + prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null); + await expect(service.findBatchByLot('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('shapes the traceability chain into public-safe fields, deduplicating apiaries', async () => { + const harvestDate1 = new Date('2026-08-05'); + const harvestDate2 = new Date('2026-08-12'); + prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ + lotNumber: 'WH-2026-001', + date: new Date('2026-08-20'), + bestBeforeDate: new Date('2028-08-20'), + jarSize: 500, + product: { name: 'Sommerhonig 500g', description: 'Sortenrein' }, + honeyBatch: { + honeyType: 'Sommerhonig', + waterContent: 16.8, + extraction: { + date: new Date('2026-08-13'), + harvests: [ + { + harvest: { + date: harvestDate1, + apiary: { + id: 'apiary-1', + name: 'Stand Wald', + location: 'Wald', + }, + }, + }, + { + harvest: { + date: harvestDate2, + apiary: { + id: 'apiary-1', + name: 'Stand Wald', + location: 'Wald', + }, + }, + }, + ], + }, + }, + }); + + const result = await service.findBatchByLot('WH-2026-001'); + + expect(result.lotNumber).toBe('WH-2026-001'); + expect(result.productName).toBe('Sommerhonig 500g'); + expect(result.waterContent).toBe(16.8); + expect(result.apiaries).toEqual([{ name: 'Stand Wald', location: 'Wald' }]); + expect(result.harvestDates).toEqual([harvestDate1, harvestDate2]); + }); +}); diff --git a/ApiCore-Backend/src/public/public.service.ts b/ApiCore-Backend/src/public/public.service.ts new file mode 100644 index 0000000..a66bca8 --- /dev/null +++ b/ApiCore-Backend/src/public/public.service.ts @@ -0,0 +1,77 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface PublicApiaryOrigin { + name: string; + location: string | null; +} + +export interface PublicBatch { + lotNumber: string; + productName: string | null; + productDescription: string | null; + bottledDate: Date; + bestBeforeDate: Date; + jarSize: number; + honeyType: string | null; + waterContent: number | null; + extractedDate: Date | null; + harvestDates: Date[]; + apiaries: PublicApiaryOrigin[]; +} + +@Injectable() +export class PublicService { + constructor(private readonly prisma: PrismaService) {} + + async findBatchByLot(lotNumber: string): Promise { + const batch = await this.prisma.bottlingBatch.findUnique({ + where: { lotNumber }, + include: { + product: true, + honeyBatch: { + include: { + extraction: { + include: { + harvests: { + include: { harvest: { include: { apiary: true } } }, + }, + }, + }, + }, + }, + }, + }); + if (!batch) { + throw new NotFoundException('Charge nicht gefunden'); + } + + const harvests = batch.honeyBatch.extraction.harvests.map( + (entry) => entry.harvest, + ); + const apiaryByName = new Map(); + for (const harvest of harvests) { + apiaryByName.set(harvest.apiary.id, { + name: harvest.apiary.name, + location: harvest.apiary.location, + }); + } + const harvestDates = [...new Set(harvests.map((h) => h.date.getTime()))] + .sort((a, b) => a - b) + .map((time) => new Date(time)); + + return { + lotNumber: batch.lotNumber, + productName: batch.product?.name ?? null, + productDescription: batch.product?.description ?? null, + bottledDate: batch.date, + bestBeforeDate: batch.bestBeforeDate, + jarSize: batch.jarSize, + honeyType: batch.honeyBatch.honeyType, + waterContent: batch.honeyBatch.waterContent, + extractedDate: batch.honeyBatch.extraction.date, + harvestDates, + apiaries: [...apiaryByName.values()], + }; + } +} diff --git a/ApiCore-Backend/src/qr/qr.service.spec.ts b/ApiCore-Backend/src/qr/qr.service.spec.ts index eff6bbd..c5f2ebc 100644 --- a/ApiCore-Backend/src/qr/qr.service.spec.ts +++ b/ApiCore-Backend/src/qr/qr.service.spec.ts @@ -10,6 +10,7 @@ describe('QrService', () => { queen: { findUnique: jest.Mock }; honeyBatch: { findUnique: jest.Mock }; storageContainer: { findUnique: jest.Mock }; + bottlingBatch: { findUnique: jest.Mock }; }; beforeEach(() => { @@ -19,6 +20,7 @@ describe('QrService', () => { queen: { findUnique: jest.fn() }, honeyBatch: { findUnique: jest.fn() }, storageContainer: { findUnique: jest.fn() }, + bottlingBatch: { findUnique: jest.fn() }, }; service = new QrService(prisma as unknown as PrismaService); }); @@ -47,4 +49,12 @@ describe('QrService', () => { const svg = await service.generateSvg('storage-containers', 'sc-1'); expect(svg).toContain(' { + prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ + lotNumber: 'WH-2026-001', + }); + const svg = await service.generateSvg('bottling-batches', 'bb-1'); + expect(svg).toContain(' { + ): 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`; + case 'colonies': { + const colony = await this.prisma.colony.findUnique({ + where: { id }, + }); + return colony ? `/inspections/new?colonyId=${id}` : null; + } + case 'apiaries': { + const apiary = await this.prisma.apiary.findUnique({ where: { id } }); + return apiary ? `/apiaries/${id}` : null; + } + case 'queens': { + const queen = await this.prisma.queen.findUnique({ where: { id } }); + return queen ? `/queens/${id}` : null; + } + case 'honey-batches': { + const honeyBatch = await this.prisma.honeyBatch.findUnique({ + where: { id }, + }); + return honeyBatch ? `/honey-batches/${id}` : null; + } + case 'storage-containers': { + const container = await this.prisma.storageContainer.findUnique({ + where: { id }, + }); + return container ? `/storage` : null; + } + case 'bottling-batches': { + const bottlingBatch = await this.prisma.bottlingBatch.findUnique({ + where: { id }, + select: { lotNumber: true }, + }); + return bottlingBatch ? `/herkunft/${bottlingBatch.lotNumber}` : null; + } } } } diff --git a/ApiCore-Backend/test/public.e2e-spec.ts b/ApiCore-Backend/test/public.e2e-spec.ts new file mode 100644 index 0000000..d2aa4b4 --- /dev/null +++ b/ApiCore-Backend/test/public.e2e-spec.ts @@ -0,0 +1,147 @@ +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'; + +interface PublicBatchBody { + lotNumber: string; + productName: string | null; + honeyType: string | null; + waterContent: number | null; + apiaries: { name: string; location: string | null }[]; + harvestDates: string[]; +} + +describe('Public batch origin page (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `public-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; + let productId: string; + let bottlingBatchId: string; + const lotNumber = `WH-E2E-${Date.now()}`; + const apiaryName = `Stand Wald ${Date.now()}`; + + 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: apiaryName, location: 'Wald' }, + }); + 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('2026-08-12'), userId: beekeeperId }, + }); + harvestId = harvest.id; + await prisma.harvestColony.create({ data: { harvestId, colonyId } }); + const extraction = await prisma.extraction.create({ + data: { date: new Date('2026-08-13'), 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: 25, + honeyType: 'Sommerhonig', + waterContent: 16.8, + }, + }); + honeyBatchId = honeyBatch.id; + const product = await prisma.product.create({ + data: { name: 'Sommerhonig 500g' }, + }); + productId = product.id; + const bottlingBatch = await prisma.bottlingBatch.create({ + data: { + honeyBatchId, + productId, + date: new Date('2026-08-20'), + jarSize: 500, + jarCount: 10, + totalAmount: 5, + bestBeforeDate: new Date('2028-08-20'), + lotNumber, + userId: beekeeperId, + }, + }); + bottlingBatchId = bottlingBatch.id; + }); + + afterAll(async () => { + await prisma.bottlingBatch.deleteMany({ where: { id: bottlingBatchId } }); + await prisma.product.deleteMany({ where: { id: productId } }); + 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('returns public-safe origin data for a lot without any authentication', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/public/batches/${lotNumber}`) + .expect(200); + const body = response.body as PublicBatchBody; + expect(body.lotNumber).toBe(lotNumber); + expect(body.productName).toBe('Sommerhonig 500g'); + expect(body.honeyType).toBe('Sommerhonig'); + expect(body.waterContent).toBe(16.8); + expect(body.apiaries).toEqual([{ name: apiaryName, location: 'Wald' }]); + expect(body.harvestDates).toHaveLength(1); + }); + + it('returns 404 for an unknown lot', () => { + return request(app.getHttpServer()) + .get('/api/v1/public/batches/does-not-exist') + .expect(404); + }); + + it('generates a QR code for the bottling batch that deep-links to the public page', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/qr/bottling-batches/${bottlingBatchId}.svg`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect(response.headers['content-type']).toContain('image/svg+xml'); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx index bce1c3f..4803dfe 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx @@ -1,10 +1,12 @@ "use client"; +import Link from "next/link"; import { useParams } from "next/navigation"; import { Package } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { fetchBottlingBatch } from "@/lib/bottling/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { QrCodeDialog } from "@/components/qr-code-dialog"; export default function BottlingBatchDetailPage() { const params = useParams<{ id: string }>(); @@ -27,14 +29,21 @@ export default function BottlingBatchDetailPage() { return (
- - - - - Abfüllung {bottlingBatch.lotNumber} + +
+ + + + Abfüllung {bottlingBatch.lotNumber} +
+

Honigcharge: {bottlingBatch.honeyBatch.batchNumber}

@@ -48,6 +57,16 @@ export default function BottlingBatchDetailPage() { Mindesthaltbar bis:{" "} {new Date(bottlingBatch.bestBeforeDate).toLocaleDateString("de-DE")}

+

+ Öffentliche Herkunftsseite:{" "} + + /herkunft/{bottlingBatch.lotNumber} + +

diff --git a/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx b/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx new file mode 100644 index 0000000..3466b67 --- /dev/null +++ b/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Hexagon, Droplet, MapPin, CalendarDays } from "lucide-react"; +import { fetchPublicBatch } from "@/lib/public-batch/api"; +import { HoneycombPattern } from "@/components/honeycomb-pattern"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +function formatMonthYear(iso: string): string { + return new Date(iso).toLocaleDateString("de-DE", { month: "long", year: "numeric" }); +} + +export default function PublicBatchPage() { + const params = useParams<{ lot: string }>(); + const lot = decodeURIComponent(params.lot); + + const { data: batch, isLoading, isError } = useQuery({ + queryKey: ["public-batch", lot], + queryFn: () => fetchPublicBatch(lot), + retry: false, + }); + + const harvestMonths = batch + ? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ") + : null; + const apiaryNames = batch ? batch.apiaries.map((a) => a.name).join(", ") : null; + + return ( +
+ + + +
+ + + +
+ Herkunftsnachweis +

Wabenhain · ApiCore

+
+
+
+ + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Keine Charge mit dieser Nummer gefunden. +

+ ) : null} + {batch ? ( + <> +
+

+ {batch.productName ?? "Honig"} +

+

Charge {batch.lotNumber}

+
+ {batch.productDescription ? ( +

{batch.productDescription}

+ ) : null} +
+ {apiaryNames ? ( +
+ +
+
Herkunft
+
{apiaryNames}
+
+
+ ) : null} + {harvestMonths ? ( +
+ +
+
Geerntet
+
{harvestMonths}
+
+
+ ) : null} + {batch.extractedDate ? ( +
+ +
+
Geschleudert
+
{formatDate(batch.extractedDate)}
+
+
+ ) : null} + {batch.waterContent !== null ? ( +
+ +
+
Wassergehalt
+
{batch.waterContent} %
+
+
+ ) : null} +
+

+ Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde +

+ + ) : null} +
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/lib/public-batch/api.ts b/ApiCore-Frontend/src/lib/public-batch/api.ts new file mode 100644 index 0000000..05740e1 --- /dev/null +++ b/ApiCore-Frontend/src/lib/public-batch/api.ts @@ -0,0 +1,6 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { PublicBatch } from "./types"; + +export function fetchPublicBatch(lotNumber: string): Promise { + return apiFetch(`/public/batches/${encodeURIComponent(lotNumber)}`); +} diff --git a/ApiCore-Frontend/src/lib/public-batch/types.ts b/ApiCore-Frontend/src/lib/public-batch/types.ts new file mode 100644 index 0000000..7bdd72a --- /dev/null +++ b/ApiCore-Frontend/src/lib/public-batch/types.ts @@ -0,0 +1,18 @@ +export interface PublicApiaryOrigin { + name: string; + location: string | null; +} + +export interface PublicBatch { + lotNumber: string; + productName: string | null; + productDescription: string | null; + bottledDate: string; + bestBeforeDate: string; + jarSize: number; + honeyType: string | null; + waterContent: number | null; + extractedDate: string | null; + harvestDates: string[]; + apiaries: PublicApiaryOrigin[]; +} diff --git a/ApiCore-Frontend/src/lib/qr/api.ts b/ApiCore-Frontend/src/lib/qr/api.ts index 5741f9f..16162d4 100644 --- a/ApiCore-Frontend/src/lib/qr/api.ts +++ b/ApiCore-Frontend/src/lib/qr/api.ts @@ -5,7 +5,8 @@ export type QrEntityType = | "apiaries" | "queens" | "honey-batches" - | "storage-containers"; + | "storage-containers" + | "bottling-batches"; export async function fetchQrCodeSvg(entityType: QrEntityType, id: string): Promise { const { blob } = await apiFetchBlob(`/qr/${entityType}/${id}.svg`, `${entityType}.svg`);