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 { DocumentsModule } from './documents/documents.module';
|
||||||
import { AutomationModule } from './automation/automation.module';
|
import { AutomationModule } from './automation/automation.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
|
import { PublicModule } from './public/public.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './auth/guards/roles.guard';
|
import { RolesGuard } from './auth/guards/roles.guard';
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
|
|||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
|
PublicModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
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 };
|
queen: { findUnique: jest.Mock };
|
||||||
honeyBatch: { findUnique: jest.Mock };
|
honeyBatch: { findUnique: jest.Mock };
|
||||||
storageContainer: { findUnique: jest.Mock };
|
storageContainer: { findUnique: jest.Mock };
|
||||||
|
bottlingBatch: { findUnique: jest.Mock };
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -19,6 +20,7 @@ describe('QrService', () => {
|
|||||||
queen: { findUnique: jest.fn() },
|
queen: { findUnique: jest.fn() },
|
||||||
honeyBatch: { findUnique: jest.fn() },
|
honeyBatch: { findUnique: jest.fn() },
|
||||||
storageContainer: { findUnique: jest.fn() },
|
storageContainer: { findUnique: jest.fn() },
|
||||||
|
bottlingBatch: { findUnique: jest.fn() },
|
||||||
};
|
};
|
||||||
service = new QrService(prisma as unknown as PrismaService);
|
service = new QrService(prisma as unknown as PrismaService);
|
||||||
});
|
});
|
||||||
@@ -47,4 +49,12 @@ describe('QrService', () => {
|
|||||||
const svg = await service.generateSvg('storage-containers', 'sc-1');
|
const svg = await service.generateSvg('storage-containers', 'sc-1');
|
||||||
expect(svg).toContain('<svg');
|
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',
|
'queens',
|
||||||
'honey-batches',
|
'honey-batches',
|
||||||
'storage-containers',
|
'storage-containers',
|
||||||
|
'bottling-batches',
|
||||||
] as const;
|
] as const;
|
||||||
type EntityType = (typeof ENTITY_TYPES)[number];
|
type EntityType = (typeof ENTITY_TYPES)[number];
|
||||||
|
|
||||||
@@ -30,56 +31,55 @@ export class QrService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exists = await this.entityExists(entityType, id);
|
const deepLink = await this.resolveDeepLink(entityType, id);
|
||||||
if (!exists) {
|
if (!deepLink) {
|
||||||
throw new NotFoundException('Object not found');
|
throw new NotFoundException('Object not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const frontendOrigin =
|
const frontendOrigin =
|
||||||
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
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 });
|
return QRCode.toString(url, { type: 'svg', margin: 1, width: 256 });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async entityExists(
|
private async resolveDeepLink(
|
||||||
entityType: EntityType,
|
entityType: EntityType,
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<boolean> {
|
): Promise<string | null> {
|
||||||
switch (entityType) {
|
switch (entityType) {
|
||||||
case 'colonies':
|
case 'colonies': {
|
||||||
return (
|
const colony = await this.prisma.colony.findUnique({
|
||||||
(await this.prisma.colony.findUnique({ where: { id } })) !== null
|
where: { id },
|
||||||
);
|
});
|
||||||
case 'apiaries':
|
return colony ? `/inspections/new?colonyId=${id}` : null;
|
||||||
return (
|
}
|
||||||
(await this.prisma.apiary.findUnique({ where: { id } })) !== null
|
case 'apiaries': {
|
||||||
);
|
const apiary = await this.prisma.apiary.findUnique({ where: { id } });
|
||||||
case 'queens':
|
return apiary ? `/apiaries/${id}` : null;
|
||||||
return (await this.prisma.queen.findUnique({ where: { id } })) !== null;
|
}
|
||||||
case 'honey-batches':
|
case 'queens': {
|
||||||
return (
|
const queen = await this.prisma.queen.findUnique({ where: { id } });
|
||||||
(await this.prisma.honeyBatch.findUnique({ where: { id } })) !== null
|
return queen ? `/queens/${id}` : null;
|
||||||
);
|
}
|
||||||
case 'storage-containers':
|
case 'honey-batches': {
|
||||||
return (
|
const honeyBatch = await this.prisma.honeyBatch.findUnique({
|
||||||
(await this.prisma.storageContainer.findUnique({ where: { id } })) !==
|
where: { id },
|
||||||
null
|
});
|
||||||
);
|
return honeyBatch ? `/honey-batches/${id}` : null;
|
||||||
}
|
}
|
||||||
}
|
case 'storage-containers': {
|
||||||
|
const container = await this.prisma.storageContainer.findUnique({
|
||||||
private deepLink(entityType: EntityType, id: string): string {
|
where: { id },
|
||||||
switch (entityType) {
|
});
|
||||||
case 'colonies':
|
return container ? `/storage` : null;
|
||||||
return `/inspections/new?colonyId=${id}`;
|
}
|
||||||
case 'apiaries':
|
case 'bottling-batches': {
|
||||||
return `/apiaries/${id}`;
|
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
||||||
case 'queens':
|
where: { id },
|
||||||
return `/queens/${id}`;
|
select: { lotNumber: true },
|
||||||
case 'honey-batches':
|
});
|
||||||
return `/honey-batches/${id}`;
|
return bottlingBatch ? `/herkunft/${bottlingBatch.lotNumber}` : null;
|
||||||
case 'storage-containers':
|
}
|
||||||
return `/storage`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Package } from "lucide-react";
|
import { Package } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchBottlingBatch } from "@/lib/bottling/api";
|
import { fetchBottlingBatch } from "@/lib/bottling/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
|
|
||||||
export default function BottlingBatchDetailPage() {
|
export default function BottlingBatchDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -27,14 +29,21 @@ export default function BottlingBatchDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardHeader className="flex flex-row items-center gap-2.5">
|
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
|
||||||
<span
|
<div className="flex items-center gap-2.5">
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
|
<span
|
||||||
style={{ background: "var(--gradient-honey)" }}
|
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
|
||||||
>
|
style={{ background: "var(--gradient-honey)" }}
|
||||||
<Package className="size-4" />
|
>
|
||||||
</span>
|
<Package className="size-4" />
|
||||||
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
|
</span>
|
||||||
|
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<QrCodeDialog
|
||||||
|
entityType="bottling-batches"
|
||||||
|
id={bottlingBatch.id}
|
||||||
|
label="QR-Code für Etikett"
|
||||||
|
/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-2 text-sm">
|
<CardContent className="flex flex-col gap-2 text-sm">
|
||||||
<p>Honigcharge: {bottlingBatch.honeyBatch.batchNumber}</p>
|
<p>Honigcharge: {bottlingBatch.honeyBatch.batchNumber}</p>
|
||||||
@@ -48,6 +57,16 @@ export default function BottlingBatchDetailPage() {
|
|||||||
Mindesthaltbar bis:{" "}
|
Mindesthaltbar bis:{" "}
|
||||||
{new Date(bottlingBatch.bestBeforeDate).toLocaleDateString("de-DE")}
|
{new Date(bottlingBatch.bestBeforeDate).toLocaleDateString("de-DE")}
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Öffentliche Herkunftsseite:{" "}
|
||||||
|
<Link
|
||||||
|
className="underline"
|
||||||
|
href={`/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
/herkunft/{bottlingBatch.lotNumber}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
||||||
|
<HoneycombPattern />
|
||||||
|
<Card className="relative w-full max-w-md shadow-[var(--shadow-elevated)]">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-1 flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className="flex size-9 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
|
||||||
|
style={{ background: "var(--gradient-honey)" }}
|
||||||
|
>
|
||||||
|
<Hexagon className="size-5 fill-white/25 text-white" strokeWidth={2.25} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">Herkunftsnachweis</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">Wabenhain · ApiCore</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Keine Charge mit dieser Nummer gefunden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{batch ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-heading text-2xl font-semibold">
|
||||||
|
{batch.productName ?? "Honig"}
|
||||||
|
</h1>
|
||||||
|
<p className="font-mono text-sm text-muted-foreground">Charge {batch.lotNumber}</p>
|
||||||
|
</div>
|
||||||
|
{batch.productDescription ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{batch.productDescription}</p>
|
||||||
|
) : null}
|
||||||
|
<dl className="flex flex-col gap-3 text-sm">
|
||||||
|
{apiaryNames ? (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<MapPin className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Herkunft</dt>
|
||||||
|
<dd className="font-medium">{apiaryNames}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{harvestMonths ? (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<CalendarDays className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Geerntet</dt>
|
||||||
|
<dd className="font-medium">{harvestMonths}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{batch.extractedDate ? (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<CalendarDays className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Geschleudert</dt>
|
||||||
|
<dd className="font-medium">{formatDate(batch.extractedDate)}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{batch.waterContent !== null ? (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Droplet className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Wassergehalt</dt>
|
||||||
|
<dd className="font-medium">{batch.waterContent} %</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { PublicBatch } from "./types";
|
||||||
|
|
||||||
|
export function fetchPublicBatch(lotNumber: string): Promise<PublicBatch> {
|
||||||
|
return apiFetch<PublicBatch>(`/public/batches/${encodeURIComponent(lotNumber)}`);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ export type QrEntityType =
|
|||||||
| "apiaries"
|
| "apiaries"
|
||||||
| "queens"
|
| "queens"
|
||||||
| "honey-batches"
|
| "honey-batches"
|
||||||
| "storage-containers";
|
| "storage-containers"
|
||||||
|
| "bottling-batches";
|
||||||
|
|
||||||
export async function fetchQrCodeSvg(entityType: QrEntityType, id: string): Promise<Blob> {
|
export async function fetchQrCodeSvg(entityType: QrEntityType, id: string): Promise<Blob> {
|
||||||
const { blob } = await apiFetchBlob(`/qr/${entityType}/${id}.svg`, `${entityType}.svg`);
|
const { blob } = await apiFetchBlob(`/qr/${entityType}/${id}.svg`, `${entityType}.svg`);
|
||||||
|
|||||||
Reference in new issue
Block a user