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

+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;
}
}
}
}