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:
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user