feat: öffentliche Herkunftsseiten für Honigchargen + Kunden-QR-Code

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.
This commit is contained in:
Nick Szaunig committed 2026-08-25 01:35:47 +02:00
1 parent 711536f299
commit d56550c0e5
13 files changed
+538 -48

No files matched your search

+2
View File
@@ -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: [
@@ -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<PublicBatch> {
return this.publicService.findBatchByLot(lotNumber);
}
}
@@ -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 {}
@@ -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]);
});
});
@@ -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<PublicBatch> {
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<string, PublicApiaryOrigin>();
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()],
};
}
}
+10
View File
@@ -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('<svg');
});
it('generates an SVG QR code that deep-links to the public origin page for a bottling batch', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
lotNumber: 'WH-2026-001',
});
const svg = await service.generateSvg('bottling-batches', 'bb-1');
expect(svg).toContain('<svg');
});
});
+39 -39
View File
@@ -12,6 +12,7 @@ const ENTITY_TYPES = [
'queens',
'honey-batches',
'storage-containers',
'bottling-batches',
] as const;
type EntityType = (typeof ENTITY_TYPES)[number];
@@ -30,56 +31,55 @@ export class QrService {
);
}
const exists = await this.entityExists(entityType, id);
if (!exists) {
const deepLink = await this.resolveDeepLink(entityType, id);
if (!deepLink) {
throw new NotFoundException('Object not found');
}
const frontendOrigin =
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const url = `${frontendOrigin}${this.deepLink(entityType, id)}`;
const url = `${frontendOrigin}${deepLink}`;
return QRCode.toString(url, { type: 'svg', margin: 1, width: 256 });
}
private async entityExists(
private async resolveDeepLink(
entityType: EntityType,
id: string,
): Promise<boolean> {
): Promise<string | null> {
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;
}
}
}
}
+147
View File
@@ -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<App>;
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');
});
});