diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 328fd76..5bca5eb 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -858,7 +858,10 @@ model Product { model LabelTemplate { id String @id @default(cuid()) name String - content String + // Array of LabelElement objects (text/placeholder/qrCode/image/line), see + // label-templates.service.ts. Drives both the in-browser canvas designer + // and the pdfkit print renderer from the same source of truth. + elements Json @default("[]") widthMm Float @default(90) heightMm Float @default(35) isDefault Boolean @default(false) diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts index 7272e11..955dd3d 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog-sync.service.ts @@ -9,6 +9,14 @@ import { PERMISSION_CATALOG } from './permission-catalog'; * catalog entry missing from the database. Never deletes a Permission row * that fell out of the catalog (only warns) — a role's grants must not * silently disappear because of a typo or a reordered catalog edit. + * + * Every newly-synced permission is also granted to every `isSystem` role + * (ADMIN's seed row documents itself as "Voller Zugriff auf alle Bereiche" - + * full access to every area). Without this, a role that legitimately means + * "everything" would silently lose that guarantee each time a new feature + * ships a new permission, forcing a manual grant in Rollen & Rechte before + * the feature is usable at all - exactly the gap that made the freshly + * shipped Etiketten feature invisible to the seeded admin account. */ @Injectable() export class PermissionCatalogSyncService implements OnModuleInit { @@ -25,15 +33,34 @@ export class PermissionCatalogSyncService implements OnModuleInit { const missing = PERMISSION_CATALOG.filter( (entry) => !existingKeys.has(entry.key), ); - for (const entry of missing) { - await this.prisma.permission.create({ data: entry }); - } + const createdPermissions = await Promise.all( + missing.map((entry) => this.prisma.permission.create({ data: entry })), + ); if (missing.length > 0) { this.logger.log( `Synced ${missing.length} new permission(s) from the code catalog: ${missing .map((entry) => entry.key) .join(', ')}`, ); + + const systemRoles = await this.prisma.role.findMany({ + where: { isSystem: true }, + select: { id: true }, + }); + if (systemRoles.length > 0) { + await this.prisma.rolePermission.createMany({ + data: systemRoles.flatMap((role) => + createdPermissions.map((permission) => ({ + roleId: role.id, + permissionId: permission.id, + })), + ), + skipDuplicates: true, + }); + this.logger.log( + `Granted ${missing.length} new permission(s) to ${systemRoles.length} system role(s).`, + ); + } } const catalogKeys = new Set(PERMISSION_CATALOG.map((entry) => entry.key)); diff --git a/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts index 81133d6..f1e957c 100644 --- a/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts +++ b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts @@ -1,23 +1,28 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { + ArrayMaxSize, + IsArray, IsBoolean, IsNumber, IsOptional, IsString, Min, + ValidateNested, } from 'class-validator'; +import { LabelElementDto } from './label-element.dto'; export class CreateLabelTemplateDto { @ApiProperty() @IsString() name!: string; - @ApiProperty({ - description: - 'Etikettentext mit Platzhaltern: {{batchNumber}} {{honeyType}} {{lotNumber}} {{bottlingDate}} {{bestBeforeDate}} {{jarSize}} {{productName}}', - }) - @IsString() - content!: string; + @ApiProperty({ type: [LabelElementDto] }) + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => LabelElementDto) + elements!: LabelElementDto[]; @ApiPropertyOptional() @IsOptional() diff --git a/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts b/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts index 9ec5a3d..5b7cc2b 100644 --- a/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts +++ b/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { LabelTemplatesService } from './label-templates.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { LabelElement } from './label-element.types'; describe('LabelTemplatesService', () => { let service: LabelTemplatesService; @@ -23,10 +24,44 @@ describe('LabelTemplatesService', () => { }; let auditLog: { record: jest.Mock }; + const elements: LabelElement[] = [ + { + id: 'el-1', + type: 'text', + x: 2, + y: 2, + width: 60, + height: 10, + text: '', + placeholderKey: 'batchNumber', + fontSize: 9, + fontFamily: 'sans', + bold: false, + italic: false, + color: '#000000', + align: 'left', + }, + { + id: 'el-2', + type: 'text', + x: 2, + y: 14, + width: 60, + height: 10, + text: 'MHD:', + fontSize: 9, + fontFamily: 'sans', + bold: false, + italic: false, + color: '#000000', + align: 'left', + }, + ]; + const template = { id: 'template-1', name: 'Standard', - content: 'Charge {{batchNumber}} - MHD {{bestBeforeDate}}', + elements, widthMm: 90, heightMm: 35, isDefault: false, @@ -74,7 +109,7 @@ describe('LabelTemplatesService', () => { }); await service.create( - { name: 'Standard', content: 'x', isDefault: true }, + { name: 'Standard', elements: [], isDefault: true }, 'user-1', ); @@ -90,7 +125,7 @@ describe('LabelTemplatesService', () => { 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'); + await service.create({ name: 'Standard', elements: [] }, 'user-1'); expect(tx.labelTemplate.updateMany).not.toHaveBeenCalled(); }); @@ -119,7 +154,7 @@ describe('LabelTemplatesService', () => { }); describe('renderLabelsPdf', () => { - it('fills placeholders and tiles one label per jar', async () => { + it('resolves placeholder text and tiles one label per jar', async () => { prisma.labelTemplate.findUnique.mockResolvedValue(template); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', @@ -149,10 +184,17 @@ describe('LabelTemplatesService', () => { ).rejects.toBeInstanceOf(NotFoundException); }); - it('embeds a QR code pointing at the Herkunft page when the template uses {{qrCode}}', async () => { + it('embeds a QR code pointing at the Herkunft page when the template has a qrCode element', async () => { + const qrElement: LabelElement = { + id: 'el-qr', + type: 'qrCode', + x: 60, + y: 2, + size: 20, + }; prisma.labelTemplate.findUnique.mockResolvedValue({ ...template, - content: `${template.content} {{qrCode}}`, + elements: [...elements, qrElement], }); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', diff --git a/ApiCore-Backend/src/label-templates/label-templates.service.ts b/ApiCore-Backend/src/label-templates/label-templates.service.ts index 2d7c1cb..87f6405 100644 --- a/ApiCore-Backend/src/label-templates/label-templates.service.ts +++ b/ApiCore-Backend/src/label-templates/label-templates.service.ts @@ -6,25 +6,54 @@ 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'; +import { + LabelElement, + LabelFontFamily, + PlaceholderKey, +} from './label-element.types'; 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}}'; +const QR_SOURCE_SIZE_PX = 400; function formatDate(date: Date): string { return date.toLocaleDateString('de-DE'); } -function fillPlaceholders( - content: string, - values: Record, +// Prisma stores `elements` as a plain Json column; the shape is only ever +// written through CreateLabelTemplateDto/UpdateLabelTemplateDto, so casting +// back to the app-level type here is safe. +function elementsOf(template: LabelTemplate): LabelElement[] { + return template.elements as unknown as LabelElement[]; +} + +function pdfFontName( + family: LabelFontFamily, + bold: boolean, + italic: boolean, ): string { - return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) => - key in values ? values[key] : match, - ); + const base = + family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica'; + if (base === 'Times') { + if (bold && italic) return 'Times-BoldItalic'; + if (bold) return 'Times-Bold'; + if (italic) return 'Times-Italic'; + return 'Times-Roman'; + } + const suffix = + bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : ''; + return `${base}${suffix}`; +} + +function dataUrlToBuffer(dataUrl: string): Buffer | null { + const match = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(dataUrl); + if (!match) { + return null; + } + return Buffer.from(match[2], 'base64'); } @Injectable() @@ -59,7 +88,9 @@ export class LabelTemplatesService { data: { isDefault: false }, }); } - return tx.labelTemplate.create({ data: { ...dto } }); + return tx.labelTemplate.create({ + data: { ...dto, elements: dto.elements as never }, + }); }); await this.auditLog.record({ entityType: 'LabelTemplate', @@ -84,7 +115,10 @@ export class LabelTemplatesService { data: { isDefault: false }, }); } - return tx.labelTemplate.update({ where: { id }, data: { ...dto } }); + return tx.labelTemplate.update({ + where: { id }, + data: { ...dto, elements: dto.elements as never }, + }); }); await this.auditLog.record({ entityType: 'LabelTemplate', @@ -127,7 +161,7 @@ export class LabelTemplatesService { throw new NotFoundException('Bottling batch not found'); } - const values: Record = { + const values: Record = { batchNumber: bottlingBatch.honeyBatch.batchNumber, honeyType: bottlingBatch.honeyBatch.honeyType ?? '', lotNumber: bottlingBatch.lotNumber, @@ -136,25 +170,29 @@ export class LabelTemplatesService { 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. + const elements = elementsOf(template).map((element) => + element.type === 'text' && element.placeholderKey + ? { ...element, text: values[element.placeholderKey] } + : element, + ); + + // The QR code always points at the public Herkunft/traceability page for + // this batch (honey type, dates, origin already shown there), generated + // once and reused across every copy and every qrCode element. let qrCodeBuffer: Buffer | null = null; - if (labelText.includes(QR_CODE_PLACEHOLDER)) { + if (elements.some((element) => element.type === 'qrCode')) { 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, + width: QR_SOURCE_SIZE_PX, }); } return this.renderPdf( - labelText, + elements, template.widthMm, template.heightMm, bottlingBatch.jarCount, @@ -163,7 +201,7 @@ export class LabelTemplatesService { } private renderPdf( - labelText: string, + elements: LabelElement[], widthMm: number, heightMm: number, copies: number, @@ -197,40 +235,85 @@ export class LabelTemplatesService { 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 originX = xMm * MM_TO_PT; + const originY = 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(); + doc + .rect(originX, originY, 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 }); + for (const element of elements) { + this.drawElement(doc, element, originX, originY, qrCodeBuffer); } } doc.end(); }); } + + private drawElement( + doc: PDFKit.PDFDocument, + element: LabelElement, + originX: number, + originY: number, + qrCodeBuffer: Buffer | null, + ): void { + const x = originX + element.x * MM_TO_PT; + const y = originY + element.y * MM_TO_PT; + + switch (element.type) { + case 'text': { + doc + .font(pdfFontName(element.fontFamily, element.bold, element.italic)) + .fontSize(element.fontSize) + .fillColor(element.color) + .text(element.text, x, y, { + width: element.width * MM_TO_PT, + height: element.height * MM_TO_PT, + align: element.align, + }); + return; + } + case 'qrCode': { + if (!qrCodeBuffer) { + return; + } + const side = element.size * MM_TO_PT; + doc.image(qrCodeBuffer, x, y, { width: side, height: side }); + return; + } + case 'image': { + const buffer = dataUrlToBuffer(element.src); + if (!buffer) { + return; + } + doc.image(buffer, x, y, { + width: element.width * MM_TO_PT, + height: element.height * MM_TO_PT, + }); + return; + } + case 'line': { + const endX = + element.orientation === 'horizontal' + ? x + element.length * MM_TO_PT + : x; + const endY = + element.orientation === 'vertical' + ? y + element.length * MM_TO_PT + : y; + doc + .moveTo(x, y) + .lineTo(endX, endY) + .lineWidth(element.thickness) + .strokeColor(element.color) + .stroke(); + return; + } + } + } } diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/create-label-template-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/create-label-template-dialog.tsx deleted file mode 100644 index 3cc08c2..0000000 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/create-label-template-dialog.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import { FormEvent, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { createLabelTemplate } from "@/lib/label-templates/api"; -import { LABEL_PLACEHOLDERS } from "@/lib/label-templates/types"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -export function CreateLabelTemplateDialog() { - const queryClient = useQueryClient(); - const [open, setOpen] = useState(false); - const [name, setName] = useState(""); - const [content, setContent] = useState(""); - const [widthMm, setWidthMm] = useState("90"); - const [heightMm, setHeightMm] = useState("35"); - const [error, setError] = useState(null); - - function reset() { - setName(""); - setContent(""); - setWidthMm("90"); - setHeightMm("35"); - setError(null); - } - - const mutation = useMutation({ - mutationFn: createLabelTemplate, - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["label-templates"] }); - setOpen(false); - reset(); - }, - onError: () => setError("Vorlage konnte nicht angelegt werden."), - }); - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - setError(null); - mutation.mutate({ - name, - content, - widthMm: widthMm ? Number(widthMm) : undefined, - heightMm: heightMm ? Number(heightMm) : undefined, - }); - } - - return ( - { - setOpen(next); - if (!next) reset(); - }} - > - - - - Neue Etikettenvorlage - -
-
- - setName(event.target.value)} - /> -
-
-
- - setWidthMm(event.target.value)} - /> -
-
- - setHeightMm(event.target.value)} - /> -
-
-
- -