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
@@ -0,0 +1,89 @@
-- quick-261003-387 — Modulkategorien durch Administratoren bearbeitbar.
--
-- Zweck: zwei neue Tabellen und eine neue Spalte.
-- * "ModuleCategory": die Kategorien einer Organisation (Kennung, optionaler
-- eigener Name, Reihenfolge, Systemkennzeichen fuer "Eigene Module").
-- Die Kennung ist unveraenderlich, sie steht als URL-Segment in
-- /modules/<kennung>/<slug>.
-- * "ModuleCategoryPlacement": Zuordnung eines Marktplatz-Moduls zu einer
-- Kategorie der Organisation samt Reihenfolge. Die Spalte "Module"."category"
-- (fuer alle Organisationen gleich) bleibt unveraendert; die wirksame
-- Kategorie ist die Zuordnung, sonst diese Spalte.
-- * "CustomModule"."sortOrder": Reihenfolge gemeinsamer eigener Module
-- innerhalb ihrer Kategorie (NULL = noch nicht sortiert).
-- Es gibt keine Rueckfuellung in SQL: der Dienst legt den Grundbestand je
-- Organisation beim ersten Lesen an.
--
-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Kategorien sind gemeinsame Einstellungen der Organisation, nicht
-- persoenliche Daten eines Benutzers. Keine `system_read_policy`: es gibt
-- keinen Hintergrunddienst, der darueber liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- AlterTable
ALTER TABLE "CustomModule" ADD COLUMN "sortOrder" INTEGER;
-- CreateTable
CREATE TABLE "ModuleCategory" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModuleCategoryPlacement" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"moduleId" TEXT NOT NULL,
"categoryKey" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategoryPlacement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ModuleCategory_tenantId_idx" ON "ModuleCategory"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategory_tenantId_key_key" ON "ModuleCategory"("tenantId", "key");
-- CreateIndex
CREATE INDEX "ModuleCategoryPlacement_tenantId_idx" ON "ModuleCategoryPlacement"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategoryPlacement_tenantId_moduleId_key" ON "ModuleCategoryPlacement"("tenantId", "moduleId");
-- AddForeignKey
ALTER TABLE "ModuleCategoryPlacement" ADD CONSTRAINT "ModuleCategoryPlacement_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz
ALTER TABLE "ModuleCategory" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategory" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategory"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "ModuleCategoryPlacement" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategoryPlacement" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategoryPlacement"
USING ("tenantId" = current_tenant_id());
+42 -1
View File
@@ -120,6 +120,7 @@ model Module {
updatedAt DateTime @updatedAt
activations TenantModuleActivation[]
grants ModuleGrant[]
categoryPlacements ModuleCategoryPlacement[]
}
model TenantModuleActivation {
@@ -849,7 +850,11 @@ model CustomModule {
tenantId String
name String
url String
category String // eine der MODULE_CATEGORIES aus @tessera/shared
category String // Kennung einer ModuleCategory der Organisation
// quick-261003-387: Reihenfolge innerhalb der Kategorie (nur gemeinsame
// Eintraege; null = noch nicht sortiert, steht hinten). Persoenliche
// Eintraege bekommen nie eine sortOrder.
sortOrder Int?
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
@@ -906,3 +911,39 @@ model WelcomeMailTemplate {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// quick-261003-387: Modulkategorien je Organisation, durch Administratoren
// pflegbar. `key` ist unveraenderlich (URL-Segment /modules/<key>/<slug>),
// `name` null = Uebersetzung moduleCategories.<key>. isSystem nur fuer
// "custom-modules" (Eigene Module): umbenennbar und verschiebbar, nicht
// loeschbar.
model ModuleCategory {
id String @id @default(uuid())
tenantId String
key String
name String?
sortOrder Int @default(0)
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, key])
@@index([tenantId])
}
// quick-261003-387: Zuordnung eines Marktplatz-Moduls zu einer Kategorie der
// Organisation samt Reihenfolge. Wirksame Kategorie = Zuordnung, sonst
// Module.category (die Spalte selbst bleibt fuer alle gleich).
model ModuleCategoryPlacement {
id String @id @default(uuid())
tenantId String
moduleId String
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
categoryKey String
sortOrder Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, moduleId])
@@index([tenantId])
}
+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],