feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
Backend Image / build-and-push (push) Successful in 2m28s
Frontend Image / build-and-push (push) Successful in 2m10s

- Profilseite mit MFA-Einrichtung, Passwort/E-Mail/Name aendern, Konto deaktivieren
- Bearbeiten-Buttons fuer Bienenstaende/Voelker/Koeniginnen/Aufgaben-Datensaetze
- Restliche englische Enum-Werte durch deutsche Labels ersetzt (SelectValue-Fix)
- Sidebar-Eintraege fuer Ernten/Schleuderungen ergaenzt
- Automatisierungen von 3 auf 6 Regeln erweitert (Schwarmstimmung, kritischer Gesundheitsstatus, Lagerbestand)
- Responsive Grid-Layout fuer Volk-/Koeniginnen-/Honigchargen-/Bienenstand-Detailseiten
- Etikettenvorlagen mit Platzhaltern inkl. QR-Code (verweist auf Herkunftsseite) und PDF-Druck
- Entra-ID-SSO (Authorization Code + PKCE, kein Auto-Provisioning, Secrets nur ueber Environment-Variablen)
This commit is contained in:
Nick Szaunig committed 2026-08-31 18:56:08 +02:00
1 parent 0a52b29c57
commit 8432313fa9
83 files changed
+2684 -289

No files matched your search

