feat(quick-261003-387): Modulkategorien je Organisation - Tabellen, Grundbestand, Lese-Endpunkt bis in die Seitenleiste

- Tabellen ModuleCategory und ModuleCategoryPlacement mit Zeilenschutz, Spalte CustomModule.sortOrder
- Dienst legt den Grundbestand beim ersten Lesen an, legt die wirksame Kategorie ueber Modullisten
- GET /module-categories, PATCH :key (nur Administratoren), /modules/active liefert wirksame Kategorie
- Seitenleiste ordnet Gruppen und Eintraege nach Kategorienspeicher, zeigt umbenannte Namen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 02:33:59 +02:00
parent 53a49a109f
commit 8ec116c75a
21 changed files with 1052 additions and 16 deletions
+2
View File
@@ -31,6 +31,7 @@ import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
import { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
import { ModuleCategoriesModule } from './module-categories/module-categories.module';
import { RemindersModule } from './reminders/reminders.module';
@Module({
@@ -62,6 +63,7 @@ import { RemindersModule } from './reminders/reminders.module';
KantineDatevModule,
HandelswareDatevModule,
CustomModulesModule,
ModuleCategoriesModule,
RemindersModule,
],
providers: [
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
const trimString = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** Neuer Anzeigename einer Kategorie (die Kennung bleibt unveraendert). */
export class RenameModuleCategoryDto {
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(60)
name!: string;
}
@@ -0,0 +1,42 @@
import 'reflect-metadata';
import { ForbiddenException } 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 { ModuleCategoriesController } from './module-categories.controller';
const proto = ModuleCategoriesController.prototype as any;
const ADMIN_ONLY = [Role.ADMIN, Role.SUPER_ADMIN];
describe('ModuleCategoriesController — Rollen (T-387-01)', () => {
it('haengt an Pfad module-categories', () => {
expect(Reflect.getMetadata('path', ModuleCategoriesController)).toBe('module-categories');
});
it('GET list traegt keine Rolle (jeder Angemeldete)', () => {
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);
});
});
describe('ModuleCategoriesController — Organisation', () => {
it('reicht die Organisation aus der Anfrage weiter', async () => {
const service = { listCategories: vi.fn(async () => []), rename: vi.fn(async () => ({})) };
const controller = new ModuleCategoriesController(service as any);
await controller.list({ tenantId: 't1' } as any);
await controller.rename({ tenantId: 't1' } as any, 'fleet', { name: 'X' });
expect(service.listCategories).toHaveBeenCalledWith('t1');
expect(service.rename).toHaveBeenCalledWith('t1', 'fleet', 'X');
});
it('ohne Organisation -> 403', async () => {
const controller = new ModuleCategoriesController({} as any);
await expect(controller.list({} as any)).rejects.toBeInstanceOf(ForbiddenException);
});
});
@@ -0,0 +1,58 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
Patch,
Req,
UseGuards,
} from '@nestjs/common';
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 { ModuleCategoriesService } from './module-categories.service';
/**
* Modulkategorien (quick-261003-387).
*
* - 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.
*
* ROUTEN-REIHENFOLGE: statische Routen (overview, order, assignment) MUESSEN
* vor den Routen mit `:key` stehen, sonst faengt `:key` sie ab (404-
* Shadowing, NestJS bildet in Deklarationsreihenfolge ab). Der Controller-
* Test haelt das fest.
*/
@Controller('module-categories')
export class ModuleCategoriesController {
constructor(private readonly service: ModuleCategoriesService) {}
private tenantIdOf(req: AuthenticatedRequest): string {
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Organisationskontext');
}
return tenantId;
}
@Get()
async list(@Req() req: AuthenticatedRequest) {
return this.service.listCategories(this.tenantIdOf(req));
}
@Patch(':key')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async rename(
@Req() req: AuthenticatedRequest,
@Param('key') key: string,
@Body() dto: RenameModuleCategoryDto,
) {
return this.service.rename(this.tenantIdOf(req), key, dto.name);
}
}
@@ -0,0 +1,138 @@
import { vi } from 'vitest';
/**
* Speicher-Attrappe fuer ModuleCategoriesService-Tests: bildet die wenigen
* Prisma-Aufrufe des Dienstes mit einfachen Gleichheits-/`in`-Filtern nach.
* Kein Produktionscode (nur von *.spec.ts importiert).
*/
// biome-ignore lint/suspicious/noExplicitAny: Attrappe fuer beliebige Tabellenzeilen
type Row = Record<string, any>;
function matches(row: Row, where: Row = {}): boolean {
return Object.entries(where).every(([field, cond]) => {
if (field === 'tenantId_moduleId') {
return row.tenantId === cond.tenantId && row.moduleId === cond.moduleId;
}
if (cond && typeof cond === 'object' && !(cond instanceof Date)) {
if ('in' in cond) return (cond.in as unknown[]).includes(row[field]);
if ('not' in cond) return row[field] !== cond.not;
}
return row[field] === cond;
});
}
function sortBy(list: Row[], orderBy?: Row | Row[]): Row[] {
const orders = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : [];
return [...list].sort((a, b) => {
for (const order of orders) {
const [field, dir] = Object.entries(order)[0] as [string, 'asc' | 'desc'];
if (a[field] === b[field]) continue;
const cmp = a[field] < b[field] ? -1 : 1;
return dir === 'desc' ? -cmp : cmp;
}
return 0;
});
}
function table(initial: Row[] = [], idPrefix = 'r') {
const rows: Row[] = [...initial];
let seq = 0;
const api = {
rows,
findMany: vi.fn(
async (args: { where?: Row; orderBy?: Row | Row[]; distinct?: string[] } = {}) => {
let list = sortBy(
rows.filter((r) => matches(r, args.where)),
args.orderBy,
);
const distinct = args.distinct;
if (distinct) {
const seen = new Set<string>();
list = list.filter((r) => {
const k = distinct.map((f) => r[f]).join('|');
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
return list.map((r) => ({ ...r }));
},
),
findFirst: vi.fn(async (args: { where?: Row } = {}) => {
const r = rows.find((row) => matches(row, args.where));
return r ? { ...r } : null;
}),
findUnique: vi.fn(async (args: { where: Row }) => {
const r = rows.find((row) => matches(row, args.where));
return r ? { ...r } : null;
}),
create: vi.fn(async ({ data }: { data: Row }) => {
const row = { id: `${idPrefix}-${++seq}`, ...data };
rows.push(row);
return { ...row };
}),
createMany: vi.fn(async ({ data }: { data: Row[]; skipDuplicates?: boolean }) => {
let count = 0;
for (const d of data) {
const dup = rows.some(
(r) =>
r.tenantId === d.tenantId &&
(d.key !== undefined ? r.key === d.key : r.moduleId === d.moduleId),
);
if (dup) continue;
rows.push({ id: `${idPrefix}-${++seq}`, ...d });
count++;
}
return { count };
}),
update: vi.fn(async ({ where, data }: { where: Row; data: Row }) => {
const r = rows.find((row) => matches(row, where));
if (!r) throw new Error('P2025');
Object.assign(r, data);
return { ...r };
}),
updateMany: vi.fn(async ({ where, data }: { where: Row; data: Row }) => {
const hit = rows.filter((row) => matches(row, where));
for (const r of hit) Object.assign(r, data);
return { count: hit.length };
}),
upsert: vi.fn(async ({ where, create, update }: { where: Row; create: Row; update: Row }) => {
const r = rows.find((row) => matches(row, where));
if (r) {
Object.assign(r, update);
return { ...r };
}
const row = { id: `${idPrefix}-${++seq}`, ...create };
rows.push(row);
return { ...row };
}),
delete: vi.fn(async ({ where }: { where: Row }) => {
const i = rows.findIndex((row) => matches(row, where));
if (i < 0) throw new Error('P2025');
const [gone] = rows.splice(i, 1);
return gone;
}),
deleteMany: vi.fn(async ({ where }: { where: Row }) => {
let count = 0;
for (let i = rows.length - 1; i >= 0; i--) {
if (matches(rows[i], where)) {
rows.splice(i, 1);
count++;
}
}
return { count };
}),
};
return api;
}
export function makeFakePrisma(
seed: { modules?: Row[]; placements?: Row[]; customModules?: Row[]; categories?: Row[] } = {},
) {
return {
module: table(seed.modules ?? [], 'm'),
moduleCategory: table(seed.categories ?? [], 'c'),
moduleCategoryPlacement: table(seed.placements ?? [], 'p'),
customModule: table(seed.customModules ?? [], 'cm'),
};
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesController } from './module-categories.controller';
import { ModuleCategoriesService } from './module-categories.service';
/**
* Modulkategorien je Organisation (quick-261003-387). `PrismaModule` ist
* global. Der Dienst wird exportiert, weil Modulliste, Freigaben-Matrix und
* eigene Module die wirksame Kategorie darueberlegen bzw. pruefen.
*/
@Module({
controllers: [ModuleCategoriesController],
providers: [ModuleCategoriesService],
exports: [ModuleCategoriesService],
})
export class ModuleCategoriesModule {}
@@ -0,0 +1,166 @@
import { NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
// `forTenant`/`withTenantTransaction` reichen den Klienten durch — die
// Mandantenbindung selbst prueft rls-access-inventory.spec.ts.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
withTenantTransaction: vi.fn(async (p: unknown, _t: string, fn: (tx: unknown) => unknown) =>
fn(p),
),
}));
import { makeFakePrisma } from './module-categories.fake-prisma';
import { ModuleCategoriesService } from './module-categories.service';
const DEFAULT_KEYS = [
'domain-tools',
'security-tools',
'fleet',
'infrastructure',
'procurement',
'accounting',
'custom-modules',
];
function setup(seed: Parameters<typeof makeFakePrisma>[0] = {}) {
const prisma = makeFakePrisma(seed);
return { prisma, service: new ModuleCategoriesService(prisma as any) };
}
describe('ModuleCategoriesService — Grundbestand (E-04)', () => {
it('legt fuer eine frische Organisation sieben Standardkategorien in Standardreihenfolge an', async () => {
const { service } = setup();
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).toEqual(DEFAULT_KEYS);
expect(list.every((c) => c.name === null)).toBe(true);
expect(list.map((c) => c.sortOrder)).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(list.filter((c) => c.isSystem).map((c) => c.key)).toEqual(['custom-modules']);
});
it('legt ein zweites Mal nichts doppelt an', async () => {
const { prisma, service } = setup();
await service.listCategories('t1');
await service.listCategories('t1');
expect(prisma.moduleCategory.rows).toHaveLength(7);
});
it('haengt eine bisher unbekannte, wirksam benutzte Kennung hinten an', async () => {
const { service } = setup({
modules: [{ id: 'm1', slug: 'neu', name: 'Neu', category: 'brandneu' }],
});
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).toEqual([...DEFAULT_KEYS, 'brandneu']);
expect(list[7].sortOrder).toBe(7);
});
it('nimmt Kennungen eigener Module (auch persoenlicher) auf', async () => {
const { service } = setup({
customModules: [
{ id: 'c1', tenantId: 't1', category: 'altlast', ownerUserId: 'u1', name: 'x' },
],
});
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).toContain('altlast');
});
it('nutzt die Zuordnung statt der Manifest-Kategorie fuer den Nachschub', async () => {
const { service } = setup({
modules: [{ id: 'm1', slug: 'a', name: 'A', category: 'fleet' }],
placements: [
{ id: 'p1', tenantId: 't1', moduleId: 'm1', categoryKey: 'zugeordnet', sortOrder: 0 },
],
categories: [
{
id: 'c1',
tenantId: 't1',
key: 'custom-modules',
name: null,
sortOrder: 0,
isSystem: true,
},
],
});
const list = await service.listCategories('t1');
// „fleet“ wird von keinem Modul wirksam benutzt und kommt nicht zurueck.
expect(list.map((c) => c.key)).toEqual(['custom-modules', 'zugeordnet']);
});
it('trennt Organisationen: t2 sieht die Zeilen von t1 nicht', async () => {
const { prisma, service } = setup();
await service.listCategories('t1');
await service.listCategories('t2');
expect(prisma.moduleCategory.rows.filter((r) => r.tenantId === 't2')).toHaveLength(7);
expect(prisma.moduleCategory.rows).toHaveLength(14);
});
});
describe('ModuleCategoriesService — applyToModules (E-07)', () => {
const modules = [
{ id: 'a', slug: 'a', name: 'Zeta', category: 'fleet' },
{ id: 'b', slug: 'b', name: 'Alpha', category: 'fleet' },
{ id: 'c', slug: 'c', name: 'Mitte', category: 'domain-tools' },
];
it('sortiert nach Kategoriereihenfolge, dann Name', async () => {
const { service } = setup({ modules });
const out = await service.applyToModules('t1', modules);
expect(out.map((m) => m.id)).toEqual(['c', 'b', 'a']);
expect(out[0].sortOrder).toBeNull();
});
it('Zuordnung schlaegt Manifest, aendert aber die Eingabe nicht', async () => {
const { service } = setup({
modules,
placements: [
{ id: 'p', tenantId: 't1', moduleId: 'c', categoryKey: 'accounting', sortOrder: 3 },
],
});
const out = await service.applyToModules('t1', modules);
expect(out.find((m) => m.id === 'c')).toMatchObject({ category: 'accounting', sortOrder: 3 });
expect(modules[2].category).toBe('domain-tools');
});
it('sortOrder aufsteigend, null zuletzt', async () => {
const { service } = setup({
modules,
placements: [{ id: 'p1', tenantId: 't1', moduleId: 'a', categoryKey: 'fleet', sortOrder: 0 }],
});
const out = await service.applyToModules('t1', modules);
expect(out.filter((m) => m.category === 'fleet').map((m) => m.id)).toEqual(['a', 'b']);
});
it('eine Zuordnung zu geloeschter Kategorie legt die Kennung wieder an und bleibt sortierbar', async () => {
const { service } = setup({
modules,
placements: [{ id: 'p', tenantId: 't1', moduleId: 'a', categoryKey: 'weg', sortOrder: 0 }],
});
const out = await service.applyToModules('t1', modules);
expect(out.find((m) => m.id === 'a')?.category).toBe('weg');
});
});
describe('ModuleCategoriesService — umbenennen', () => {
it('speichert den getrimmten Namen, Kennung bleibt', async () => {
const { service } = setup();
const res = await service.rename('t1', 'fleet', ' Fuhrpark ');
expect(res).toMatchObject({ key: 'fleet', name: 'Fuhrpark' });
});
it('erlaubt „Eigene Module“', async () => {
const { service } = setup();
const res = await service.rename('t1', 'custom-modules', 'Meine Sachen');
expect(res.name).toBe('Meine Sachen');
});
it('unbekannte Kennung -> 404', async () => {
const { service } = setup();
await expect(service.rename('t1', 'gibtsnicht', 'X')).rejects.toBeInstanceOf(NotFoundException);
});
it('leerer oder zu langer Name -> 400', async () => {
const { service } = setup();
await expect(service.rename('t1', 'fleet', ' ')).rejects.toThrow('1 bis 60');
await expect(service.rename('t1', 'fleet', 'x'.repeat(61))).rejects.toThrow('1 bis 60');
});
});
@@ -0,0 +1,203 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/** Eine Kategorie, wie sie GET /module-categories liefert. */
export interface ModuleCategoryRow {
id: string;
key: string;
name: string | null;
sortOrder: number;
isSystem: boolean;
}
/** Zuordnung eines Marktplatz-Moduls (nur die Felder, die der Dienst braucht). */
interface PlacementRow {
moduleId: string;
categoryKey: string;
sortOrder: number;
}
/** Grundbestand und Zuordnungen einer Organisation nach `ensure`. */
export interface CategoryState {
categories: ModuleCategoryRow[];
placements: PlacementRow[];
}
const CATEGORY_SELECT = {
id: true,
key: true,
name: true,
sortOrder: true,
isSystem: true,
} as const;
/**
* Modulkategorien je Organisation (quick-261003-387).
*
* Modell (Entscheidungen E-01 bis E-08 aus dem Plan):
* - `ModuleCategory` — Kategorien der Organisation; `key` ist unveraenderlich
* (URL-Segment), `name` null = Uebersetzung `moduleCategories.<key>`.
* - `ModuleCategoryPlacement` — Zuordnung eines Marktplatz-Moduls. Die
* wirksame Kategorie ist die Zuordnung, sonst `Module.category`; die Spalte
* `Module.category` wird nie geaendert (sie gilt fuer alle Organisationen).
* - Gemeinsame eigene Module tragen ihre Kategorie und Reihenfolge direkt
* (`CustomModule.category`, `CustomModule.sortOrder`).
*
* Der Grundbestand wird ohne SQL-Rueckfuellung beim ersten Lesen angelegt
* (`ensure`): die Standardkategorien in der Reihenfolge von
* CUSTOM_MODULE_CATEGORIES, danach hinten angehaengt jede wirksam benutzte
* Kennung, die noch fehlt.
*
* RLS-BINDUNG: je Methode ein eigener `forTenant`-Klient, zusaetzlich steht
* `tenantId` in jedem `where` (Anwendungspruefung, solange der RLS-Schalter
* aus ist). Der globale Modulkatalog wird ueber den ungebundenen Klienten
* gelesen — dieselbe Begruendung wie in `module-access.service.ts`: die Tabelle
* "Module" traegt heute keinen Zeilenschutz, eine Bindung waere heute
* wirkungslos; sie wuerde erst katastrophal, WENN diese Tabelle eine Regel
* bekaeme (dann verschwaende der Katalog fuer jede Organisation). Diese
* Bedingung steht hier als Bedingung, nicht als heute beobachtbare Tatsache.
*/
@Injectable()
export class ModuleCategoriesService {
constructor(private readonly prisma: PrismaService) {}
/**
* Grundbestand sicherstellen und Zustand liefern (E-04). Legt beim ersten
* Lesen die Standardkategorien an und ergaenzt jede wirksam benutzte
* Kennung (Manifest-Kategorie eines Moduls ohne Zuordnung, Kategorie
* eigener Module), die noch fehlt, hinten. Eine geloeschte Standardkategorie
* kommt nur zurueck, wenn ein Modul sie wirksam benutzt.
*/
async ensure(tenantId: string): Promise<CategoryState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
let categories: ModuleCategoryRow[] = await tenantPrisma.moduleCategory.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { key: 'asc' }],
select: CATEGORY_SELECT,
});
if (categories.length === 0) {
await tenantPrisma.moduleCategory.createMany({
data: CUSTOM_MODULE_CATEGORIES.map((key, index) => ({
tenantId,
key,
name: null,
sortOrder: index,
isSystem: key === CUSTOM_MODULE_CATEGORY,
})),
skipDuplicates: true,
});
categories = await tenantPrisma.moduleCategory.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { key: 'asc' }],
select: CATEGORY_SELECT,
});
}
const placements: PlacementRow[] = await tenantPrisma.moduleCategoryPlacement.findMany({
where: { tenantId },
select: { moduleId: true, categoryKey: true, sortOrder: true },
});
const modules = await this.prisma.module.findMany({
select: { id: true, category: true },
});
const customRows = await tenantPrisma.customModule.findMany({
where: { tenantId },
select: { category: true },
distinct: ['category'],
});
const placementByModule = new Map(placements.map((p) => [p.moduleId, p.categoryKey]));
const used = new Set<string>();
for (const m of modules) used.add(placementByModule.get(m.id) ?? m.category);
for (const c of customRows) used.add(c.category);
const known = new Set(categories.map((c) => c.key));
const missing = [...used].filter((key) => !known.has(key)).sort();
if (missing.length > 0) {
const base = categories.reduce((max, c) => Math.max(max, c.sortOrder), -1) + 1;
await tenantPrisma.moduleCategory.createMany({
data: missing.map((key, index) => ({
tenantId,
key,
name: null,
sortOrder: base + index,
isSystem: false,
})),
skipDuplicates: true,
});
categories = await tenantPrisma.moduleCategory.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { key: 'asc' }],
select: CATEGORY_SELECT,
});
}
return { categories, placements };
}
/** Alle Kategorien der Organisation in ihrer Reihenfolge. */
async listCategories(tenantId: string): Promise<ModuleCategoryRow[]> {
const { categories } = await this.ensure(tenantId);
return categories;
}
/**
* Wirksame Kategorie und Reihenfolge ueber eine Modulliste legen (E-07):
* jedes Modul bekommt `category` = wirksame Kennung (Zuordnung schlaegt
* Manifest) und `sortOrder` (Zahl oder null). Sortiert nach Reihenfolge
* der Kategorie, dann `sortOrder` (null zuletzt), dann Name. Eine
* unbekannte Kategorie sortiert zuletzt.
*/
async applyToModules<T extends { id: string; category: string; name: string }>(
tenantId: string,
modules: T[],
): Promise<Array<Omit<T, 'category'> & { category: string; sortOrder: number | null }>> {
const { categories, placements } = await this.ensure(tenantId);
const rank = new Map(categories.map((c, index) => [c.key, index]));
const placementByModule = new Map(placements.map((p) => [p.moduleId, p]));
const result = modules.map((module) => {
const placement = placementByModule.get(module.id);
return {
...module,
category: placement?.categoryKey ?? module.category,
sortOrder: placement?.sortOrder ?? null,
};
});
return result.sort((a, b) => {
const ra = rank.get(a.category) ?? Number.MAX_SAFE_INTEGER;
const rb = rank.get(b.category) ?? Number.MAX_SAFE_INTEGER;
if (ra !== rb) return ra - rb;
if (a.sortOrder !== b.sortOrder) {
if (a.sortOrder === null) return 1;
if (b.sortOrder === null) return -1;
return a.sortOrder - b.sortOrder;
}
return a.name.localeCompare(b.name);
});
}
/** 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 { categories } = await this.ensure(tenantId);
const existing = categories.find((c) => c.key === key);
if (!existing) {
throw new NotFoundException('Kategorie nicht gefunden');
}
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.moduleCategory.update({
where: { id: existing.id, tenantId },
data: { name: trimmed },
select: CATEGORY_SELECT,
});
}
}
@@ -11,6 +11,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 { ModuleAccessService } from './module-access.service';
import { ModuleRegistryService } from './module-registry.service';
@@ -32,6 +33,7 @@ export class ModuleRegistryController {
constructor(
private readonly moduleRegistryService: ModuleRegistryService,
private readonly moduleAccessService: ModuleAccessService,
private readonly moduleCategoriesService: ModuleCategoriesService,
) {}
/**
@@ -60,7 +62,10 @@ export class ModuleRegistryController {
if (!tenantId || !userId || !role) {
throw new ForbiddenException('No user context');
}
return this.moduleAccessService.findAccessibleModules(tenantId, userId, role);
const modules = await this.moduleAccessService.findAccessibleModules(tenantId, userId, role);
// quick-261003-387: wirksame Kategorie + Reihenfolge der Organisation
// (Zuordnung schlaegt Manifest-Kategorie), sortiert fuer die Seitenleiste.
return this.moduleCategoriesService.applyToModules(tenantId, modules);
}
/**
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
import { ModuleAccessService } from './module-access.service';
import { ModuleRegistryController } from './module-registry.controller';
import { ModuleRegistryService } from './module-registry.service';
@@ -19,6 +20,7 @@ import { ModuleGuard } from './module.guard';
* Plan 15-05, Grant-Services Plan 15-03) can inject and use them.
*/
@Module({
imports: [ModuleCategoriesModule],
controllers: [ModuleRegistryController],
providers: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
exports: [ModuleRegistryService, ModuleAccessService, ModuleGuard],