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:
1 parent
711536f299
commit
d56550c0e5
13 files changed
+538
-48
No files matched your search
@@ -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,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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user