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(
// 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
+2
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service';
import { ModuleGrantsController } from './module-grants.controller';
@@ -17,6 +18,7 @@ import { ModuleGrantsService } from './module-grants.service';
* ModuleAccessService (15-01/15-05), das hier nicht verwendet wird.
*/
@Module({
imports: [ModuleCategoriesModule],
controllers: [GroupsController, ModuleGrantsController],
providers: [GroupsService, ModuleGrantsService],
exports: [GroupsService, ModuleGrantsService],
@@ -13,6 +13,7 @@ import { Role } from '@prisma/client';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
import { CreateModuleGrantDto } from './dto/create-module-grant.dto';
import { ModuleGrantsService } from './module-grants.service';
@@ -29,7 +30,10 @@ import { ModuleGrantsService } from './module-grants.service';
*/
@Controller('module-grants')
export class ModuleGrantsController {
constructor(private readonly moduleGrantsService: ModuleGrantsService) {}
constructor(
private readonly moduleGrantsService: ModuleGrantsService,
private readonly moduleCategoriesService: ModuleCategoriesService,
) {}
private getTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId ?? req.user?.tenantId;
@@ -41,13 +45,20 @@ export class ModuleGrantsController {
/**
* GET /module-grants/matrix
* Module × Gruppen mit den bestehenden Gruppen-Grants (D-15).
* Module × Gruppen mit den bestehenden Gruppen-Grants (D-15); die Module
* stehen in der eingestellten Kategorie- und Modulreihenfolge.
*/
@Get('matrix')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async matrix(@Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.getMatrix(this.getTenantId(req));
const tenantId = this.getTenantId(req);
const result = await this.moduleGrantsService.getMatrix(tenantId);
// quick-261003-387: Module in der Kategorie- und Modulreihenfolge der
// Organisation, jedes mit seiner wirksamen Kategorie. getMatrix selbst
// bleibt unveraendert.
const modules = await this.moduleCategoriesService.applyToModules(tenantId, result.modules);
return { ...result, modules };
}
/**
@@ -1,9 +1,22 @@
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsNotEmpty,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
const trimString = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** Kennung einer Kategorie: klein, a-z, 0-9, Bindestrich (T-387-06). */
export const CATEGORY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,59}$/;
/** Neuer Anzeigename einer Kategorie (die Kennung bleibt unveraendert). */
export class RenameModuleCategoryDto {
@Transform(trimString)
@@ -12,3 +25,51 @@ export class RenameModuleCategoryDto {
@MaxLength(60)
name!: string;
}
/** Neue Kategorie; die Kennung bildet der Dienst aus dem Namen. */
export class CreateModuleCategoryDto extends RenameModuleCategoryDto {}
/** Neue Reihenfolge aller Kategorien (Kennungen). */
export class ReorderModuleCategoriesDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200)
@IsString({ each: true })
@Matches(CATEGORY_KEY_PATTERN, { each: true })
keys!: string[];
}
/** Zuordnung eines Eintrags zu einer Kategorie. */
export class AssignModuleCategoryDto {
@IsIn(['module', 'custom'])
type!: 'module' | 'custom';
@IsString()
@IsNotEmpty()
@MaxLength(100)
id!: string;
@IsString()
@Matches(CATEGORY_KEY_PATTERN)
categoryKey!: string;
}
/** Ein Eintrag in der Reihenfolge einer Kategorie. */
export class CategoryItemRefDto {
@IsIn(['module', 'custom'])
type!: 'module' | 'custom';
@IsString()
@IsNotEmpty()
@MaxLength(100)
id!: string;
}
/** Neue Reihenfolge der Eintraege einer Kategorie. */
export class ReorderCategoryItemsDto {
@IsArray()
@ArrayMaxSize(1000)
@ValidateNested({ each: true })
@Type(() => CategoryItemRefDto)
items!: CategoryItemRefDto[];
}
@@ -1,10 +1,18 @@
import 'reflect-metadata';
import { ForbiddenException } from '@nestjs/common';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import {
AssignModuleCategoryDto,
CreateModuleCategoryDto,
ReorderCategoryItemsDto,
ReorderModuleCategoriesDto,
} from './dto/module-category.dto';
import { ModuleCategoriesController } from './module-categories.controller';
const proto = ModuleCategoriesController.prototype as any;
@@ -19,9 +27,26 @@ describe('ModuleCategoriesController — Rollen (T-387-01)', () => {
expect(Reflect.getMetadata(ROLES_KEY, proto.list)).toBeUndefined();
});
it('rename ist nur fuer Administratoren', () => {
expect(Reflect.getMetadata(ROLES_KEY, proto.rename)).toEqual(ADMIN_ONLY);
expect(Reflect.getMetadata(GUARDS_METADATA, proto.rename)).toContain(RolesGuard);
it.each([
'overview',
'create',
'reorder',
'assign',
'rename',
'reorderItems',
'remove',
])('%s ist nur fuer Administratoren', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toEqual(ADMIN_ONLY);
expect(Reflect.getMetadata(GUARDS_METADATA, proto[name]), name).toContain(RolesGuard);
});
it('jede Methode ausser list traegt eine Rolle (keine vergessen)', () => {
const names = Object.getOwnPropertyNames(proto).filter(
(n) => n !== 'constructor' && n !== 'tenantIdOf' && typeof proto[n] === 'function',
);
for (const name of names.filter((n) => n !== 'list')) {
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toEqual(ADMIN_ONLY);
}
});
});
@@ -40,3 +65,86 @@ describe('ModuleCategoriesController — Organisation', () => {
await expect(controller.list({} as any)).rejects.toBeInstanceOf(ForbiddenException);
});
});
describe('ModuleCategoriesController — Routen-Reihenfolge (statisch vor :key)', () => {
it("steht jede statische Route im Quelltext vor der ersten ':key'-Route", () => {
const source = readFileSync(join(__dirname, 'module-categories.controller.ts'), 'utf8');
const firstParam = source.indexOf("':key");
expect(firstParam).toBeGreaterThan(0);
for (const route of ["'overview'", "'order'", "'assignment'"]) {
const at = source.indexOf(route);
expect(at, route).toBeGreaterThan(0);
expect(at, route).toBeLessThan(firstParam);
}
});
});
describe('ModuleCategoriesController — Weiterreichen', () => {
it('reicht Organisation und Eingaben an den Dienst', async () => {
const service = {
getOverview: vi.fn(async () => []),
create: vi.fn(async () => ({})),
reorderCategories: vi.fn(async () => []),
assign: vi.fn(async () => ({})),
reorderItems: vi.fn(async () => ({})),
remove: vi.fn(async () => ({})),
};
const c = new ModuleCategoriesController(service as any);
const req = { tenantId: 't1' } as any;
await c.overview(req);
await c.create(req, { name: 'Neu' });
await c.reorder(req, { keys: ['a'] });
await c.assign(req, { type: 'module', id: 'x', categoryKey: 'a' });
await c.reorderItems(req, 'a', { items: [{ type: 'module', id: 'x' }] });
await c.remove(req, 'a', 'b');
await c.remove(req, 'a');
expect(service.getOverview).toHaveBeenCalledWith('t1');
expect(service.create).toHaveBeenCalledWith('t1', 'Neu');
expect(service.reorderCategories).toHaveBeenCalledWith('t1', ['a']);
expect(service.assign).toHaveBeenCalledWith('t1', {
type: 'module',
id: 'x',
categoryKey: 'a',
});
expect(service.reorderItems).toHaveBeenCalledWith('t1', 'a', [{ type: 'module', id: 'x' }]);
expect(service.remove).toHaveBeenNthCalledWith(1, 't1', 'a', 'b');
expect(service.remove).toHaveBeenNthCalledWith(2, 't1', 'a', undefined);
});
});
describe('Module-Kategorien-DTOs', () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const run = (metatype: any, value: unknown) => pipe.transform(value, { type: 'body', metatype });
it('Name wird getrimmt, leer oder zu lang -> 400', async () => {
await expect(run(CreateModuleCategoryDto, { name: ' Neu ' })).resolves.toMatchObject({
name: 'Neu',
});
await expect(run(CreateModuleCategoryDto, { name: ' ' })).rejects.toThrow();
await expect(run(CreateModuleCategoryDto, { name: 'x'.repeat(61) })).rejects.toThrow();
});
it('Reihenfolge verlangt Kennungen im erlaubten Format', async () => {
await expect(run(ReorderModuleCategoriesDto, { keys: ['a', 'b-1'] })).resolves.toBeDefined();
await expect(run(ReorderModuleCategoriesDto, { keys: [] })).rejects.toThrow();
await expect(run(ReorderModuleCategoriesDto, { keys: ['A b'] })).rejects.toThrow();
});
it('Zuordnung verlangt Art module|custom', async () => {
await expect(
run(AssignModuleCategoryDto, { type: 'x', id: 'a', categoryKey: 'a' }),
).rejects.toThrow();
await expect(
run(AssignModuleCategoryDto, { type: 'custom', id: 'a', categoryKey: 'a' }),
).resolves.toBeDefined();
});
it('Eintragsreihenfolge prueft jedes Element', async () => {
await expect(
run(ReorderCategoryItemsDto, { items: [{ type: 'module', id: 'a' }] }),
).resolves.toBeDefined();
await expect(
run(ReorderCategoryItemsDto, { items: [{ type: 'x', id: 'a' }] }),
).rejects.toThrow();
});
});
@@ -1,10 +1,14 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
@@ -12,7 +16,13 @@ import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { RenameModuleCategoryDto } from './dto/module-category.dto';
import {
AssignModuleCategoryDto,
CreateModuleCategoryDto,
RenameModuleCategoryDto,
ReorderCategoryItemsDto,
ReorderModuleCategoriesDto,
} from './dto/module-category.dto';
import { ModuleCategoriesService } from './module-categories.service';
/**
@@ -20,8 +30,8 @@ import { ModuleCategoriesService } from './module-categories.service';
*
* - GET /module-categories — alle angemeldeten Benutzer (Beschriftung und
* Reihenfolge der Seitenleiste); liefert nur Kennung, Name, Reihenfolge.
* - Verwaltung (Umbenennen, spaeter Anlegen/Sortieren/Zuordnen/Loeschen) nur
* ADMIN/SUPER_ADMIN, je Methode ueber @UseGuards(RolesGuard) + @Roles.
* - Verwaltung (Uebersicht, Anlegen, Umbenennen, Sortieren, Zuordnen, Loeschen)
* nur ADMIN/SUPER_ADMIN, je Methode ueber @UseGuards(RolesGuard) + @Roles.
*
* ROUTEN-REIHENFOLGE: statische Routen (overview, order, assignment) MUESSEN
* vor den Routen mit `:key` stehen, sonst faengt `:key` sie ab (404-
@@ -45,6 +55,34 @@ export class ModuleCategoriesController {
return this.service.listCategories(this.tenantIdOf(req));
}
@Get('overview')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async overview(@Req() req: AuthenticatedRequest) {
return this.service.getOverview(this.tenantIdOf(req));
}
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateModuleCategoryDto) {
return this.service.create(this.tenantIdOf(req), dto.name);
}
@Put('order')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async reorder(@Req() req: AuthenticatedRequest, @Body() dto: ReorderModuleCategoriesDto) {
return this.service.reorderCategories(this.tenantIdOf(req), dto.keys);
}
@Put('assignment')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async assign(@Req() req: AuthenticatedRequest, @Body() dto: AssignModuleCategoryDto) {
return this.service.assign(this.tenantIdOf(req), dto);
}
@Patch(':key')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@@ -55,4 +93,26 @@ export class ModuleCategoriesController {
) {
return this.service.rename(this.tenantIdOf(req), key, dto.name);
}
@Put(':key/items')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async reorderItems(
@Req() req: AuthenticatedRequest,
@Param('key') key: string,
@Body() dto: ReorderCategoryItemsDto,
) {
return this.service.reorderItems(this.tenantIdOf(req), key, dto.items);
}
@Delete(':key')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(
@Req() req: AuthenticatedRequest,
@Param('key') key: string,
@Query('moveTo') moveTo?: string,
) {
return this.service.remove(this.tenantIdOf(req), key, moveTo);
}
}
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
// `forTenant`/`withTenantTransaction` reichen den Klienten durch — die
@@ -11,7 +11,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
}));
import { makeFakePrisma } from './module-categories.fake-prisma';
import { ModuleCategoriesService } from './module-categories.service';
import { ModuleCategoriesService, slugifyCategoryName } from './module-categories.service';
const DEFAULT_KEYS = [
'domain-tools',
@@ -164,3 +164,349 @@ describe('ModuleCategoriesService — umbenennen', () => {
await expect(service.rename('t1', 'fleet', 'x'.repeat(61))).rejects.toThrow('1 bis 60');
});
});
const MODULES = [
{ id: 'm-fleet', slug: 'dkv-fleet', name: 'DKV', category: 'fleet' },
{ id: 'm-fleet2', slug: 'tanken', name: 'Tanken', category: 'fleet' },
{ id: 'm-dom', slug: 'domaincheck', name: 'Domaincheck', category: 'domain-tools' },
{ id: 'm-prox', slug: 'proxmox', name: 'Proxmox', category: 'infrastructure' },
];
function custom(id: string, category: string, extra: Record<string, unknown> = {}) {
return {
id,
tenantId: 't1',
name: id,
url: 'https://x.de',
category,
ownerUserId: null,
sortOrder: null,
...extra,
};
}
describe('slugifyCategoryName (E-06)', () => {
it.each([
['Werkzeuge & Tools', 'werkzeuge-tools'],
['Übergröße Maßnahmen', 'uebergroesse-massnahmen'],
[' -- ', 'kategorie'],
['Café Ünï', 'cafe-ueni'],
['x'.repeat(60), 'x'.repeat(40)],
])('%j -> %j', (input, expected) => {
expect(slugifyCategoryName(input)).toBe(expected);
});
});
describe('ModuleCategoriesService — anlegen', () => {
it('bildet die Kennung aus dem Namen und haengt hinten an', async () => {
const { service } = setup({ modules: MODULES });
const created = await service.create('t1', 'Werkzeuge & Tools');
expect(created).toMatchObject({
key: 'werkzeuge-tools',
name: 'Werkzeuge & Tools',
isSystem: false,
});
expect(created.sortOrder).toBe(7);
});
it('haengt -2 an, wenn die Kennung ein Modul-Slug ist', async () => {
const { service } = setup({ modules: MODULES });
expect((await service.create('t1', 'Proxmox')).key).toBe('proxmox-2');
});
it('haengt -2 an bei „custom“ und bei einer vorhandenen Kennung, dann -3', async () => {
const { service } = setup({ modules: MODULES });
expect((await service.create('t1', 'Custom')).key).toBe('custom-2');
expect((await service.create('t1', 'Custom')).key).toBe('custom-3');
expect((await service.create('t1', 'Fleet')).key).toBe('fleet-2');
});
it('leerer Name -> 400', async () => {
const { service } = setup();
await expect(service.create('t1', ' ')).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('ModuleCategoriesService — reorderCategories', () => {
it('schreibt sortOrder = Index', async () => {
const { service } = setup();
const reversed = [...DEFAULT_KEYS].reverse();
const out = await service.reorderCategories('t1', reversed);
expect(out.map((c) => c.key)).toEqual(reversed);
expect(out.map((c) => c.sortOrder)).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
it.each([
['fehlende Kennung', DEFAULT_KEYS.slice(1)],
['unbekannte Kennung', [...DEFAULT_KEYS.slice(1), 'fremd']],
['doppelte Kennung', [...DEFAULT_KEYS.slice(1), DEFAULT_KEYS[1]]],
])('%s -> 400', async (_label, keys) => {
const { service } = setup();
await expect(service.reorderCategories('t1', keys)).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('ModuleCategoriesService — zuordnen', () => {
it('Modul: legt die Zuordnung an, Module.category bleibt, Position hinten', async () => {
const { prisma, service } = setup({ modules: MODULES });
await service.assign('t1', { type: 'module', id: 'm-dom', categoryKey: 'fleet' });
expect(prisma.moduleCategoryPlacement.rows).toEqual([
expect.objectContaining({ moduleId: 'm-dom', categoryKey: 'fleet', sortOrder: 0 }),
]);
expect(prisma.module.rows.find((m) => m.id === 'm-dom')?.category).toBe('domain-tools');
await service.assign('t1', { type: 'module', id: 'm-prox', categoryKey: 'fleet' });
expect(
prisma.moduleCategoryPlacement.rows.find((p) => p.moduleId === 'm-prox')?.sortOrder,
).toBe(1);
});
it('Modul: schreibt eine bestehende Zuordnung um (kein zweiter Datensatz)', async () => {
const { prisma, service } = setup({
modules: MODULES,
placements: [
{ id: 'p', tenantId: 't1', moduleId: 'm-dom', categoryKey: 'fleet', sortOrder: 5 },
],
});
await service.assign('t1', { type: 'module', id: 'm-dom', categoryKey: 'accounting' });
expect(prisma.moduleCategoryPlacement.rows).toHaveLength(1);
expect(prisma.moduleCategoryPlacement.rows[0]).toMatchObject({ categoryKey: 'accounting' });
});
it('unbekannte Kategorie -> 400, unbekanntes Modul -> 404', async () => {
const { service } = setup({ modules: MODULES });
await expect(
service.assign('t1', { type: 'module', id: 'm-dom', categoryKey: 'gibtsnicht' }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.assign('t1', { type: 'module', id: 'nix', categoryKey: 'fleet' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('eigenes gemeinsames Modul: schreibt Kategorie und sortOrder', async () => {
const { prisma, service } = setup({ customModules: [custom('c1', 'infrastructure')] });
await service.assign('t1', { type: 'custom', id: 'c1', categoryKey: 'fleet' });
expect(prisma.customModule.rows[0]).toMatchObject({ category: 'fleet', sortOrder: 0 });
});
it('eigenes persoenliches oder fremdes Modul -> 404, nichts geaendert', async () => {
const { prisma, service } = setup({
customModules: [
custom('priv', 'infrastructure', { ownerUserId: 'u1' }),
custom('fremd', 'infrastructure', { tenantId: 't2' }),
],
});
await expect(
service.assign('t1', { type: 'custom', id: 'priv', categoryKey: 'fleet' }),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
service.assign('t1', { type: 'custom', id: 'fremd', categoryKey: 'fleet' }),
).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.customModule.rows.map((r) => r.category)).toEqual([
'infrastructure',
'infrastructure',
]);
});
});
describe('ModuleCategoriesService — reorderItems (T-387-04)', () => {
const seed = () => ({
modules: MODULES,
customModules: [custom('c1', 'fleet'), custom('priv', 'fleet', { ownerUserId: 'u1' })],
});
it('schreibt sortOrder = Index, Module per Zuordnung, eigene Module direkt', async () => {
const { prisma, service } = setup(seed());
await service.reorderItems('t1', 'fleet', [
{ type: 'custom', id: 'c1' },
{ type: 'module', id: 'm-fleet2' },
{ type: 'module', id: 'm-fleet' },
]);
const order = (id: string) =>
prisma.moduleCategoryPlacement.rows.find((p) => p.moduleId === id)?.sortOrder;
expect(order('m-fleet2')).toBe(1);
expect(order('m-fleet')).toBe(2);
expect(prisma.customModule.rows.find((r) => r.id === 'c1')?.sortOrder).toBe(0);
// Persoenliche Eintraege bekommen nie eine sortOrder.
expect(prisma.customModule.rows.find((r) => r.id === 'priv')?.sortOrder).toBeNull();
});
it.each([
[
'fehlender Eintrag',
[
{ type: 'module', id: 'm-fleet' },
{ type: 'custom', id: 'c1' },
],
],
[
'fremder Eintrag',
[
{ type: 'module', id: 'm-fleet' },
{ type: 'module', id: 'm-fleet2' },
{ type: 'custom', id: 'c1' },
{ type: 'module', id: 'm-dom' },
],
],
[
'persoenlicher Eintrag',
[
{ type: 'module', id: 'm-fleet' },
{ type: 'module', id: 'm-fleet2' },
{ type: 'custom', id: 'c1' },
{ type: 'custom', id: 'priv' },
],
],
[
'Doppelter',
[
{ type: 'module', id: 'm-fleet' },
{ type: 'module', id: 'm-fleet' },
{ type: 'custom', id: 'c1' },
],
],
])('%s -> 400, nichts geschrieben', async (_l, items) => {
const { prisma, service } = setup(seed());
await expect(service.reorderItems('t1', 'fleet', items as any)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(prisma.moduleCategoryPlacement.rows).toHaveLength(0);
});
it('unbekannte Kategorie -> 404', async () => {
const { service } = setup(seed());
await expect(service.reorderItems('t1', 'gibtsnicht', [])).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('ModuleCategoriesService — loeschen (E-05, T-387-05)', () => {
it('„Eigene Module“ (isSystem) -> 400', async () => {
const { service } = setup();
await expect(service.remove('t1', 'custom-modules', 'fleet')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('unbekannte Kennung -> 404', async () => {
const { service } = setup();
await expect(service.remove('t1', 'gibtsnicht')).rejects.toBeInstanceOf(NotFoundException);
});
it('nicht leer ohne Ziel -> 409 und nichts geloescht', async () => {
const { prisma, service } = setup({ modules: MODULES });
await expect(service.remove('t1', 'fleet')).rejects.toBeInstanceOf(ConflictException);
expect(prisma.moduleCategory.rows.some((c) => c.key === 'fleet')).toBe(true);
expect(prisma.moduleCategoryPlacement.rows).toHaveLength(0);
});
it('nur persoenliche Eintraege zaehlen auch als nicht leer -> 409', async () => {
const { service } = setup({
customModules: [custom('priv', 'fleet', { ownerUserId: 'u1' })],
});
await expect(service.remove('t1', 'fleet')).rejects.toBeInstanceOf(ConflictException);
});
it('Ziel gleich Kennung oder unbekannt -> 400', async () => {
const { service } = setup({ modules: MODULES });
await expect(service.remove('t1', 'fleet', 'fleet')).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(service.remove('t1', 'fleet', 'nix')).rejects.toBeInstanceOf(BadRequestException);
});
it('leere Kategorie ohne Ziel wird geloescht und kommt nicht zurueck', async () => {
const { prisma, service } = setup({ modules: MODULES });
await service.remove('t1', 'accounting');
expect(prisma.moduleCategory.rows.some((c) => c.key === 'accounting')).toBe(false);
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).not.toContain('accounting');
});
it('mit Ziel wandern Module (auch mit Manifest-Kategorie), gemeinsame UND persoenliche eigene Module', async () => {
const { prisma, service } = setup({
modules: MODULES,
placements: [
{ id: 'p', tenantId: 't1', moduleId: 'm-fleet2', categoryKey: 'fleet', sortOrder: 0 },
{ id: 'p2', tenantId: 't1', moduleId: 'm-dom', categoryKey: 'procurement', sortOrder: 4 },
],
customModules: [
custom('shared', 'fleet', { sortOrder: 1 }),
custom('priv', 'fleet', { ownerUserId: 'u1' }),
custom('andere', 'infrastructure'),
],
});
const res = await service.remove('t1', 'fleet', 'procurement');
expect(res).toEqual({ deleted: true, moved: 4 });
expect(prisma.moduleCategory.rows.some((c) => c.key === 'fleet')).toBe(false);
const place = (id: string) =>
prisma.moduleCategoryPlacement.rows.find((p) => p.moduleId === id);
// Manifest-Modul ohne Zuordnung bekommt eine, damit „fleet“ es nicht zurueckholt.
expect(place('m-fleet')).toMatchObject({ categoryKey: 'procurement' });
expect(place('m-fleet2')).toMatchObject({ categoryKey: 'procurement' });
// Verschobene Eintraege stehen hinten (nach dem bisherigen Maximum 4).
expect(place('m-fleet')?.sortOrder).toBeGreaterThan(4);
expect(place('m-fleet2')?.sortOrder).toBeGreaterThan(4);
const rows = (id: string) => prisma.customModule.rows.find((r) => r.id === id) ?? {};
expect(rows('shared')).toMatchObject({ category: 'procurement' });
expect((rows('shared') as { sortOrder: number }).sortOrder).toBeGreaterThan(4);
expect(rows('priv')).toMatchObject({ category: 'procurement', sortOrder: null });
expect(rows('andere')).toMatchObject({ category: 'infrastructure' });
// Nach dem Loeschen legt ensure „fleet“ nicht wieder an.
expect((await service.listCategories('t1')).map((c) => c.key)).not.toContain('fleet');
});
it('beruehrt eine andere Organisation nicht', async () => {
const { prisma, service } = setup({
modules: [],
customModules: [custom('t2-eintrag', 'fleet', { tenantId: 't2' })],
});
await service.listCategories('t1');
await service.listCategories('t2');
await service.remove('t1', 'fleet');
expect(prisma.moduleCategory.rows.some((c) => c.tenantId === 't2' && c.key === 'fleet')).toBe(
true,
);
});
});
describe('ModuleCategoriesService — Uebersicht', () => {
it('liefert je Kategorie in Reihenfolge die Eintraege und nur die Zahl persoenlicher', async () => {
const { service } = setup({
modules: MODULES,
placements: [
{ id: 'p', tenantId: 't1', moduleId: 'm-fleet2', categoryKey: 'fleet', sortOrder: 0 },
],
customModules: [
custom('shared', 'fleet', { name: 'Gemeinsam', sortOrder: 1 }),
custom('priv', 'fleet', { name: 'Geheim', ownerUserId: 'u1' }),
custom('priv2', 'fleet', { name: 'Geheim 2', ownerUserId: 'u2' }),
],
});
const overview = await service.getOverview('t1');
expect(overview.map((c) => c.key)).toEqual(DEFAULT_KEYS);
const fleet = overview.find((c) => c.key === 'fleet');
if (!fleet) throw new Error('fleet fehlt');
expect(fleet.items.map((i) => `${i.type}:${i.name}`)).toEqual([
'module:Tanken',
'custom:Gemeinsam',
'module:DKV',
]);
expect(fleet.items[0]).toMatchObject({ slug: 'tanken' });
expect(fleet.personalCount).toBe(2);
expect(JSON.stringify(overview)).not.toContain('Geheim');
expect(overview.find((c) => c.key === 'domain-tools')?.items.map((i) => i.name)).toEqual([
'Domaincheck',
]);
});
});
describe('ModuleCategoriesService — assertCategoryKey', () => {
it('kennt Standardkategorien, lehnt unbekannte mit 400 ab', async () => {
const { service } = setup();
await expect(service.assertCategoryKey('t1', 'fleet')).resolves.toBeUndefined();
await expect(service.assertCategoryKey('t1', 'nix')).rejects.toThrow('Unbekannte Kategorie');
});
});
@@ -1,7 +1,13 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
/** Eine Kategorie, wie sie GET /module-categories liefert. */
export interface ModuleCategoryRow {
@@ -25,6 +31,72 @@ export interface CategoryState {
placements: PlacementRow[];
}
/** Art eines Eintrags in einer Kategorie. */
export type CategoryItemType = 'module' | 'custom';
/** Ein Eintrag in der Verwaltungsuebersicht (persoenliche Eintraege nie einzeln). */
export interface OverviewItem {
type: CategoryItemType;
id: string;
name: string;
slug?: string;
}
/** Eine Kategorie mit ihren Eintraegen fuer die Verwaltungsseite. */
export interface CategoryOverview extends ModuleCategoryRow {
items: OverviewItem[];
/** Anzahl persoenlicher eigener Module von Benutzern — nie Name oder Adresse (T-387-03). */
personalCount: number;
}
/** Ein Eintrag mit wirksamer Kategorie, wie die Eintragsliste ihn intern fuehrt. */
interface ItemRow {
type: CategoryItemType;
id: string;
name: string;
slug?: string;
category: string;
sortOrder: number | null;
personal: boolean;
}
const MAX_NAME_LENGTH = 60;
/**
* Kennung aus einem Namen bilden (E-06): klein, ae/oe/ue/ss, sonst nur a-z,
* 0-9 und Bindestrich, hoechstens 40 Zeichen; leer wird „kategorie“.
*/
export function slugifyCategoryName(name: string): string {
const base = name
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+/, '')
.slice(0, 40)
.replace(/-+$/, '');
return base === '' ? 'kategorie' : base;
}
/** Reihenfolge der Eintraege: sortOrder aufsteigend (null zuletzt), eingebaut vor eigenem, Name. */
function compareItems(a: ItemRow, b: ItemRow): number {
if (a.sortOrder !== b.sortOrder) {
if (a.sortOrder === null) return 1;
if (b.sortOrder === null) return -1;
return a.sortOrder - b.sortOrder;
}
if (a.type !== b.type) return a.type === 'module' ? -1 : 1;
return a.name.localeCompare(b.name);
}
function sameSet(a: string[], b: string[]): boolean {
return a.length === b.length && new Set(a).size === a.length && b.every((x) => a.includes(x));
}
const CATEGORY_SELECT = {
id: true,
key: true,
@@ -184,10 +256,7 @@ export class ModuleCategoriesService {
/** Anzeigename aendern (1-60 Zeichen, getrimmt); die Kennung bleibt. */
async rename(tenantId: string, key: string, name: string): Promise<ModuleCategoryRow> {
const trimmed = name.trim();
if (trimmed.length < 1 || trimmed.length > 60) {
throw new BadRequestException('Der Name muss 1 bis 60 Zeichen lang sein');
}
const trimmed = this.validName(name);
const { categories } = await this.ensure(tenantId);
const existing = categories.find((c) => c.key === key);
if (!existing) {
@@ -200,4 +269,300 @@ export class ModuleCategoriesService {
select: CATEGORY_SELECT,
});
}
/** Fehlermeldung „Unbekannte Kategorie“ (400), wenn die Organisation die Kennung nicht fuehrt. */
async assertCategoryKey(tenantId: string, key: string): Promise<void> {
const { categories } = await this.ensure(tenantId);
if (!categories.some((c) => c.key === key)) {
throw new BadRequestException('Unbekannte Kategorie');
}
}
/** Neue Kategorie hinten anfuegen; die Kennung wird aus dem Namen gebildet (E-06). */
async create(tenantId: string, name: string): Promise<ModuleCategoryRow> {
const trimmed = this.validName(name);
const { categories } = await this.ensure(tenantId);
// Modul-Slugs sind eigene Routenordner unter /modules, „custom“ ist die
// Adresse eigener Module; beides darf keine Kategoriekennung werden.
const slugs = await this.prisma.module.findMany({ select: { slug: true } });
const taken = new Set<string>([
...categories.map((c) => c.key),
...slugs.map((m) => m.slug),
'custom',
]);
const base = slugifyCategoryName(trimmed);
let key = base;
for (let n = 2; taken.has(key); n++) key = `${base}-${n}`;
const sortOrder = categories.reduce((max, c) => Math.max(max, c.sortOrder), -1) + 1;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.moduleCategory.create({
data: { tenantId, key, name: trimmed, sortOrder, isSystem: false },
select: CATEGORY_SELECT,
});
}
/** Kategorien umsortieren: `keys` muss genau eine Umstellung aller Kennungen sein. */
async reorderCategories(tenantId: string, keys: string[]): Promise<ModuleCategoryRow[]> {
const { categories } = await this.ensure(tenantId);
if (
!sameSet(
keys,
categories.map((c) => c.key),
)
) {
throw new BadRequestException('Die Liste muss genau alle Kategorien enthalten');
}
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
for (const [index, key] of keys.entries()) {
await tx.moduleCategory.updateMany({
where: { tenantId, key },
data: { sortOrder: index },
});
}
});
return this.listCategories(tenantId);
}
/**
* Einen Eintrag einer Kategorie zuordnen und hinten anhaengen (E-02, E-03).
* Marktplatz-Module: Zuordnung anlegen oder umschreiben, `Module.category`
* bleibt unveraendert. Eigene Module: nur gemeinsame (ein persoenliches oder
* fremdes ist 404), die Kategorie steht direkt auf der Zeile.
*/
async assign(
tenantId: string,
input: { type: CategoryItemType; id: string; categoryKey: string },
): Promise<{ type: CategoryItemType; id: string; categoryKey: string }> {
const { type, id, categoryKey } = input;
await this.assertCategoryKey(tenantId, categoryKey);
const tenantPrisma = forTenant(this.prisma, tenantId);
if (type === 'module') {
const found = await this.prisma.module.findUnique({ where: { id }, select: { id: true } });
if (!found) throw new NotFoundException('Modul nicht gefunden');
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
const sortOrder = await this.nextSortOrder(tx, tenantId, categoryKey);
await tx.moduleCategoryPlacement.upsert({
where: { tenantId_moduleId: { tenantId, moduleId: id } },
create: { tenantId, moduleId: id, categoryKey, sortOrder },
update: { categoryKey, sortOrder },
});
});
} else {
const found = await tenantPrisma.customModule.findFirst({
where: { id, tenantId, ownerUserId: null },
select: { id: true },
});
if (!found) throw new NotFoundException('Eigenes Modul nicht gefunden');
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
const sortOrder = await this.nextSortOrder(tx, tenantId, categoryKey);
await tx.customModule.updateMany({
where: { id, tenantId, ownerUserId: null },
data: { category: categoryKey, sortOrder },
});
});
}
return { type, id, categoryKey };
}
/**
* Reihenfolge innerhalb einer Kategorie: `items` muss genau die Menge der
* nicht persoenlichen Eintraege der Kategorie sein (Marktplatz-Module mit
* wirksamer Kategorie plus gemeinsame eigene Module), sonst 400 (T-387-04).
*/
async reorderItems(
tenantId: string,
key: string,
items: Array<{ type: CategoryItemType; id: string }>,
): Promise<{ ok: true }> {
const state = await this.ensure(tenantId);
if (!state.categories.some((c) => c.key === key)) {
throw new NotFoundException('Kategorie nicht gefunden');
}
const rows = await this.loadItems(tenantId, state);
const expected = rows.filter((r) => r.category === key && !r.personal);
if (
!sameSet(
items.map((i) => `${i.type}:${i.id}`),
expected.map((r) => `${r.type}:${r.id}`),
)
) {
throw new BadRequestException('Die Liste muss genau die Einträge dieser Kategorie enthalten');
}
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
for (const [index, item] of items.entries()) {
if (item.type === 'module') {
await tx.moduleCategoryPlacement.upsert({
where: { tenantId_moduleId: { tenantId, moduleId: item.id } },
create: { tenantId, moduleId: item.id, categoryKey: key, sortOrder: index },
update: { categoryKey: key, sortOrder: index },
});
} else {
await tx.customModule.updateMany({
where: { id: item.id, tenantId, ownerUserId: null },
data: { sortOrder: index },
});
}
}
});
return { ok: true };
}
/**
* Kategorie loeschen (E-05). „Eigene Module“ (isSystem) nie (400). Eine nicht
* leere Kategorie ohne `moveTo` wird nicht angefasst (409); mit Ziel wandern
* ALLE Inhalte dorthin — Marktplatz-Module (Zuordnung umgeschrieben bzw. neu
* angelegt, damit die Manifest-Kategorie sie nicht zurueckholt), gemeinsame
* UND persoenliche eigene Module — in einer Transaktion mit dem Loeschen.
*/
async remove(
tenantId: string,
key: string,
moveTo?: string,
): Promise<{ deleted: true; moved: number }> {
const state = await this.ensure(tenantId);
const category = state.categories.find((c) => c.key === key);
if (!category) throw new NotFoundException('Kategorie nicht gefunden');
if (category.isSystem) {
throw new BadRequestException('Diese Kategorie kann nicht gelöscht werden');
}
if (moveTo !== undefined && moveTo !== '') {
if (moveTo === key) {
throw new BadRequestException('Die Zielkategorie muss eine andere Kategorie sein');
}
if (!state.categories.some((c) => c.key === moveTo)) {
throw new BadRequestException('Unbekannte Kategorie');
}
}
const target = moveTo === undefined || moveTo === '' ? null : moveTo;
const rows = await this.loadItems(tenantId, state);
const contents = rows.filter((r) => r.category === key).sort(compareItems);
if (contents.length > 0 && target === null) {
throw new ConflictException(
'Die Kategorie enthält Einträge – bitte eine Zielkategorie wählen',
);
}
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
if (target !== null && contents.length > 0) {
let next = await this.nextSortOrder(tx, tenantId, target);
for (const item of contents) {
if (item.type === 'module') {
await tx.moduleCategoryPlacement.upsert({
where: { tenantId_moduleId: { tenantId, moduleId: item.id } },
create: { tenantId, moduleId: item.id, categoryKey: target, sortOrder: next },
update: { categoryKey: target, sortOrder: next },
});
next++;
} else if (!item.personal) {
await tx.customModule.updateMany({
where: { id: item.id, tenantId },
data: { category: target, sortOrder: next },
});
next++;
}
}
// Persoenliche Eintraege gehen mit (nur das Feld `category`, ohne
// ihren Inhalt zu lesen); sie tragen nie eine sortOrder.
await tx.customModule.updateMany({
where: { tenantId, category: key },
data: { category: target },
});
}
await tx.moduleCategory.deleteMany({ where: { tenantId, key } });
});
return { deleted: true, moved: contents.length };
}
/** Alle Kategorien mit ihren Eintraegen in Reihenfolge — fuer die Verwaltungsseite. */
async getOverview(tenantId: string): Promise<CategoryOverview[]> {
const state = await this.ensure(tenantId);
const rows = await this.loadItems(tenantId, state);
return state.categories.map((category) => {
const inCategory = rows.filter((r) => r.category === category.key);
return {
...category,
items: inCategory
.filter((r) => !r.personal)
.sort(compareItems)
.map((r) => ({
type: r.type,
id: r.id,
name: r.name,
...(r.slug === undefined ? {} : { slug: r.slug }),
})),
personalCount: inCategory.filter((r) => r.personal).length,
};
});
}
private validName(name: string): string {
const trimmed = name.trim();
if (trimmed.length < 1 || trimmed.length > MAX_NAME_LENGTH) {
throw new BadRequestException('Der Name muss 1 bis 60 Zeichen lang sein');
}
return trimmed;
}
/**
* Alle Eintraege der Organisation mit wirksamer Kategorie: Marktplatz-Module
* (Zuordnung schlaegt Manifest) und eigene Module (gemeinsame und
* persoenliche; Letztere nur mit Kategorie, nie mit Inhalt).
*/
private async loadItems(tenantId: string, state: CategoryState): Promise<ItemRow[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const placementByModule = new Map(state.placements.map((p) => [p.moduleId, p]));
const modules = await this.prisma.module.findMany({
select: { id: true, slug: true, name: true, category: true },
});
const customs = await tenantPrisma.customModule.findMany({
where: { tenantId },
select: { id: true, name: true, category: true, sortOrder: true, ownerUserId: true },
});
return [
...modules.map((m): ItemRow => {
const placement = placementByModule.get(m.id);
return {
type: 'module',
id: m.id,
name: m.name,
slug: m.slug,
category: placement?.categoryKey ?? m.category,
sortOrder: placement?.sortOrder ?? null,
personal: false,
};
}),
...customs.map(
(c): ItemRow => ({
type: 'custom',
id: c.id,
name: c.name,
category: c.category,
sortOrder: c.sortOrder,
personal: c.ownerUserId !== null,
}),
),
];
}
/** Naechste freie Position in einer Kategorie (Maximum der nicht persoenlichen Eintraege + 1). */
private async nextSortOrder(
tx: Prisma.TransactionClient,
tenantId: string,
categoryKey: string,
): Promise<number> {
const placed = await tx.moduleCategoryPlacement.findMany({
where: { tenantId, categoryKey },
select: { sortOrder: true },
});
const custom = await tx.customModule.findMany({
where: { tenantId, category: categoryKey, ownerUserId: null },
select: { sortOrder: true },
});
const all = [...placed, ...custom]
.map((r) => r.sortOrder)
.filter((n): n is number => typeof n === 'number');
return all.length === 0 ? 0 : Math.max(...all) + 1;
}
}
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { ModuleGrantsController } from '../groups/module-grants.controller';
import { ModuleRegistryController } from './module-registry.controller';
/**
* quick-261003-387 (E-07): alle Modullisten laufen ueber
* `ModuleCategoriesService.applyToModules`, damit Seitenleiste, Marktplatz,
* Kategorieseite und Freigaben-Matrix dieselbe wirksame Kategorie sehen.
*/
const overlay = vi.fn(async (_tenantId: string, modules: Array<{ id: string }>) =>
modules.map((m) => ({ ...m, category: 'ueberlagert', sortOrder: 7 })),
);
const categories = { applyToModules: overlay } as any;
const req = { tenantId: 't1', user: { id: 'u1', role: 'USER', tenantId: 't1' } } as any;
describe('ModuleRegistryController — Kategorie-Ueberlagerung', () => {
const registry = { findAll: vi.fn(async () => [{ id: 'a', name: 'A', category: 'fleet' }]) };
const access = {
findAccessibleModules: vi.fn(async () => [{ id: 'a', name: 'A', category: 'fleet' }]),
getCatalogFlags: vi.fn(
async () => new Map([['a', { isActiveForTenant: true, hasAccess: true }]]),
),
};
const controller = new ModuleRegistryController(registry as any, access as any, categories);
it('GET /modules liefert die wirksame Kategorie', async () => {
expect(await controller.findAll(req)).toEqual([
{ id: 'a', name: 'A', category: 'ueberlagert', sortOrder: 7 },
]);
expect(overlay).toHaveBeenCalledWith('t1', expect.any(Array));
});
it('GET /modules ohne Organisationskontext bleibt unveraendert', async () => {
overlay.mockClear();
expect(await controller.findAll({} as any)).toEqual([
{ id: 'a', name: 'A', category: 'fleet' },
]);
expect(overlay).not.toHaveBeenCalled();
});
it('GET /modules/active liefert die wirksame Kategorie', async () => {
expect(await controller.findActive(req)).toEqual([
{ id: 'a', name: 'A', category: 'ueberlagert', sortOrder: 7 },
]);
});
it('GET /modules/catalog liefert die wirksame Kategorie samt Flags', async () => {
expect(await controller.findCatalog(req)).toEqual([
{
id: 'a',
name: 'A',
category: 'ueberlagert',
sortOrder: 7,
isActiveForTenant: true,
hasAccess: true,
},
]);
});
});
describe('ModuleGrantsController.matrix — Kategorie-Ueberlagerung', () => {
it('ersetzt nur die Module, Gruppen und Freigaben bleiben', async () => {
const grants = {
getMatrix: vi.fn(async () => ({
modules: [{ id: 'a', name: 'A', category: 'fleet' }],
groups: [{ id: 'g' }],
grants: [{ moduleId: 'a', groupId: 'g', level: 'USE' }],
})),
};
const controller = new ModuleGrantsController(grants as any, categories);
const out = await controller.matrix(req);
expect(out.modules).toEqual([{ id: 'a', name: 'A', category: 'ueberlagert', sortOrder: 7 }]);
expect(out.groups).toEqual([{ id: 'g' }]);
expect(out.grants).toHaveLength(1);
});
});
@@ -42,8 +42,13 @@ export class ModuleRegistryController {
* Available to any authenticated user (T-03-03: module catalog is non-sensitive).
*/
@Get()
async findAll() {
return this.moduleRegistryService.findAll();
async findAll(@Req() req: AuthenticatedRequest) {
const modules = await this.moduleRegistryService.findAll();
// quick-261003-387: wirksame Kategorie + Reihenfolge der Organisation;
// ohne Organisationskontext bleibt die Liste unveraendert.
const tenantId = req?.tenantId ?? req?.user?.tenantId;
if (!tenantId) return modules;
return this.moduleCategoriesService.applyToModules(tenantId, modules);
}
/**
@@ -92,7 +97,9 @@ export class ModuleRegistryController {
this.moduleAccessService.getCatalogFlags(tenantId, userId, role),
]);
return modules.map((module) => ({
// quick-261003-387: wirksame Kategorie + Reihenfolge der Organisation.
const withCategories = await this.moduleCategoriesService.applyToModules(tenantId, modules);
return withCategories.map((module) => ({
...module,
isActiveForTenant: flags.get(module.id)?.isActiveForTenant ?? false,
hasAccess: flags.get(module.id)?.hasAccess ?? false,
File diff suppressed because one or more lines are too long