@@ -0,0 +1,38 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateLabelTemplateDto {
@ApiProperty()
@IsString()
name!: string;
@ApiProperty({
description:
'Etikettentext mit Platzhaltern: {{batchNumber}} {{honeyType}} {{lotNumber}} {{bottlingDate}} {{bestBeforeDate}} {{jarSize}} {{productName}}',
})
@IsString()
content!: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(10)
widthMm?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(10)
heightMm?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateLabelTemplateDto } from './create-label-template.dto';
export class UpdateLabelTemplateDto extends PartialType(
CreateLabelTemplateDto,
) {}
@@ -0,0 +1,81 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
import { LabelTemplatesService } from './label-templates.service';
@ApiTags('label-templates')
@ApiBearerAuth()
@Controller('label-templates')
export class LabelTemplatesController {
constructor(private readonly labelTemplatesService: LabelTemplatesService) {}
@RequirePermission('label-templates:read')
@Get()
findAll() {
return this.labelTemplatesService.findAll();
}
@RequirePermission('label-templates:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.labelTemplatesService.findById(id);
}
@RequirePermission('label-templates:create')
@Post()
create(
@Body() dto: CreateLabelTemplateDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.labelTemplatesService.create(dto, actor.id);
}
@RequirePermission('label-templates:update')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateLabelTemplateDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.labelTemplatesService.update(id, dto, actor.id);
}
@RequirePermission('label-templates:deactivate')
@Delete(':id')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.labelTemplatesService.deactivate(id, actor.id);
}
@RequirePermission('label-templates:print')
@Get(':id/print/:bottlingBatchId')
async print(
@Param('id') id: string,
@Param('bottlingBatchId') bottlingBatchId: string,
@Res() res: Response,
): Promise<void> {
const pdf = await this.labelTemplatesService.renderLabelsPdf(
id,
bottlingBatchId,
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
'attachment; filename="etiketten.pdf"',
);
res.send(pdf);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LabelTemplatesController } from './label-templates.controller';
import { LabelTemplatesService } from './label-templates.service';
@Module({
controllers: [LabelTemplatesController],
providers: [LabelTemplatesService],
exports: [LabelTemplatesService],
})
export class LabelTemplatesModule {}
@@ -0,0 +1,175 @@
import { NotFoundException } from '@nestjs/common';
import { LabelTemplatesService } from './label-templates.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('LabelTemplatesService', () => {
let service: LabelTemplatesService;
let tx: {
labelTemplate: {
updateMany: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
labelTemplate: {
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
bottlingBatch: { findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
const template = {
id: 'template-1',
name: 'Standard',
content: 'Charge {{batchNumber}} - MHD {{bestBeforeDate}}',
widthMm: 90,
heightMm: 35,
isDefault: false,
active: true,
};
beforeEach(() => {
tx = {
labelTemplate: {
updateMany: jest.fn().mockResolvedValue({}),
create: jest.fn(),
update: jest.fn(),
},
};
prisma = {
labelTemplate: {
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
bottlingBatch: { findUnique: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new LabelTemplatesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('findById throws NotFoundException when missing', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
describe('create', () => {
it('clears other defaults first when isDefault is set', async () => {
tx.labelTemplate.create.mockResolvedValue({
...template,
isDefault: true,
});
await service.create(
{ name: 'Standard', content: 'x', isDefault: true },
'user-1',
);
expect(tx.labelTemplate.updateMany).toHaveBeenCalledWith({
where: {},
data: { isDefault: false },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'CREATE' }),
);
});
it('does not touch other defaults when isDefault is not set', async () => {
tx.labelTemplate.create.mockResolvedValue(template);
await service.create({ name: 'Standard', content: 'x' }, 'user-1');
expect(tx.labelTemplate.updateMany).not.toHaveBeenCalled();
});
});
describe('deactivate', () => {
it('sets active and isDefault to false, records DEACTIVATE', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(template);
prisma.labelTemplate.update.mockResolvedValue({
...template,
active: false,
isDefault: false,
});
const result = await service.deactivate('template-1', 'user-1');
expect(result.active).toBe(false);
expect(prisma.labelTemplate.update).toHaveBeenCalledWith({
where: { id: 'template-1' },
data: { active: false, isDefault: false },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEACTIVATE' }),
);
});
});
describe('renderLabelsPdf', () => {
it('fills placeholders and tiles one label per jar', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(template);
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1',
jarSize: 500,
jarCount: 2,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
expect(pdf).toBeInstanceOf(Buffer);
expect(pdf.length).toBeGreaterThan(0);
// %PDF header confirms a real PDF document was produced.
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
});
it('throws NotFoundException when the bottling batch does not exist', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(template);
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
await expect(
service.renderLabelsPdf('template-1', 'missing'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('embeds a QR code pointing at the Herkunft page when the template uses {{qrCode}}', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue({
...template,
content: `${template.content} {{qrCode}}`,
});
prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1',
jarSize: 500,
jarCount: 1,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
// A PDF with an embedded raster image contains an XObject /Image entry.
expect(pdf.toString('latin1')).toContain('/Image');
});
});
});
@@ -0,0 +1,236 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import * as QRCode from 'qrcode';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { LabelTemplate } from '../../generated/prisma';
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
const MM_TO_PT = 72 / 25.4;
const PAGE_MARGIN_MM = 5;
const LABEL_GAP_MM = 2;
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const QR_CODE_PLACEHOLDER = '{{qrCode}}';
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE');
}
function fillPlaceholders(
content: string,
values: Record<string, string>,
): string {
return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
key in values ? values[key] : match,
);
}
@Injectable()
export class LabelTemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(): Promise<LabelTemplate[]> {
return this.prisma.labelTemplate.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<LabelTemplate> {
const template = await this.prisma.labelTemplate.findUnique({
where: { id },
});
if (!template) {
throw new NotFoundException('Label template not found');
}
return template;
}
async create(
dto: CreateLabelTemplateDto,
actingUserId: string,
): Promise<LabelTemplate> {
const template = await this.prisma.$transaction(async (tx) => {
if (dto.isDefault) {
await tx.labelTemplate.updateMany({
where: {},
data: { isDefault: false },
});
}
return tx.labelTemplate.create({ data: { ...dto } });
});
await this.auditLog.record({
entityType: 'LabelTemplate',
entityId: template.id,
action: 'CREATE',
userId: actingUserId,
afterData: template,
});
return template;
}
async update(
id: string,
dto: UpdateLabelTemplateDto,
actingUserId: string,
): Promise<LabelTemplate> {
const before = await this.findById(id);
const template = await this.prisma.$transaction(async (tx) => {
if (dto.isDefault) {
await tx.labelTemplate.updateMany({
where: { id: { not: id } },
data: { isDefault: false },
});
}
return tx.labelTemplate.update({ where: { id }, data: { ...dto } });
});
await this.auditLog.record({
entityType: 'LabelTemplate',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: template,
});
return template;
}
async deactivate(id: string, actingUserId: string): Promise<LabelTemplate> {
const before = await this.findById(id);
const template = await this.prisma.labelTemplate.update({
where: { id },
data: { active: false, isDefault: false },
});
await this.auditLog.record({
entityType: 'LabelTemplate',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: template,
});
return template;
}
async renderLabelsPdf(
templateId: string,
bottlingBatchId: string,
): Promise<Buffer> {
const template = await this.findById(templateId);
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
where: { id: bottlingBatchId },
include: { honeyBatch: true, product: true },
});
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
}
const values: Record<string, string> = {
batchNumber: bottlingBatch.honeyBatch.batchNumber,
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
lotNumber: bottlingBatch.lotNumber,
bottlingDate: formatDate(bottlingBatch.date),
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
jarSize: `${bottlingBatch.jarSize} ml`,
productName: bottlingBatch.product?.name ?? '',
};
const labelText = fillPlaceholders(template.content, values);
// The QR code is generated once (not per copy) - it always points at the
// public Herkunft/traceability page for this batch, which already shows
// honey type, dates, and origin, so the label itself only needs to
// reference it rather than duplicate that information.
let qrCodeBuffer: Buffer | null = null;
if (labelText.includes(QR_CODE_PLACEHOLDER)) {
const frontendOrigin =
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const herkunftUrl = `${frontendOrigin}/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`;
qrCodeBuffer = await QRCode.toBuffer(herkunftUrl, {
margin: 1,
width: 300,
});
}
return this.renderPdf(
labelText,
template.widthMm,
template.heightMm,
bottlingBatch.jarCount,
qrCodeBuffer,
);
}
private renderPdf(
labelText: string,
widthMm: number,
heightMm: number,
copies: number,
qrCodeBuffer: Buffer | null,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 0 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', (error: Error) => reject(error));
const usableWidthMm = A4_WIDTH_MM - 2 * PAGE_MARGIN_MM;
const usableHeightMm = A4_HEIGHT_MM - 2 * PAGE_MARGIN_MM;
const columns = Math.max(
1,
Math.floor((usableWidthMm + LABEL_GAP_MM) / (widthMm + LABEL_GAP_MM)),
);
const rows = Math.max(
1,
Math.floor((usableHeightMm + LABEL_GAP_MM) / (heightMm + LABEL_GAP_MM)),
);
const perPage = columns * rows;
for (let index = 0; index < copies; index += 1) {
const onPage = index % perPage;
if (index > 0 && onPage === 0) {
doc.addPage({ size: 'A4', margin: 0 });
}
const column = onPage % columns;
const row = Math.floor(onPage / columns);
const xMm = PAGE_MARGIN_MM + column * (widthMm + LABEL_GAP_MM);
const yMm = PAGE_MARGIN_MM + row * (heightMm + LABEL_GAP_MM);
const x = xMm * MM_TO_PT;
const y = yMm * MM_TO_PT;
const w = widthMm * MM_TO_PT;
const h = heightMm * MM_TO_PT;
doc.rect(x, y, w, h).dash(2, { space: 2 }).stroke('#999999').undash();
if (qrCodeBuffer) {
const qrSide = Math.min(w, h) - 8;
const qrX = x + w - qrSide - 4;
const qrY = y + (h - qrSide) / 2;
doc.image(qrCodeBuffer, qrX, qrY, { width: qrSide, height: qrSide });
const textWidth = qrX - 4 - (x + 4);
doc
.fontSize(9)
.fillColor('#000000')
.text(
labelText.replace(QR_CODE_PLACEHOLDER, '').trim(),
x + 4,
y + 4,
{
width: Math.max(textWidth, 0),
height: h - 8,
},
);
} else {
doc
.fontSize(9)
.fillColor('#000000')
.text(labelText, x + 4, y + 4, { width: w - 8, height: h - 8 });
}
}
doc.end();
});
}
}