feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
- 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:
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user