feat(quick-261003-387): Kategorien-API - Anlegen, Sortieren, Zuordnen, Loeschen mit Verschieben, Ueberlagerung in allen Modullisten

- Verwaltungs-Endpunkte nur fuer Administratoren, statische Routen vor :key
- Loeschen verschiebt alle Eintraege inkl. persoenlicher eigener Module in einer Transaktion, ohne Ziel 409
- /modules, /modules/catalog und /module-grants/matrix liefern wirksame Kategorie und Reihenfolge
- eigene Module pruefen die Kategorie gegen die Organisation (400 bei unbekannter)
- Zugriffsinventar nachgezogen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 02:39:36 +02:00
parent 8ec116c75a
commit af1878d5ee
15 changed files with 1152 additions and 37 deletions
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
import { CustomModulesController } from './custom-modules.controller';
import { CustomModulesService } from './custom-modules.service';
@@ -7,6 +8,7 @@ import { CustomModulesService } from './custom-modules.service';
* `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt).
*/
@Module({
imports: [ModuleCategoriesModule],
controllers: [CustomModulesController],
providers: [CustomModulesService],
})
@@ -1,4 +1,4 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
@@ -51,9 +51,18 @@ const admin = { id: 'admin1', role: Role.ADMIN };
const userA = { id: 'ua', role: Role.USER };
const userB = { id: 'ub', role: Role.USER };
// Kategorien-Dienst (quick-261003-387): die Organisation fuehrt eine feste
// Menge von Kennungen; eine andere lehnt assertCategoryKey mit 400 ab.
const KNOWN_CATEGORIES = new Set(['fleet', 'infrastructure', 'custom-modules', 'neu-angelegt']);
function setup() {
const prisma = makeFakePrisma();
return { prisma, service: new CustomModulesService(prisma as any) };
const categories = {
assertCategoryKey: vi.fn(async (_tenantId: string, key: string) => {
if (!KNOWN_CATEGORIES.has(key)) throw new BadRequestException('Unbekannte Kategorie');
}),
};
return { prisma, categories, service: new CustomModulesService(prisma as any, categories as any) };
}
describe('CustomModulesService — anlegen', () => {
@@ -106,6 +115,40 @@ describe('CustomModulesService — anlegen', () => {
});
});
describe('CustomModulesService — Kategorie gegen die Organisation pruefen', () => {
it('create: eine vorhandene Kennung (auch eine neu angelegte) ist erlaubt', async () => {
const { categories, service } = setup();
const res: any = await service.create('t1', userA, { ...dto, category: 'neu-angelegt' });
expect(res.category).toBe('neu-angelegt');
expect(categories.assertCategoryKey).toHaveBeenCalledWith('t1', 'neu-angelegt');
});
it('create: eine unbekannte Kategorie -> 400, nichts gespeichert', async () => {
const { prisma, service } = setup();
await expect(
service.create('t1', userA, { ...dto, category: 'gibtsnicht' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.customModule.create).not.toHaveBeenCalled();
});
it('update: eine unbekannte Kategorie -> 400, der Eintrag bleibt unveraendert', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', userA, dto);
await expect(
service.update('t1', userA, created.id, { category: 'gibtsnicht' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.customModule.update).not.toHaveBeenCalled();
});
it('update ohne Kategorie prueft nichts', async () => {
const { categories, service } = setup();
const created: any = await service.create('t1', userA, dto);
categories.assertCategoryKey.mockClear();
await service.update('t1', userA, created.id, { name: 'Neuer Name' });
expect(categories.assertCategoryKey).not.toHaveBeenCalled();
});
});
describe('CustomModulesService — lesen', () => {
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
const { service } = setup();
@@ -1,5 +1,6 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
@@ -10,6 +11,7 @@ const CUSTOM_MODULE_SELECT = {
name: true,
url: true,
category: true,
sortOrder: true,
ownerUserId: true,
createdAt: true,
updatedAt: true,
@@ -56,7 +58,10 @@ function toResponse<T extends { ownerUserId: string | null }>(row: T) {
*/
@Injectable()
export class CustomModulesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly categories: ModuleCategoriesService,
) {}
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
async list(tenantId: string, caller: CustomModuleCaller) {
@@ -81,6 +86,9 @@ export class CustomModulesService {
if (shared && !isAdmin(caller)) {
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
}
// quick-261003-387: jede Kategorie der Organisation ist erlaubt, eine
// unbekannte lehnt der Dienst mit 400 ab (statt fester Liste im DTO).
await this.categories.assertCategoryKey(tenantId, dto.category);
const data = {
tenantId,
name: dto.name,
@@ -106,6 +114,9 @@ export class CustomModulesService {
dto: UpdateCustomModuleDto,
) {
const tenantPrisma = await this.writableClient(tenantId, caller, id);
if (dto.category !== undefined) {
await this.categories.assertCategoryKey(tenantId, dto.category);
}
const data: { name?: string; url?: string; category?: string } = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.url !== undefined) data.url = dto.url;
@@ -29,11 +29,23 @@ describe('CreateCustomModuleDto', () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
});
it('lehnt eine unbekannte Kategorie ab', async () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category: 'other' })).toContain(
'category',
);
});
// quick-261003-387: das DTO prueft nur das Format der Kennung; ob die
// Organisation sie fuehrt, entscheidet der Dienst (400, siehe Dienst-Spec).
it.each(['', 'Gross', 'mit leerzeichen', '-fuehrend', 'x'.repeat(61)])(
'lehnt die Kategorie-Kennung %j ab',
async (category) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).toContain('category');
},
);
it.each(['fleet', 'custom-modules', 'werkzeuge-tools'])(
'akzeptiert die Kategorie-Kennung %s',
async (category) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).not.toContain(
'category',
);
},
);
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
@@ -61,7 +73,7 @@ describe('UpdateCustomModuleDto', () => {
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, { category: 'Nicht Gueltig' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
});
@@ -1,12 +1,11 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
Validate,
ValidatorConstraint,
@@ -60,8 +59,13 @@ export class CreateCustomModuleDto {
@Validate(NurHttpsOhneZugangsdatenConstraint)
url!: string;
@IsIn([...CUSTOM_MODULE_CATEGORIES])
category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
/**
* Kennung einer Kategorie der Organisation (quick-261003-387). Das Format
* prueft das DTO, ob die Kategorie existiert der Dienst (sonst 400).
*/
@IsString()
@Matches(/^[a-z0-9][a-z0-9-]{0,59}$/)
category!: string;
/**
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer