feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite

- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000
- API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten
- Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“
- MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:25:33 +02:00
parent 643b1a2caa
commit b9d87be360
23 changed files with 1270 additions and 40 deletions
@@ -0,0 +1,47 @@
-- 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Eintraege.
--
-- Zweck: neue Tabelle "CustomModule". Der Administrator legt Eintraege an
-- (Name, https-Adresse, Kategorie), alle Benutzer des Mandanten sehen sie in
-- der Seitenleiste und ein Klick zeigt die Seite im Rahmen. Mehrere Zeilen je
-- Mandant, Vorbild "ProxmoxServer" (tenantId-Spalte, keine Relation zu
-- Tenant).
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — eigene
-- Module sind Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines
-- einzelnen Benutzers.
--
-- BEWUSST KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der
-- eigene Module ueber alle Mandanten lesen muesste; jeder Zugriff laeuft
-- mandantengebunden ueber `forTenant(prisma, tenantId)`.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "CustomModule" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"category" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomModule_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CustomModule_tenantId_idx" ON "CustomModule"("tenantId");
ALTER TABLE "CustomModule" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "CustomModule" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "CustomModule"
USING ("tenantId" = current_tenant_id());
+16
View File
@@ -716,3 +716,19 @@ model ProxmoxServerStatus {
@@index([tenantId])
}
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
// keine Relation zu Tenant).
model CustomModule {
id String @id @default(uuid())
tenantId String
name String
url String
category String // eine der MODULE_CATEGORIES aus @tessera/shared
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
+2
View File
@@ -27,6 +27,7 @@ import { TenantModule } from './tenant/tenant.module';
import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
@Module({
imports: [
@@ -53,6 +54,7 @@ import { ProxmoxModule } from './proxmox/proxmox.module';
TendersModule,
BugReportsModule,
ProxmoxModule,
CustomModulesModule,
],
providers: [
// Global JWT guard: all routes require auth unless @Public()
@@ -0,0 +1,81 @@
import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CustomModulesController } from './custom-modules.controller';
import { CreateCustomModuleDto } from './dto/custom-module.dto';
function makeService() {
return {
list: vi.fn(async (..._args: unknown[]) => []),
getOne: vi.fn(async (..._args: unknown[]) => ({})),
create: vi.fn(async (..._args: unknown[]) => ({})),
update: vi.fn(async (..._args: unknown[]) => ({})),
remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })),
};
}
const req = (tenantId?: string) => ({ tenantId }) as any;
const proto = CustomModulesController.prototype as any;
describe('CustomModulesController — Rollen (T-9WC-01)', () => {
it.each(['create', 'update', 'remove'])('%s ist nur fuer ADMIN und SUPER_ADMIN offen', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
});
it.each(['list', 'getOne'])('%s traegt keine Rollen (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
});
it('haengt an Pfad custom-modules', () => {
expect(Reflect.getMetadata('path', CustomModulesController)).toBe('custom-modules');
});
});
describe('CustomModulesController — Mandant', () => {
it('reicht req.tenantId an den Dienst weiter', async () => {
const service = makeService();
const controller = new CustomModulesController(service as any);
await controller.list(req('t1'));
await controller.getOne(req('t1'), 'x');
await controller.create(req('t1'), { name: 'a', url: 'https://a.de', category: 'fleet' });
await controller.update(req('t1'), 'x', { name: 'b' });
await controller.remove(req('t1'), 'x');
expect(service.list).toHaveBeenCalledWith('t1');
expect(service.getOne).toHaveBeenCalledWith('t1', 'x');
expect(service.create.mock.calls[0][0]).toBe('t1');
expect(service.update.mock.calls[0].slice(0, 2)).toEqual(['t1', 'x']);
expect(service.remove).toHaveBeenCalledWith('t1', 'x');
});
it('wirft ForbiddenException ohne req.tenantId', async () => {
const controller = new CustomModulesController(makeService() as any);
await expect(controller.list(req())).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getOne(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.create(req(), { name: 'a', url: 'https://a.de', category: 'fleet' }),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.remove(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
});
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{ name: 'a', url: 'https://a.de', category: 'fleet', tenantId: 'evil' },
{ type: 'body', metatype: CreateCustomModuleDto },
);
expect(out).not.toHaveProperty('tenantId');
});
});
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
it('deklariert list vor getOne', () => {
const methods = Object.getOwnPropertyNames(CustomModulesController.prototype);
const listIdx = methods.indexOf('list');
const idIdx = methods.indexOf('getOne');
expect(listIdx).toBeGreaterThanOrEqual(0);
expect(idIdx).toBeGreaterThanOrEqual(0);
expect(listIdx).toBeLessThan(idIdx);
});
});
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CustomModulesService } from './custom-modules.service';
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
/**
* Eigene Module (quick-260929-9wc). Lesen (`GET`, `GET :id`) steht jedem
* angemeldeten Benutzer offen — die Eintraege sind fuer alle sichtbar (D-01);
* Schreiben nur `@Roles(ADMIN, SUPER_ADMIN)` (T-9WC-01). Kein `@UseModule`:
* eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`).
*
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
* stehen, sonst faengt `:id` sie ab (404-Shadowing); der Controller-Test
* haelt die Reihenfolge von `list` vor `getOne` fest.
*/
@Controller('custom-modules')
export class CustomModulesController {
constructor(private readonly service: CustomModulesService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get()
async list(@Req() req: AuthenticatedRequest) {
return this.service.list(this.requireTenantId(req));
}
@Get(':id')
async getOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.getOne(this.requireTenantId(req), id);
}
@Post()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateCustomModuleDto) {
return this.service.create(this.requireTenantId(req), dto);
}
@Patch(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateCustomModuleDto,
) {
return this.service.update(this.requireTenantId(req), id, dto);
}
@Delete(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.remove(this.requireTenantId(req), id);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CustomModulesController } from './custom-modules.controller';
import { CustomModulesService } from './custom-modules.service';
/**
* Eigene Module (quick-260929-9wc). `PrismaModule` ist global (wie bei
* `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt).
*/
@Module({
controllers: [CustomModulesController],
providers: [CustomModulesService],
})
export class CustomModulesModule {}
@@ -0,0 +1,126 @@
import { NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
// rls-access-inventory.spec.ts; hier zaehlt, dass je Methode (prisma, tenantId)
// uebergeben wird.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CustomModulesService } from './custom-modules.service';
function makeFakePrisma() {
const rows = new Map<string, any>();
let seq = 0;
const customModule = {
create: vi.fn(async ({ data }: { data: any }) => {
const id = `cm-${++seq}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
rows.set(id, row);
return row;
}),
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
let list = [...rows.values()];
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name));
return list;
}),
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => rows.get(where.id) ?? null),
update: vi.fn(async ({ where, data }: { where: { id: string }; data: any }) => {
const row = { ...rows.get(where.id), ...data };
rows.set(where.id, row);
return row;
}),
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
rows.delete(where.id);
}),
};
return { customModule, rows };
}
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
describe('CustomModulesService', () => {
it('create speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
await service.create('t1', { ...dto, tenantId: 'evil' } as any);
expect(prisma.customModule.create).toHaveBeenCalledTimes(1);
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
});
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
await service.create('t1', { ...dto, name: 'Zebra' });
await service.create('t1', { ...dto, name: 'Anker' });
await service.create('t2', { ...dto, name: 'Fremd' });
const result = await service.list('t1');
expect(result.map((r: any) => r.name)).toEqual(['Anker', 'Zebra']);
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({ tenantId: 't1' });
});
it('getOne liefert die Zeile ohne tenantId', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t1', dto);
const row: any = await service.getOne('t1', created.id);
expect(row.name).toBe('Wiki');
expect(row).not.toHaveProperty('tenantId');
});
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
const service = new CustomModulesService(makeFakePrisma() as any);
await expect(service.getOne('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', 'nope', { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
});
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t2', dto);
await expect(service.getOne('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', created.id, { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.customModule.update).not.toHaveBeenCalled();
expect(prisma.customModule.delete).not.toHaveBeenCalled();
});
it('update aendert nur gesetzte Felder', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t1', dto);
await service.update('t1', created.id, { name: 'Neu' });
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
});
it('remove loescht und liefert { deleted: true }', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t1', dto);
await expect(service.remove('t1', created.id)).resolves.toEqual({ deleted: true });
expect(prisma.rows.size).toBe(0);
});
it('ruft forTenant je Methode mit (prisma, tenantId) auf', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
vi.mocked(forTenant).mockClear();
const created: any = await service.create('t1', dto);
await service.list('t1');
await service.getOne('t1', created.id);
await service.update('t1', created.id, { name: 'a' });
await service.remove('t1', created.id);
expect(forTenant).toHaveBeenCalledTimes(5);
for (const call of vi.mocked(forTenant).mock.calls) {
expect(call).toEqual([prisma, 't1']);
}
});
});
@@ -0,0 +1,85 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
const CUSTOM_MODULE_SELECT = {
id: true,
name: true,
url: true,
category: true,
createdAt: true,
updatedAt: true,
};
/**
* Eigene Module (quick-260929-9wc): vom Administrator angelegte
* Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt
* ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je
* Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen
* getOne/update/remove `row.tenantId` — zweites Netz, solange der
* RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte
* id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz.
*/
@Injectable()
export class CustomModulesService {
constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.customModule.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
select: CUSTOM_MODULE_SELECT,
});
}
async getOne(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.customModule.findUnique({
where: { id },
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
});
if (!row || row.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
const { tenantId: _omit, ...result } = row;
return result;
}
async create(tenantId: string, dto: CreateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.customModule.create({
data: { tenantId, name: dto.name, url: dto.url, category: dto.category },
select: CUSTOM_MODULE_SELECT,
});
}
async update(tenantId: string, id: string, dto: UpdateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
if (!existing || existing.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
const data: { name?: string; url?: string; category?: string } = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.url !== undefined) data.url = dto.url;
if (dto.category !== undefined) data.category = dto.category;
return tenantPrisma.customModule.update({
where: { id },
data,
select: CUSTOM_MODULE_SELECT,
});
}
async remove(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
if (!existing || existing.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
await tenantPrisma.customModule.delete({ where: { id } });
return { deleted: true };
}
}
@@ -0,0 +1,67 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, expect, it } from 'vitest';
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './custom-module.dto';
async function errorsFor<T extends object>(cls: new () => T, plain: Record<string, unknown>) {
const dto = plainToInstance(cls, plain);
const errors = await validate(dto as object);
return errors.map((e) => e.property);
}
const valid = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' };
describe('CreateCustomModuleDto', () => {
it('nimmt einen gueltigen Eintrag an', async () => {
expect(await errorsFor(CreateCustomModuleDto, valid)).toEqual([]);
});
it.each([
'http://example.com',
'javascript:alert(1)',
'data:text/html,x',
'ftp://x',
'kaputt',
'https://user:pw@example.com',
'https://user@example.com',
])('lehnt die Adresse %s ab', async (url) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
});
it('lehnt eine unbekannte Kategorie ab', async () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category: 'other' })).toContain(
'category',
);
});
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
});
it('trimmt den Namen', () => {
const dto = plainToInstance(CreateCustomModuleDto, { ...valid, name: ' Wiki ' });
expect(dto.name).toBe('Wiki');
});
it('lehnt zu lange Namen und Adressen ab', async () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name: 'a'.repeat(101) })).toContain(
'name',
);
const longUrl = `https://example.com/${'a'.repeat(2048)}`;
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url: longUrl })).toContain('url');
});
});
describe('UpdateCustomModuleDto', () => {
it('akzeptiert Teilmengen', async () => {
expect(await errorsFor(UpdateCustomModuleDto, { name: 'Neu' })).toEqual([]);
expect(await errorsFor(UpdateCustomModuleDto, {})).toEqual([]);
});
it('prueft jedes gesetzte Feld gleich', async () => {
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
});
});
@@ -0,0 +1,66 @@
import { PartialType } from '@nestjs/mapped-types';
import { MODULE_CATEGORIES } from '@tessera/shared';
import { Transform } from 'class-transformer';
import {
IsIn,
IsNotEmpty,
IsString,
MaxLength,
Validate,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
/**
* Adresse eines eigenen Moduls (T-9WC-03, T-9WC-06): gueltig nur, wenn der
* URL-Parser sie annimmt, das Schema `https:` ist, ein Rechnername da ist und
* weder Benutzername noch Kennwort in der Adresse stehen — sonst saehe jeder
* Benutzer die Zugangsdaten. `javascript:`, `data:`, `http:` und `ftp:` fallen
* damit heraus.
*/
@ValidatorConstraint({ name: 'nurHttpsOhneZugangsdaten', async: false })
class NurHttpsOhneZugangsdatenConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (typeof value !== 'string') return false;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (
parsed.protocol === 'https:' &&
parsed.hostname !== '' &&
parsed.username === '' &&
parsed.password === ''
);
}
defaultMessage(): string {
return 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.';
}
}
const trimString = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** DTO fuer das Anlegen eines eigenen Moduls. */
export class CreateCustomModuleDto {
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(100)
name!: string;
@Transform(trimString)
@IsString()
@MaxLength(2048)
@Validate(NurHttpsOhneZugangsdatenConstraint)
url!: string;
@IsIn([...MODULE_CATEGORIES])
category!: (typeof MODULE_CATEGORIES)[number];
}
/** Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. */
export class UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto) {}