fix: fehlende Dateien fuer Etiketten-Elemente-Umbau nachreichen
git add -u im letzten Commit hat neue (untracked) Dateien übersprungen: die Migration für LabelTemplate.elements, die neuen Typ-/DTO-Dateien und den kompletten Drag-and-Drop-Editor fehlten dadurch im Build.
This commit is contained in:
1 parent
5f4e1a7ba9
commit
938239e16e
9 files changed
+1134
No files matched your search
@@ -0,0 +1,5 @@
|
||||
-- LabelTemplate switches from a plain-text "content" field to a structured
|
||||
-- "elements" JSON array driving the canvas designer + PDF renderer. The
|
||||
-- feature was never deployed live, so no existing rows need preserving.
|
||||
ALTER TABLE "LabelTemplate" DROP COLUMN "content";
|
||||
ALTER TABLE "LabelTemplate" ADD COLUMN "elements" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { PermissionCatalogSyncService } from './permission-catalog-sync.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { PERMISSION_CATALOG } from './permission-catalog';
|
||||
|
||||
describe('PermissionCatalogSyncService', () => {
|
||||
let service: PermissionCatalogSyncService;
|
||||
let prisma: {
|
||||
permission: { findMany: jest.Mock; create: jest.Mock };
|
||||
role: { findMany: jest.Mock };
|
||||
rolePermission: { createMany: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
permission: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn((args: { data: { key: string } }) =>
|
||||
Promise.resolve({ id: `perm-${args.data.key}`, ...args.data }),
|
||||
),
|
||||
},
|
||||
role: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
rolePermission: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||
};
|
||||
service = new PermissionCatalogSyncService(
|
||||
prisma as unknown as PrismaService,
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when every catalog permission already exists', async () => {
|
||||
prisma.permission.findMany.mockResolvedValue(
|
||||
PERMISSION_CATALOG.map((entry) => ({ key: entry.key })),
|
||||
);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(prisma.permission.create).not.toHaveBeenCalled();
|
||||
expect(prisma.role.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inserts missing catalog permissions and grants them to every system role', async () => {
|
||||
const [first, ...rest] = PERMISSION_CATALOG;
|
||||
prisma.permission.findMany.mockResolvedValue(
|
||||
rest.map((entry) => ({ key: entry.key })),
|
||||
);
|
||||
prisma.role.findMany.mockResolvedValue([
|
||||
{ id: 'role-admin' },
|
||||
{ id: 'role-owner' },
|
||||
]);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(prisma.permission.create).toHaveBeenCalledWith({ data: first });
|
||||
expect(prisma.role.findMany).toHaveBeenCalledWith({
|
||||
where: { isSystem: true },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.rolePermission.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ roleId: 'role-admin', permissionId: `perm-${first.key}` },
|
||||
{ roleId: 'role-owner', permissionId: `perm-${first.key}` },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the role grant step when no system role exists', async () => {
|
||||
prisma.permission.findMany.mockResolvedValue([]);
|
||||
prisma.role.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(prisma.permission.create).toHaveBeenCalledTimes(
|
||||
PERMISSION_CATALOG.length,
|
||||
);
|
||||
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { PLACEHOLDER_KEYS } from '../label-element.types';
|
||||
|
||||
const ELEMENT_TYPES = ['text', 'qrCode', 'image', 'line'] as const;
|
||||
const FONT_FAMILIES = ['sans', 'serif', 'mono'] as const;
|
||||
const TEXT_ALIGNS = ['left', 'center', 'right'] as const;
|
||||
const LINE_ORIENTATIONS = ['horizontal', 'vertical'] as const;
|
||||
|
||||
// One DTO shared by all element types rather than a discriminated-union of
|
||||
// four DTOs: this is an internal, permission-gated design surface (not
|
||||
// public input), so the pragmatic boundary check is "every field has the
|
||||
// right primitive type" - the service picks which fields it reads per
|
||||
// `type` at render time.
|
||||
export class LabelElementDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enum: ELEMENT_TYPES })
|
||||
@IsIn(ELEMENT_TYPES)
|
||||
type!: (typeof ELEMENT_TYPES)[number];
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
x!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
y!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
width?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
height?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
size?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
length?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
thickness?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'text-Element: literaler Text' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PLACEHOLDER_KEYS })
|
||||
@IsOptional()
|
||||
@IsIn(PLACEHOLDER_KEYS)
|
||||
placeholderKey?: (typeof PLACEHOLDER_KEYS)[number] | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
fontSize?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: FONT_FAMILIES })
|
||||
@IsOptional()
|
||||
@IsIn(FONT_FAMILIES)
|
||||
fontFamily?: (typeof FONT_FAMILIES)[number];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
bold?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
italic?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TEXT_ALIGNS })
|
||||
@IsOptional()
|
||||
@IsIn(TEXT_ALIGNS)
|
||||
align?: (typeof TEXT_ALIGNS)[number];
|
||||
|
||||
@ApiPropertyOptional({ enum: LINE_ORIENTATIONS })
|
||||
@IsOptional()
|
||||
@IsIn(LINE_ORIENTATIONS)
|
||||
orientation?: (typeof LINE_ORIENTATIONS)[number];
|
||||
|
||||
@ApiPropertyOptional({ description: 'image-Element: data: URL (PNG/JPEG)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(3_000_000)
|
||||
src?: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export type LabelFontFamily = 'sans' | 'serif' | 'mono';
|
||||
export type LabelTextAlign = 'left' | 'center' | 'right';
|
||||
export type LabelLineOrientation = 'horizontal' | 'vertical';
|
||||
|
||||
export const PLACEHOLDER_KEYS = [
|
||||
'batchNumber',
|
||||
'honeyType',
|
||||
'lotNumber',
|
||||
'bottlingDate',
|
||||
'bestBeforeDate',
|
||||
'jarSize',
|
||||
'productName',
|
||||
] as const;
|
||||
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
|
||||
|
||||
interface LabelElementBase {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface LabelTextElement extends LabelElementBase {
|
||||
type: 'text';
|
||||
width: number;
|
||||
height: number;
|
||||
text: string;
|
||||
placeholderKey?: PlaceholderKey | null;
|
||||
fontSize: number;
|
||||
fontFamily: LabelFontFamily;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
color: string;
|
||||
align: LabelTextAlign;
|
||||
}
|
||||
|
||||
export interface LabelQrCodeElement extends LabelElementBase {
|
||||
type: 'qrCode';
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LabelImageElement extends LabelElementBase {
|
||||
type: 'image';
|
||||
width: number;
|
||||
height: number;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export interface LabelLineElement extends LabelElementBase {
|
||||
type: 'line';
|
||||
length: number;
|
||||
thickness: number;
|
||||
color: string;
|
||||
orientation: LabelLineOrientation;
|
||||
}
|
||||
|
||||
export type LabelElement =
|
||||
LabelTextElement | LabelQrCodeElement | LabelImageElement | LabelLineElement;
|
||||
Reference in new issue
Block a user