diff --git a/apps/api/prisma/migrations/20261003120000_module_categories/migration.sql b/apps/api/prisma/migrations/20261003120000_module_categories/migration.sql new file mode 100644 index 0000000..7a13c31 --- /dev/null +++ b/apps/api/prisma/migrations/20261003120000_module_categories/migration.sql @@ -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//. +-- * "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()); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 40add73..b761698 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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//), +// `name` null = Uebersetzung moduleCategories.. 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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 67d1913..a683578 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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: [ diff --git a/apps/api/src/module-categories/dto/module-category.dto.ts b/apps/api/src/module-categories/dto/module-category.dto.ts new file mode 100644 index 0000000..cfd8476 --- /dev/null +++ b/apps/api/src/module-categories/dto/module-category.dto.ts @@ -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; +} diff --git a/apps/api/src/module-categories/module-categories.controller.spec.ts b/apps/api/src/module-categories/module-categories.controller.spec.ts new file mode 100644 index 0000000..d82cbf2 --- /dev/null +++ b/apps/api/src/module-categories/module-categories.controller.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/module-categories/module-categories.controller.ts b/apps/api/src/module-categories/module-categories.controller.ts new file mode 100644 index 0000000..4ca6484 --- /dev/null +++ b/apps/api/src/module-categories/module-categories.controller.ts @@ -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); + } +} diff --git a/apps/api/src/module-categories/module-categories.fake-prisma.ts b/apps/api/src/module-categories/module-categories.fake-prisma.ts new file mode 100644 index 0000000..11fc46c --- /dev/null +++ b/apps/api/src/module-categories/module-categories.fake-prisma.ts @@ -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; + +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(); + 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'), + }; +} diff --git a/apps/api/src/module-categories/module-categories.module.ts b/apps/api/src/module-categories/module-categories.module.ts new file mode 100644 index 0000000..4792388 --- /dev/null +++ b/apps/api/src/module-categories/module-categories.module.ts @@ -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 {} diff --git a/apps/api/src/module-categories/module-categories.service.spec.ts b/apps/api/src/module-categories/module-categories.service.spec.ts new file mode 100644 index 0000000..250740d --- /dev/null +++ b/apps/api/src/module-categories/module-categories.service.spec.ts @@ -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[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'); + }); +}); diff --git a/apps/api/src/module-categories/module-categories.service.ts b/apps/api/src/module-categories/module-categories.service.ts new file mode 100644 index 0000000..00f41dd --- /dev/null +++ b/apps/api/src/module-categories/module-categories.service.ts @@ -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.`. + * - `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 { + 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(); + 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 { + 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( + tenantId: string, + modules: T[], + ): Promise & { 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 { + 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, + }); + } +} diff --git a/apps/api/src/module-registry/module-registry.controller.ts b/apps/api/src/module-registry/module-registry.controller.ts index 62c9b2d..fb13a07 100644 --- a/apps/api/src/module-registry/module-registry.controller.ts +++ b/apps/api/src/module-registry/module-registry.controller.ts @@ -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); } /** diff --git a/apps/api/src/module-registry/module-registry.module.ts b/apps/api/src/module-registry/module-registry.module.ts index e2c38b7..4058141 100644 --- a/apps/api/src/module-registry/module-registry.module.ts +++ b/apps/api/src/module-registry/module-registry.module.ts @@ -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], diff --git a/apps/web/src/components/layout/sidebar.test.tsx b/apps/web/src/components/layout/sidebar.test.tsx index 53d5496..a261618 100644 --- a/apps/web/src/components/layout/sidebar.test.tsx +++ b/apps/web/src/components/layout/sidebar.test.tsx @@ -1,6 +1,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useModuleCategoryStore } from '@/lib/stores/module-category-store'; let mockPathname = '/'; vi.mock('next/navigation', () => ({ @@ -95,6 +96,7 @@ let mockCustomModules: { name: string; url: string; category: string; + sortOrder?: number | null; createdAt: string; updatedAt: string; }[] = []; @@ -102,10 +104,41 @@ vi.mock('@/lib/custom-modules-api', () => ({ listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)), })); -const mockActiveModules = [ +// Kategorien (quick-261003-387): eigener Client; Standard = die Kategorien der +// Testdaten, „Eigene Module“ zuletzt. Der Kategorienspeicher ist echt. +interface MockCategory { + id: string; + key: string; + name: string | null; + sortOrder: number; + isSystem: boolean; +} +function categoriesFor(keys: string[], names: Record = {}): MockCategory[] { + return keys.map((key, index) => ({ + id: `c-${key}`, + key, + name: names[key] ?? null, + sortOrder: index, + isSystem: key === 'custom-modules', + })); +} +const DEFAULT_CATEGORY_KEYS = ['Domain-Tools', 'Utilities', 'infrastructure', 'fleet', 'custom-modules']; +let mockCategories: MockCategory[] = categoriesFor(DEFAULT_CATEGORY_KEYS); +vi.mock('@/lib/module-categories-api', () => ({ + listModuleCategories: vi.fn(() => Promise.resolve(mockCategories)), +})); + +let mockActiveModules: { + id: string; + slug: string; + name: string; + category: string; + sortOrder?: number | null; +}[] = [ { id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' }, { id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' }, ]; +const DEFAULT_ACTIVE_MODULES = [...mockActiveModules]; function stubFetch() { vi.stubGlobal( @@ -121,6 +154,9 @@ afterEach(() => { mockRefreshKey = 0; mockCustomModules = []; mockCollapsed = false; + mockCategories = categoriesFor(DEFAULT_CATEGORY_KEYS); + mockActiveModules = [...DEFAULT_ACTIVE_MODULES]; + useModuleCategoryStore.setState({ categories: [], loaded: false }); }); function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) { @@ -442,6 +478,67 @@ describe('Sidebar', () => { expect(screen.getByText('Wiki')).toBeInTheDocument(); }); + it('Gruppen folgen der Reihenfolge aus dem Kategorienspeicher', async () => { + mockCategories = categoriesFor(['Utilities', 'Domain-Tools', 'custom-modules']); + const Sidebar = await importSidebar(); + render(); + + await waitFor(() => { + expect(screen.getByText('Domaincheck')).toBeInTheDocument(); + }); + const groups = () => + Array.from( + screen + .getByRole('group', { name: 'Kategorien' }) + .querySelectorAll(':scope > ul > li > button span.truncate'), + ).map((n) => n.textContent); + await waitFor(() => { + expect(groups()).toEqual(['Utilities', 'Domain-Tools']); + }); + }); + + it('eine umbenannte Kategorie zeigt den gespeicherten Namen', async () => { + mockCategories = categoriesFor(DEFAULT_CATEGORY_KEYS, { 'Domain-Tools': 'Meine Domains' }); + const Sidebar = await importSidebar(); + render(); + + await waitFor(() => { + expect(screen.getByText('Meine Domains')).toBeInTheDocument(); + }); + expect(screen.queryByText('Domain-Tools')).not.toBeInTheDocument(); + // Eine nicht umbenannte Kategorie behaelt ihre Beschriftung. + expect(screen.getByText('Utilities')).toBeInTheDocument(); + }); + + it('Eintraege innerhalb einer Gruppe folgen sortOrder, null zuletzt, eingebaut vor eigenem', async () => { + mockActiveModules = [ + { id: 'a', slug: 'aaa', name: 'Aaa', category: 'fleet', sortOrder: null }, + { id: 'b', slug: 'bbb', name: 'Bbb', category: 'fleet', sortOrder: 2 }, + { id: 'c', slug: 'ccc', name: 'Ccc', category: 'fleet', sortOrder: 1 }, + ]; + mockCustomModules = [ + customModule({ id: 'cm-1', category: 'fleet', name: 'Eigenes A', sortOrder: 2 }), + customModule({ id: 'cm-2', category: 'fleet', name: 'Eigenes B', sortOrder: null }), + ]; + const Sidebar = await importSidebar(); + render(); + + await waitFor(() => { + expect(screen.getByText('Eigenes B')).toBeInTheDocument(); + }); + const items = screen + .getByText('fleet') + .closest('li') + ?.querySelectorAll('a span.truncate'); + expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([ + 'Ccc', + 'Bbb', + 'Eigenes A', + 'Aaa', + 'Eigenes B', + ]); + }); + it('renders the version badge below the navigation', async () => { const Sidebar = await importSidebar(); render(); diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 28d1304..d2a29e0 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -1,6 +1,5 @@ 'use client'; -import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useTranslations } from 'next-intl'; @@ -11,7 +10,9 @@ import { AppVersionBadge } from '@/components/layout/app-version-badge'; import { SidebarSearch } from '@/components/layout/sidebar-search'; import { ModuleTile } from '@/components/modules/module-tile'; import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; +import { categoryRank, compareSidebarEntries } from '@/lib/module-category-order'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; +import { useModuleCategoryStore } from '@/lib/stores/module-category-store'; import { useNavStore } from '@/lib/stores/nav-store'; import { useSidebarStore } from '@/lib/stores/sidebar-store'; import { useCategoryLabel } from '@/lib/use-category-label'; @@ -34,6 +35,8 @@ interface SidebarModule { slug: string; name: string; category: string; + /** Reihenfolge innerhalb der Kategorie (Administrator); `null`/fehlt = nicht sortiert. */ + sortOrder?: number | null; } /** @@ -48,6 +51,10 @@ interface SidebarEntry { href: string; /** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */ tileSlug: string; + /** Reihenfolge innerhalb der Kategorie; `null` = nicht sortiert, steht hinten. */ + sortOrder: number | null; + /** `true` = eigenes Modul (steht bei Gleichstand hinter eingebauten). */ + custom: boolean; /** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */ extraActiveHref?: string; } @@ -60,6 +67,8 @@ function moduleEntry(mod: SidebarModule): SidebarEntry { category, href: `/modules/${category}/${mod.slug}`, tileSlug: mod.slug, + sortOrder: mod.sortOrder ?? null, + custom: false, extraActiveHref: `/modules/${mod.slug}`, }; } @@ -71,6 +80,8 @@ function customEntry(mod: CustomModule): SidebarEntry { category: mod.category || 'other', href: `/modules/custom/${mod.id}`, tileSlug: 'custom', + sortOrder: mod.sortOrder ?? null, + custom: true, }; } @@ -85,6 +96,8 @@ export function Sidebar() { // sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN. const [closedCategories, setClosedCategories] = useState>(new Set()); const categoryLabel = useCategoryLabel(); + const orderedCategoryList = useModuleCategoryStore((s) => s.categories); + const loadCategories = useModuleCategoryStore((s) => s.load); const setNavModules = useNavStore((s) => s.setModules); const pathname = usePathname(); const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey); @@ -148,8 +161,10 @@ export function Sidebar() { // silently fail } }; - await Promise.all([loadBuiltIn(), loadCustom()]); - }, []); + // Kategorien (quick-261003-387) ebenfalls im selben Takt: Reihenfolge und + // Namen stammen aus dem geteilten Kategorienspeicher. + await Promise.all([loadBuiltIn(), loadCustom(), loadCategories()]); + }, [loadCategories]); // Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene // Module gehen mit slug = id hinein, damit `resolvePageTitle` das @@ -166,9 +181,13 @@ export function Sidebar() { ]); }, [activeModules, customModules, setNavModules]); - // Eingebaute Module stehen innerhalb einer Kategorie vor eigenen. + // Reihenfolge innerhalb einer Kategorie: sortOrder aufsteigend (null zuletzt), + // bei Gleichstand eingebaute Module vor eigenen, dann nach Name. const entries = useMemo( - () => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)], + () => + [...activeModules.map(moduleEntry), ...customModules.map(customEntry)].sort( + compareSidebarEntries, + ), [activeModules, customModules], ); @@ -190,8 +209,9 @@ export function Sidebar() { }); }; - // Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt, - // die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten. + // Eintraege nach Kategorie gruppiert, Gruppen in der von Administratoren + // eingestellten Reihenfolge (Standard: „Eigene Module“ zuletzt); unbekannte + // Kennungen stehen zuletzt in Fundreihenfolge. Grundlage fuer BEIDE Ansichten. const orderedCategories = useMemo(() => { const categories = new Map(); for (const entry of entries) { @@ -200,9 +220,9 @@ export function Sidebar() { else categories.set(entry.category, [entry]); } return Array.from(categories.entries()).sort( - ([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY), + ([a], [b]) => categoryRank(orderedCategoryList, a) - categoryRank(orderedCategoryList, b), ); - }, [entries]); + }, [entries, orderedCategoryList]); // Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege // stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt. diff --git a/apps/web/src/lib/custom-modules-api.ts b/apps/web/src/lib/custom-modules-api.ts index 99337d6..afa20f5 100644 --- a/apps/web/src/lib/custom-modules-api.ts +++ b/apps/web/src/lib/custom-modules-api.ts @@ -13,6 +13,8 @@ export interface CustomModule { name: string; url: string; category: string; + /** Reihenfolge innerhalb der Kategorie (nur gemeinsame Eintraege); `null` = nicht sortiert. */ + sortOrder?: number | null; /** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */ personal: boolean; createdAt: string; diff --git a/apps/web/src/lib/module-categories-api.ts b/apps/web/src/lib/module-categories-api.ts new file mode 100644 index 0000000..5654ab9 --- /dev/null +++ b/apps/web/src/lib/module-categories-api.ts @@ -0,0 +1,25 @@ +/** + * Modulkategorien — API-Client (quick-261003-387). Konsumiert + * `/module-categories`. Muster `custom-modules-api.ts`: `credentials: + * 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export interface ModuleCategoryInfo { + id: string; + /** Unveraenderliche Kennung (URL-Segment /modules//). */ + key: string; + /** Eigener Name; `null` = Uebersetzung `moduleCategories.`. */ + name: string | null; + sortOrder: number; + /** `true` nur fuer „Eigene Module“: umbenennbar, nicht loeschbar. */ + isSystem: boolean; +} + +/** Alle Kategorien der Organisation in ihrer Reihenfolge (jeder Angemeldete). */ +export async function listModuleCategories(): Promise { + const res = await fetch(`${API_URL}/module-categories`, { credentials: 'include' }); + if (!res.ok) throw new Error(`Request failed (${res.status})`); + return res.json(); +} diff --git a/apps/web/src/lib/module-category-order.test.ts b/apps/web/src/lib/module-category-order.test.ts new file mode 100644 index 0000000..cc20fdb --- /dev/null +++ b/apps/web/src/lib/module-category-order.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { categoryRank, compareSidebarEntries } from './module-category-order'; + +describe('categoryRank', () => { + const cats = [{ key: 'b' }, { key: 'a' }]; + it('liefert den Index in der eingestellten Reihenfolge', () => { + expect(categoryRank(cats, 'b')).toBe(0); + expect(categoryRank(cats, 'a')).toBe(1); + }); + it('stellt unbekannte Kennungen hinter alle bekannten', () => { + expect(categoryRank(cats, 'x')).toBe(2); + expect(categoryRank([], 'x')).toBe(0); + }); +}); + +describe('compareSidebarEntries', () => { + const e = (name: string, sortOrder: number | null, custom = false) => ({ + name, + sortOrder, + custom, + }); + const sorted = (list: ReturnType[]) => + [...list].sort(compareSidebarEntries).map((x) => x.name); + + it('sortOrder aufsteigend, null zuletzt', () => { + expect(sorted([e('c', null), e('b', 2), e('a', 1)])).toEqual(['a', 'b', 'c']); + }); + it('bei Gleichstand stehen eingebaute Module vor eigenen', () => { + expect(sorted([e('Aaa', null, true), e('Zzz', null, false)])).toEqual(['Zzz', 'Aaa']); + expect(sorted([e('Aaa', 1, true), e('Zzz', 1, false)])).toEqual(['Zzz', 'Aaa']); + }); + it('danach nach Name', () => { + expect(sorted([e('b', null), e('a', null)])).toEqual(['a', 'b']); + }); + it('ein sortierter Eintrag steht vor einem unsortierten, auch wenn der unsortierte eingebaut ist', () => { + expect(sorted([e('Eingebaut', null, false), e('Eigenes', 0, true)])).toEqual([ + 'Eigenes', + 'Eingebaut', + ]); + }); +}); diff --git a/apps/web/src/lib/module-category-order.ts b/apps/web/src/lib/module-category-order.ts new file mode 100644 index 0000000..90f139f --- /dev/null +++ b/apps/web/src/lib/module-category-order.ts @@ -0,0 +1,31 @@ +/** + * Reihenfolge von Kategorien und Seitenleisten-Eintraegen (quick-261003-387, + * E-08). Reine Funktionen — der Stand kommt aus dem Kategorienspeicher. + */ + +/** Rang einer Kategorie in der eingestellten Reihenfolge; unbekannte zuletzt (alle gleich). */ +export function categoryRank(categories: ReadonlyArray<{ key: string }>, key: string): number { + const index = categories.findIndex((c) => c.key === key); + return index === -1 ? categories.length : index; +} + +export interface SortableEntry { + name: string; + sortOrder: number | null; + /** `true` = eigenes Modul. */ + custom: boolean; +} + +/** + * Reihenfolge innerhalb einer Kategorie: `sortOrder` aufsteigend, `null` + * zuletzt; bei Gleichstand eingebaute Module vor eigenen, dann nach Name. + */ +export function compareSidebarEntries(a: SortableEntry, b: SortableEntry): 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.custom !== b.custom) return a.custom ? 1 : -1; + return a.name.localeCompare(b.name); +} diff --git a/apps/web/src/lib/stores/module-category-store.ts b/apps/web/src/lib/stores/module-category-store.ts new file mode 100644 index 0000000..48784ad --- /dev/null +++ b/apps/web/src/lib/stores/module-category-store.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand'; +import { listModuleCategories, type ModuleCategoryInfo } from '@/lib/module-categories-api'; + +/** + * Geteilter Kategorienstand (quick-261003-387): Beschriftung, Reihenfolge und + * Auswahlfelder lesen alle denselben Stand. Geladen wird von der Seitenleiste + * (sie ist im Portal immer da) und von Seiten, die die Reihenfolge brauchen + * (`ensureLoaded`). Ein Fehler laesst den bisherigen Stand stehen — wie beim + * Modulabruf der Seitenleiste. + */ +interface ModuleCategoryState { + categories: ModuleCategoryInfo[]; + loaded: boolean; + load: () => Promise; + ensureLoaded: () => Promise; +} + +export const useModuleCategoryStore = create()((set, get) => ({ + categories: [], + loaded: false, + load: async () => { + try { + const categories = await listModuleCategories(); + set({ categories, loaded: true }); + } catch { + // silently fail + } + }, + ensureLoaded: async () => { + if (!get().loaded) await get().load(); + }, +})); diff --git a/apps/web/src/lib/use-category-label.ts b/apps/web/src/lib/use-category-label.ts index a8786f7..4139c08 100644 --- a/apps/web/src/lib/use-category-label.ts +++ b/apps/web/src/lib/use-category-label.ts @@ -2,16 +2,25 @@ import { useTranslations } from 'next-intl'; import { useCallback } from 'react'; +import { useModuleCategoryStore } from '@/lib/stores/module-category-store'; /** * Liefert den Anzeigenamen einer Modulkategorie in Alltagssprache - * (`domain-tools` → „Domains“). Unbekannte Kategorien behalten ihre Kennung. + * (`domain-tools` → „Domains“). Reihenfolge der Quellen (quick-261003-387): + * ein von Administratoren gespeicherter Name, dann die Uebersetzung, dann + * die Kennung selbst. Der Hook abonniert den Kategorienspeicher, damit + * Beschriftungen nach dem Laden oder Umbenennen neu gezeichnet werden. */ export function useCategoryLabel(): (slug: string) => string { const t = useTranslations('moduleCategories'); + const categories = useModuleCategoryStore((s) => s.categories); return useCallback( - // `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung. - (slug: string) => (typeof t.has === 'function' && t.has(slug) ? t(slug) : slug), - [t], + (slug: string) => { + const stored = categories.find((c) => c.key === slug)?.name; + if (stored) return stored; + // `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung. + return typeof t.has === 'function' && t.has(slug) ? t(slug) : slug; + }, + [t, categories], ); } diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index e9c8bac..d3f8ee7 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -863,6 +863,10 @@ werden. | apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). | | apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. | | apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). **quick-260929-dzu — persönliche Einträge:** neue Spalte `ownerUserId` (NULL = gemeinsam, gesetzt = persönlich, nur für den Besitzer sichtbar). Klasse und Stand unverändert (`muss-mandantengebunden`, `gebunden`); der Zeilenschutz bekommt die Benutzerdimension nach dem Muster `SearchProvider` (Migration 20260929130000): vier nach Befehl getrennte Regeln — Lesen: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId` NULL ODER eigene Zeile), Schreiben (INSERT/UPDATE/DELETE): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Persönliche Zugriffe binden mit Benutzer (`forTenant(prisma, tenantId, user.id)`); das Schreiben GEMEINSAMER Einträge bindet bewusst OHNE Benutzer, weil die Regel einem Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — davor prüft der Dienst die Rolle (nur Administrator, sonst 403). Fremde persönliche Einträge sind für jeden anderen Benutzer, auch Administratoren, ununterscheidbar 404. Sechs mandantengebundene Rohtreffer (siehe Bereichszeile). | +| apps/api/src/module-categories/module-categories.service.ts | moduleCategory | muss-mandantengebunden | gebunden | **quick-261003-387:** neu — die Modulkategorien einer Organisation (Kennung, optionaler eigener Name, Reihenfolge, Systemkennzeichen für „Eigene Module“). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261003120000, Form aus `NextcloudInstance`) — gemeinsame Einstellungen der Organisation, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst liest sie. Je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`), mehrschrittige Schreibwege über `withTenantTransaction`; zusätzlich steht `tenantId` in jedem `where`. | +| apps/api/src/module-categories/module-categories.service.ts | moduleCategoryPlacement | muss-mandantengebunden | gebunden | **quick-261003-387:** neu — Zuordnung eines Marktplatz-Moduls zu einer Kategorie der Organisation samt Reihenfolge (`tenantId`, `moduleId`, `categoryKey`, `sortOrder`). Regel `tenant_isolation_policy` ohne Benutzerdimension (Migration 20261003120000). Die Spalte `Module.category` bleibt unverändert; wirksam ist die Zuordnung, sonst die Manifest-Kategorie. Alle Zugriffe an den Organisationsklienten gebunden, `where` trägt `tenantId`. | +| apps/api/src/module-categories/module-categories.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-261003-387:** der Kategorien-Dienst liest die Kennungen eigener Module (auch persönlicher, nur das Feld `category`, damit keine Kennung verloren geht) und schreibt für GEMEINSAME eigene Module Kategorie und `sortOrder` (`where: { id, tenantId, ownerUserId: null }`). Beim Löschen einer Kategorie ziehen auch persönliche Einträge mit um — nur das Feld `category`, ohne ihren Inhalt zu lesen. Klient ohne Benutzer (die Regel aus Migration 20260929130000 lässt ihm alle Zeilen der Organisation); die Administrator-Prüfung sitzt im Controller (`@Roles`). Persönliche Einträge erscheinen in der Verwaltungsübersicht nur als Anzahl. | +| apps/api/src/module-categories/module-categories.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | **quick-261003-387:** Modulkatalog ist plattformweit, kein `tenantId`; der Kategorien-Dienst liest ihn (Kennung, Slug, Name, Manifest-Kategorie) über den ungebundenen Klienten — dieselbe Begründung wie `module-access.service.ts` (260910-exd, Befund E): keine Regel auf der Tabelle, eine Bindung wäre heute wirkungslos, katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. | | apps/api/src/reminders/reminders.service.ts | reminder | muss-mandantengebunden | gebunden | **quick-260929-if2:** neu — persönliche, einmalige Erinnerungen des Dashboard-Widgets „Erinnerungen“ (Titel, Beschreibung, Fälligkeit). `tenantId`- und `userId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension (Migration 20260929140000, Form aus `DashboardImage`): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Jede Methode bindet mit Mandant UND Benutzer (`const tenantPrisma = forTenant(this.prisma, tenantId, userId)`), jedes `where` trägt zusätzlich `tenantId` und `userId` (Anwendungspruefung, solange der RLS-Schalter aus ist). Fremde oder unbekannte Kennungen sind ununterscheidbar 404, nie 403 (D-05). Sieben mandantengebundene Rohtreffer: `list` (`findMany`), `create` (`count` und `create`), `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst` mit `where: { id, tenantId, userId }`, für `update`/`snooze`/`remove`). Das Verschieben setzt `emailSentAt` und `emailAttempts` zurück (D-03). | | apps/api/src/reminders/reminders.service.ts | user | muss-mandantengebunden | gebunden | quick-260929-if2 (Aufgabe 3): `getEmailAvailability` liest die eigene E-Mail-Adresse des Aufrufers (`user.findFirst` mit `where: { id: userId, tenantId }`), gebunden mit Mandant UND Benutzer über denselben Klienten wie die übrigen Zugriffe der Methode. Grundlage für den Schalter „zusätzlich per E-Mail“ und die 400-Antwort bei `emailEnabled` ohne Adresse. | | apps/api/src/reminders/reminder-mail.scheduler.ts | reminder | beides | system-gebunden | quick-260929-if2 (Aufgabe 3): der E-Mail-Planer der Erinnerungen ist ein globales 30-Sekunden-Intervall über ALLE Mandanten (bewusst übergreifend, siehe Dateikopf). Die Kandidatenabfrage (`findMany`, nur skalarer Select, `take 200`) liest über `forSystem()` (`system_read_policy ... FOR SELECT`, Migration 20260929140000, nur lesend); Anspruch (`updateMany`), Laden (`findFirst`) und Freigabe (`updateMany`) laufen je Kandidatenzeile über `forTenant(prisma, c.tenantId)`. Der Anspruch ist atomar (`emailSentAt: null` und unveränderte `dueAt` in der Bedingung), damit mehrere Instanzen nie doppelt senden. |