From af1878d5ee02f13b8aef16bfe267dbdab2059ad4 Mon Sep 17 00:00:00 2001 From: Schalli Date: Sat, 3 Oct 2026 02:39:36 +0200 Subject: [PATCH] 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) --- .../custom-modules/custom-modules.module.ts | 2 + .../custom-modules.service.spec.ts | 47 ++- .../custom-modules/custom-modules.service.ts | 13 +- .../dto/custom-module.dto.spec.ts | 24 +- .../custom-modules/dto/custom-module.dto.ts | 12 +- apps/api/src/groups/groups.module.ts | 2 + .../src/groups/module-grants.controller.ts | 17 +- .../dto/module-category.dto.ts | 65 ++- .../module-categories.controller.spec.ts | 116 +++++- .../module-categories.controller.ts | 66 ++- .../module-categories.service.spec.ts | 350 +++++++++++++++- .../module-categories.service.ts | 377 +++++++++++++++++- ...ule-registry.controller.categories.spec.ts | 76 ++++ .../module-registry.controller.ts | 13 +- ...andantentrennung-zugriffsklassifikation.md | 9 +- 15 files changed, 1152 insertions(+), 37 deletions(-) create mode 100644 apps/api/src/module-registry/module-registry.controller.categories.spec.ts diff --git a/apps/api/src/custom-modules/custom-modules.module.ts b/apps/api/src/custom-modules/custom-modules.module.ts index b1fe6d0..d711bdf 100644 --- a/apps/api/src/custom-modules/custom-modules.module.ts +++ b/apps/api/src/custom-modules/custom-modules.module.ts @@ -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], }) diff --git a/apps/api/src/custom-modules/custom-modules.service.spec.ts b/apps/api/src/custom-modules/custom-modules.service.spec.ts index d4e312e..d182260 100644 --- a/apps/api/src/custom-modules/custom-modules.service.spec.ts +++ b/apps/api/src/custom-modules/custom-modules.service.spec.ts @@ -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(); diff --git a/apps/api/src/custom-modules/custom-modules.service.ts b/apps/api/src/custom-modules/custom-modules.service.ts index 90ff399..36526db 100644 --- a/apps/api/src/custom-modules/custom-modules.service.ts +++ b/apps/api/src/custom-modules/custom-modules.service.ts @@ -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(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; diff --git a/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts b/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts index c22416b..46c7495 100644 --- a/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts +++ b/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts @@ -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'); }); diff --git a/apps/api/src/custom-modules/dto/custom-module.dto.ts b/apps/api/src/custom-modules/dto/custom-module.dto.ts index 292c612..af6fe5f 100644 --- a/apps/api/src/custom-modules/dto/custom-module.dto.ts +++ b/apps/api/src/custom-modules/dto/custom-module.dto.ts @@ -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 diff --git a/apps/api/src/groups/groups.module.ts b/apps/api/src/groups/groups.module.ts index 8af4af4..00d1c11 100644 --- a/apps/api/src/groups/groups.module.ts +++ b/apps/api/src/groups/groups.module.ts @@ -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], diff --git a/apps/api/src/groups/module-grants.controller.ts b/apps/api/src/groups/module-grants.controller.ts index 2a37cf4..8a8ca6c 100644 --- a/apps/api/src/groups/module-grants.controller.ts +++ b/apps/api/src/groups/module-grants.controller.ts @@ -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 }; } /** diff --git a/apps/api/src/module-categories/dto/module-category.dto.ts b/apps/api/src/module-categories/dto/module-category.dto.ts index cfd8476..f988738 100644 --- a/apps/api/src/module-categories/dto/module-category.dto.ts +++ b/apps/api/src/module-categories/dto/module-category.dto.ts @@ -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[]; +} diff --git a/apps/api/src/module-categories/module-categories.controller.spec.ts b/apps/api/src/module-categories/module-categories.controller.spec.ts index d82cbf2..e96a96d 100644 --- a/apps/api/src/module-categories/module-categories.controller.spec.ts +++ b/apps/api/src/module-categories/module-categories.controller.spec.ts @@ -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(); + }); +}); diff --git a/apps/api/src/module-categories/module-categories.controller.ts b/apps/api/src/module-categories/module-categories.controller.ts index 4ca6484..ec25514 100644 --- a/apps/api/src/module-categories/module-categories.controller.ts +++ b/apps/api/src/module-categories/module-categories.controller.ts @@ -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); + } } diff --git a/apps/api/src/module-categories/module-categories.service.spec.ts b/apps/api/src/module-categories/module-categories.service.spec.ts index 250740d..569e104 100644 --- a/apps/api/src/module-categories/module-categories.service.spec.ts +++ b/apps/api/src/module-categories/module-categories.service.spec.ts @@ -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 = {}) { + 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'); + }); +}); diff --git a/apps/api/src/module-categories/module-categories.service.ts b/apps/api/src/module-categories/module-categories.service.ts index 00f41dd..8b7d22f 100644 --- a/apps/api/src/module-categories/module-categories.service.ts +++ b/apps/api/src/module-categories/module-categories.service.ts @@ -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 { - 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 { + 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 { + 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([ + ...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 { + 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 { + 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 { + 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 { + 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; + } } diff --git a/apps/api/src/module-registry/module-registry.controller.categories.spec.ts b/apps/api/src/module-registry/module-registry.controller.categories.spec.ts new file mode 100644 index 0000000..f8a390a --- /dev/null +++ b/apps/api/src/module-registry/module-registry.controller.categories.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/module-registry/module-registry.controller.ts b/apps/api/src/module-registry/module-registry.controller.ts index fb13a07..5feb856 100644 --- a/apps/api/src/module-registry/module-registry.controller.ts +++ b/apps/api/src/module-registry/module-registry.controller.ts @@ -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, diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index d3f8ee7..b8dbabe 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -177,11 +177,12 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | | proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) | | custom-modules | 0 | 6 | 0 | **Nachgemessen quick-260929-dzu:** 0/6/0 — persönliche Einträge je Benutzer: `create` trägt jetzt zwei Klienten in getrennten Zweigen (gemeinsam ohne Benutzer, persönlich mit Benutzer, je ein `tenantPrisma.customModule.create`), die gemeinsame Ladefunktion `loadVisible` trägt das einzige `findUnique` für `getOne`/`update`/`remove` (vorher je Methode eines): `list` 1, `create` 2, `loadVisible` 1, `update` 1, `remove` 1. Das Ergebnis ist ein Treffer weniger als bei quick-260929-9wc, obwohl der Zugriff strenger geworden ist. Vorher: **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. | +| module-categories | 4 | 22 | 0 | **quick-261003-387:** neu — `module-categories.service.ts` mit 22 gebundenen Rohtreffern, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`) oder eine Transaktion über `withTenantTransaction`: `ensure` (`moduleCategory` `findMany` ×3 und `createMany` ×2, `moduleCategoryPlacement.findMany`, `customModule.findMany`), `rename` (`moduleCategory.update`), `create` (`moduleCategory.create`), `assign` (`customModule.findFirst`; in der Transaktion `moduleCategoryPlacement.upsert`, `customModule.updateMany`), `reorderCategories` (in der Transaktion `moduleCategory.updateMany`), `reorderItems` (in der Transaktion `moduleCategoryPlacement.upsert`, `customModule.updateMany`), `remove` (in der Transaktion `moduleCategoryPlacement.upsert`, `customModule.updateMany` ×2, `moduleCategory.deleteMany`), `loadItems` (`customModule.findMany`) und die Hilfsmethode `nextSortOrder` (`moduleCategoryPlacement.findMany`, `customModule.findMany`). Die vier ungebundenen Rohtreffer sind `this.prisma.module.findMany`/`findUnique` auf dem plattformweiten Modulkatalog (`ensure`, `create`, `assign`, `loadItems`; Begründung wie `module-access.service.ts`). Kein Systemkontext. Nachgemessen mit der Gate-Schleife über `module-categories/`: 4/22/0. | | reminders | 0 | 12 | 1 | **quick-260929-if2 (Aufgabe 3):** nachgemessen mit der Gate-Schleife: 0/12/1 — +5 gebunden, +1 System. `reminders.service.ts` +1 gebunden (`getEmailAvailability`: `user.findFirst` für die eigene E-Mail-Adresse, an Mandant und Benutzer gebunden). NEU `reminder-mail.scheduler.ts`: +4 gebunden je Kandidatenzeile (`reminder.updateMany` als Anspruch, `reminder.findFirst`, `user.findFirst` für die Adresse des Besitzers, `reminder.updateMany` als Freigabe bei Transportfehler; alle über `forTenant(prisma, c.tenantId)` ohne Benutzer) und +1 System (`systemPrisma.reminder.findMany`, die Kandidatenabfrage über alle Mandanten, nur skalarer Select). Vorher: **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). | | kantine-datev | 0 | 2 | 0 | **quick-261002-fm5:** neu, zwei gebundene Rohtreffer in `kantine-datev.service.ts` (`getSettings` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. | | handelsware-datev | 0 | 8 | 0 | **quick-261002-fm5:** neu, acht gebundene Rohtreffer über `tenantPrisma` in `handelsware-datev.service.ts` (`handelswareDatevConfig` 2, `handelswareKonto` 6), nachgemessen mit der Gate-Schleife: 0/8/0. Dazu fünf Zugriffe über den Transaktionsparameter `tx` von `withTenantTransaction` (`handelswareDatevConfig` 1, `handelswareKonto` 4), die diese einfache Rohtrefferzählung strukturell nicht sieht (siehe Hinweis zu `groups` oben) — die Bestandsaufnahme unten führt sie. Kein ungebundener Zugriff, kein Systemkontext. | | nextcloud-status | 0 | 23 | 1 | **quick-261002-kxc (Aufgabe 3, Endstand):** 21→23 gebunden — `listRecentAlerts` in `nextcloud-alert.service.ts` liest die Abonnements des Aufrufers (`nextcloudAlertSubscription.findMany`, Klient mit Mandant UND Benutzer) und dazu die letzten Übergänge dieser Clouds (`nextcloudInstance.findMany`, nur skalare Felder, `where: { tenantId, id: { in } }`); System unverändert 1. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/23/1. Vorher: **quick-261002-kxc (Aufgabe 1):** 14→21 gebunden, System unverändert 1 — NEU `nextcloud-alert.service.ts` mit sieben gebundenen Rohtreffern (`subscribe`: `nextcloudInstance.findFirst`, `nextcloudAlertSubscription.upsert`; `unsubscribe`: `deleteMany`; `subscribedInstanceIds`: `findMany`; `evaluateAfterCheck`: `nextcloudInstance.updateMany`; `notifySubscribers`: `nextcloudAlertSubscription.findMany`, `user.findMany`), je Methode ein Klient; `nextcloud-status.service.ts` unverändert bei 14. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/21/1. Vorher: **quick-261002-k67 (Aufgabe 2, Endstand):** 4→14 gebunden, 0→1 System — Schreibwege und Planer: `updateInstance` (`findFirst`, `update`), `deleteInstance` (`findFirst`, `delete`), `uploadLogo` (`findFirst`, `update`), `getLogo` (`findFirst`), `removeLogo` (`findFirst`, `update`), `listInstanceIdsForTenant` (`findMany`) bringen zehn weitere gebundene Rohtreffer, je Methode ein Klient. Der eine System-Rohtreffer ist `loadAllInstancesForScheduler()` (`systemPrisma.nextcloudInstance.findMany`, nur `select: { id, tenantId }`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Nachgemessen mit der Gate-Schleife: 0/14/1. Vorher: **quick-261002-k67 (Aufgabe 1):** neu, vier gebundene Rohtreffer in `nextcloud-status.service.ts` (`listForTenant` `findMany`, `createInstance` `create`, `checkInstance` `findFirst` und `update`). | -| **Summe** | **61** | **273** | **8** | **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| **Summe** | **65** | **295** | **8** | **quick-261003-387:** Ungebunden +4 und Gebunden +22 = `module-categories` (neu, siehe dortige Zeile), System unverändert: 65/295/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor, die Differenz zur vorigen Messung ist genau +4/+22. Vorher: **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare) @@ -409,6 +410,12 @@ quick-261002-kxc (Aufgabe 1): +3 `muss-mandantengebunden` (`nextcloud-alert.serv 22 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. +quick-261003-387: +3 `muss-mandantengebunden` (`module-categories.service.ts`/`moduleCategory`, `/moduleCategoryPlacement` +und `/customModule`, alle `gebunden`) und +1 `keine-mandantengebundene-tabelle` +(`module-categories.service.ts`/`module`, `ungebunden`): 100 Paare, davon 59 `muss-mandantengebunden`, +23 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit +`grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. + quick-261002-kxc (Aufgabe 3): Paarzahl unverändert (96); `listRecentAlerts` nutzt dieselben Paare `nextcloud-alert.service.ts`/`nextcloudAlertSubscription` und `/nextcloudInstance`.