docs(quick-261003-387): Kategorien bearbeitbar

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 02:53:19 +02:00
parent f2c0a896e8
commit 2053dbac28
3 changed files with 408 additions and 1 deletions
+2 -1
View File
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-10-02 - Quick 261002-k67 + 261002-kxc Nextcloud-Status mit Benachrichtigung (lokal nachgewiesen, nicht gepusht) Last activity: 2026-10-03 - Quick 261003-387 Kategorien bearbeitbar (lokal nachgewiesen, nicht gepusht)
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -491,6 +491,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 261002-icv | Modul-Freigabe mit Stufe Verwalten (Modul-Einstellungen ohne Admin; Kantine, Handelsware, Proxmox, DKV), im Browser nachgewiesen | 2026-10-02 | a222711..HEAD | [261002-icv-modul-freigabe-mit-stufe-verwalten-modul](.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/) | | 261002-icv | Modul-Freigabe mit Stufe Verwalten (Modul-Einstellungen ohne Admin; Kantine, Handelsware, Proxmox, DKV), im Browser nachgewiesen | 2026-10-02 | a222711..HEAD | [261002-icv-modul-freigabe-mit-stufe-verwalten-modul](.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/) |
| 261002-k67 | Modul Nextcloud-Status mit Ampel-Kacheln und Dashboard-Uebersicht | 2026-10-02 | 5ef7b0c..87a7b7c | [261002-k67-modul-nextcloud-status-mit-ampel-kacheln](.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/) | | 261002-k67 | Modul Nextcloud-Status mit Ampel-Kacheln und Dashboard-Uebersicht | 2026-10-02 | 5ef7b0c..87a7b7c | [261002-k67-modul-nextcloud-status-mit-ampel-kacheln](.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/) |
| 261002-kxc | Nextcloud-Status: Benachrichtigung bei Rot je Benutzer (Mail + Desktop-Hinweis), Klartext-Fehler | 2026-10-02 | faed0d7..6c4bff6 | [261002-kxc-nextcloud-status-benachrichtigung-bei-ro](.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/) | | 261002-kxc | Nextcloud-Status: Benachrichtigung bei Rot je Benutzer (Mail + Desktop-Hinweis), Klartext-Fehler | 2026-10-02 | faed0d7..6c4bff6 | [261002-kxc-nextcloud-status-benachrichtigung-bei-ro](.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/) |
| 261003-387 | Kategorien durch Admins bearbeitbar (anlegen, umbenennen, sortieren, loeschen mit Verschieben, Module zuordnen) | 2026-10-03 | 8ec116c..f2c0a89 | [261003-387-kategorien-durch-admins-bearbeitbar-umbe](.planning/quick/261003-387-kategorien-durch-admins-bearbeitbar-umbe/) |
## Deferred Items ## Deferred Items
@@ -0,0 +1,265 @@
---
phase: quick-261003-387
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261003-387
description: "Modulkategorien durch Administratoren bearbeitbar: anlegen, umbenennen, sortieren, löschen mit Verschieben, Module zuordnen und innerhalb der Kategorie sortieren"
date: 2026-10-03
files_modified:
# Task 1 — tracer: DB -> Dienst (Grundbestand + Überlagerung) -> GET /module-categories + /modules/active -> Store -> Seitenleiste/Beschriftung
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261003120000_module_categories/migration.sql
- apps/api/src/module-categories/module-categories.service.ts
- apps/api/src/module-categories/module-categories.service.spec.ts
- apps/api/src/module-categories/module-categories.controller.ts
- apps/api/src/module-categories/module-categories.controller.spec.ts
- apps/api/src/module-categories/module-categories.module.ts
- apps/api/src/module-categories/dto/module-category.dto.ts
- apps/api/src/module-registry/module-registry.module.ts
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/module-categories-api.ts
- apps/web/src/lib/stores/module-category-store.ts
- apps/web/src/lib/module-category-order.ts
- apps/web/src/lib/module-category-order.test.ts
- apps/web/src/lib/use-category-label.ts
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/components/layout/sidebar.test.tsx
# Task 2 — API: Verwaltungs-Endpunkte, Löschen mit Verschieben, Überlagerung überall, eigene Module
- apps/api/src/groups/groups.module.ts
- apps/api/src/groups/module-grants.controller.ts
- apps/api/src/custom-modules/custom-modules.module.ts
- apps/api/src/custom-modules/custom-modules.service.ts
- apps/api/src/custom-modules/custom-modules.service.spec.ts
- apps/api/src/custom-modules/custom-modules.controller.spec.ts
- apps/api/src/custom-modules/dto/custom-module.dto.ts
# Task 3 — Web: Verwaltungsseite, Marktplatz, Formular, Texte, Doku
- apps/web/src/app/(portal)/admin/modules/categories/page.tsx
- apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx
- apps/web/src/app/(portal)/admin/modules/page.tsx
- apps/web/src/app/(portal)/marketplace/page.tsx
- apps/web/src/app/(portal)/modules/[category]/page.tsx
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx
- apps/web/src/lib/custom-modules-api.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
- docs/anleitung-administration.md
- docs/anleitung-anwender.md
autonomous: true
requirements: [QUICK-261003-387]
estimate:
tokens: 240000
raw_tokens: 240000
tasks: 3
confidence: low
must_haves:
truths:
- "Ein Administrator sieht unter Administrator → Module neben „Freigaben-Matrix“ einen Knopf „Kategorien“, der /admin/modules/categories öffnet; Nicht-Administratoren bekommen dort den Zugriffshinweis und von der API 403."
- "Ein Administrator kann eine Kategorie anlegen, umbenennen, mit Pfeilen nach oben/unten verschieben und löschen; „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen (Knopf gesperrt, API 400)."
- "Löschen einer nicht leeren Kategorie fragt nach einer Zielkategorie; alle Module, gemeinsame UND persönliche eigene Module, landen dort — kein Eintrag geht verloren; ohne Ziel antwortet die API 409 und löscht nichts."
- "Ein Administrator ordnet jedes Marktplatz-Modul und jedes gemeinsame eigene Modul per Auswahlfeld einer Kategorie zu und sortiert die Einträge innerhalb einer Kategorie mit Pfeilen; die Spalte Module.category (für alle gleich) bleibt unverändert."
- "Seitenleiste, Marktplatz (Filterchips und Kartenreihenfolge), Kategorieseite /modules/<kategorie> und Freigaben-Matrix zeigen die Kategorie aus der Zuordnung in der eingestellten Kategorie- und Modulreihenfolge; nicht umbenannte Standardkategorien bleiben übersetzt (de/en), umbenannte zeigen den gespeicherten Namen."
- "Im Formular für eigene Module stehen alle vorhandenen Kategorien zur Wahl; bestehende Werte bleiben gültig; eine unbekannte Kategorie lehnt die API mit 400 ab."
- "Alte Modul-Adressen /modules/<alte-kategorie>/<slug> öffnen das Modul weiterhin, weil die Seite nur über den Slug auflöst."
artifacts:
- path: "apps/api/prisma/migrations/20261003120000_module_categories/migration.sql"
provides: "Tabellen ModuleCategory + ModuleCategoryPlacement mit RLS, Spalte CustomModule.sortOrder"
contains: "tenant_isolation_policy"
- path: "apps/api/src/module-categories/module-categories.service.ts"
provides: "Grundbestand je Organisation, CRUD, Löschen mit Verschieben, Überlagerung applyToModules, assertCategoryKey"
- path: "apps/api/src/module-categories/module-categories.controller.ts"
provides: "GET /module-categories (alle), Verwaltungs-Endpunkte nur ADMIN/SUPER_ADMIN, statische Routen vor :key"
- path: "apps/web/src/app/(portal)/admin/modules/categories/page.tsx"
provides: "Verwaltungsseite Kategorien"
- path: "apps/web/src/lib/stores/module-category-store.ts"
provides: "Geteilter Kategorienstand für Beschriftung, Reihenfolge und Auswahlfelder"
key_links:
- from: "apps/api/src/module-registry/module-registry.controller.ts"
to: "ModuleCategoriesService.applyToModules"
via: "GET /modules, /modules/active, /modules/catalog liefern die zugeordnete Kategorie + sortOrder"
pattern: "applyToModules"
- from: "apps/api/src/groups/module-grants.controller.ts"
to: "ModuleCategoriesService.applyToModules"
via: "GET /module-grants/matrix sortiert Module nach Kategorie- und Modulreihenfolge"
pattern: "applyToModules"
- from: "apps/web/src/lib/use-category-label.ts"
to: "apps/web/src/lib/stores/module-category-store.ts"
via: "gespeicherter Name vor Übersetzung, Übersetzung vor Kennung"
pattern: "useModuleCategoryStore"
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "apps/web/src/lib/module-category-order.ts"
via: "Gruppenreihenfolge und Reihenfolge innerhalb der Gruppe"
pattern: "module-category-order"
---
<objective>
Modulkategorien werden pro Organisation durch Administratoren pflegbar: anlegen, umbenennen, sortieren, löschen (mit Verschieben der Inhalte), Module und gemeinsame eigene Module zuordnen und innerhalb der Kategorie sortieren. Die eingestellte Kategorie und Reihenfolge gilt in Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und im Formular für eigene Module.
Purpose: Heute legt jedes Modul seine Kategorie fest (Module.category aus dem Manifest, für alle Organisationen gleich); Administratoren können die Seitenleiste nicht nach ihren Abläufen ordnen.
Output: Zwei neue Tabellen mit Zeilenschutz, ein Kategorien-Dienst mit Endpunkten, eine Verwaltungsseite, angepasste Anzeigen, CHANGELOG und Handbuch.
Festgelegte Entwurfsentscheidungen (aus dem Auftrag, Planer-Ermessen hier dokumentiert):
- E-01 Datenform: ModuleCategory {id, tenantId, key, name (null = Übersetzung moduleCategories.<key>), sortOrder, isSystem}, eindeutig (tenantId, key). key ist UNVERÄNDERLICH und bleibt URL-Segment /modules/<key>/<slug>; Umbenennen ändert nur name. isSystem=true nur für „custom-modules“ (Eigene Module): umbenennbar, verschiebbar, nicht löschbar.
- E-02 Zuordnung Marktplatz-Module: ModuleCategoryPlacement {id, tenantId, moduleId → Module (onDelete Cascade), categoryKey, sortOrder}, eindeutig (tenantId, moduleId). Wirksame Kategorie = Zuordnung, sonst Module.category. Module.category wird NIE geändert.
- E-03 Gemeinsame eigene Module: CustomModule ist schon eine Zeile je Organisation; Zuordnung schreibt direkt CustomModule.category, Reihenfolge in der neuen Spalte CustomModule.sortOrder (Int, null erlaubt). Persönliche eigene Module wählen ihre Kategorie selbst (jede vorhandene) und bekommen nie eine sortOrder.
- E-04 Grundbestand ohne SQL-Rückfüllung: der Dienst legt beim ersten Lesen je Organisation die Standardkategorien an (Reihenfolge von CUSTOM_MODULE_CATEGORIES, also die sechs Modulkategorien und „Eigene Module“ zuletzt, name null) und legt fehlende Zeilen für jede wirksam benutzte Kennung nach (Kategorie eines später ausgelieferten Moduls, vorhandene Werte eigener Module), jeweils hinten angehängt. Eine gelöschte Standardkategorie kommt nur wieder, wenn ein Modul sie wirksam benutzt (z. B. ein neu ausgeliefertes Modul mit diesem Manifest-Wert).
- E-05 Löschen: alle Inhalte wandern in die gewählte Zielkategorie — Marktplatz-Module (Zuordnung umgeschrieben bzw. neu angelegt, damit die Manifest-Kategorie sie nicht zurückholt), gemeinsame UND persönliche eigene Module (persönliche Einträge gehen mit den anderen mit, nicht nach „Eigene Module“). Verschobene Einträge werden hinten angehängt.
- E-06 Neue Kennung: aus dem Namen gebildet (klein, ä→ae, ö→oe, ü→ue, ß→ss, sonst nur a-z0-9 und Bindestrich, höchstens 40 Zeichen, leer → „kategorie“); kollidiert sie mit einer Kennung der Organisation, einem Modul-Slug (eigene Routenordner unter /modules) oder „custom“, wird „-2“, „-3“ … angehängt.
- E-07 Wirksame Kategorie wird SERVERSEITIG über die Modullisten gelegt (/modules, /modules/active, /modules/catalog, /module-grants/matrix): jedes Modul bekommt category = wirksame Kennung und sortOrder (Zahl oder null), Liste sortiert nach Kategorie-Reihenfolge, dann sortOrder (null zuletzt), dann Name. Dadurch gruppieren Kategorieseite, Marktplatz und Matrix ohne eigene Logik richtig.
- E-08 Reihenfolge innerhalb einer Kategorie (Seitenleiste): sortOrder aufsteigend, null zuletzt; bei Gleichstand eingebaute Module vor eigenen, dann Name. Persönliche eigene Module stehen damit immer hinter den vom Administrator sortierten Einträgen.
- E-09 Nicht im Umfang: der Benutzer-Zugriffsdialog (Benutzerdetails) behält seine bisherige Sortierung; keine „Auf Standardnamen zurücksetzen“-Funktion.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Bestehende Muster (einmal lesen, dann nachbauen):
- Migration mit Kopfkommentar + RLS ohne Benutzerdimension: apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
- RLS-Regeln eigener Module (Klient ohne Benutzer darf alle Zeilen der Organisation ändern): apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql
- forTenant / withTenantTransaction: apps/api/src/prisma/prisma-tenant.extension.ts
- Rollen je Methode: apps/api/src/module-registry/module-registry.controller.ts (@UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN), tenantId = req.tenantId ?? req.user?.tenantId)
- Zugriffsinventar: apps/api/src/prisma/rls-access-inventory.spec.ts gegen docs/mandantentrennung-zugriffsklassifikation.md (Zeilenformat wie Eintrag nextcloud-status.service.ts)
- Löschdialog mit Rückfrage: apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx
- Admin-Seite mit Rollenprüfung und Kopf-Link: apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/page.tsx
- Seitenleisten-Test zählt fetch-Aufrufe: apps/web/src/components/layout/sidebar.test.tsx (API-Helfer werden als Modul gemockt, z. B. @/lib/custom-modules-api)
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — Kategorietabellen, Grundbestand, Lese-Endpunkt und wirksame Kategorie bis in die Seitenleiste</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261003120000_module_categories/migration.sql, apps/api/src/module-categories/module-categories.service.ts, apps/api/src/module-categories/module-categories.service.spec.ts, apps/api/src/module-categories/module-categories.controller.ts, apps/api/src/module-categories/module-categories.controller.spec.ts, apps/api/src/module-categories/module-categories.module.ts, apps/api/src/module-categories/dto/module-category.dto.ts, apps/api/src/module-registry/module-registry.module.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/module-categories-api.ts, apps/web/src/lib/stores/module-category-store.ts, apps/web/src/lib/module-category-order.ts, apps/web/src/lib/module-category-order.test.ts, apps/web/src/lib/use-category-label.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx</files>
<read_first>apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/lib/use-category-label.ts, packages/shared/src/index.ts (Zeilen 270-300)</read_first>
<action>
Schema (E-01, E-02, E-03): in schema.prisma die Modelle ModuleCategory und ModuleCategoryPlacement wie in E-01/E-02 beschrieben anlegen (beide mit tenantId String, createdAt/updatedAt, @@index([tenantId]); ModuleCategory @@unique([tenantId, key]), sortOrder Int @default(0), isSystem Boolean @default(false), name String?; Placement @@unique([tenantId, moduleId]), sortOrder Int, Relation zu Module mit onDelete: Cascade und Gegenfeld categoryPlacements an Module). An CustomModule die Spalte sortOrder Int? ergänzen und den Kommentar an category auf „Kennung einer ModuleCategory der Organisation“ ändern. Migration 20261003120000_module_categories: DDL mit `pnpm --filter @tessera/api exec prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --script` erzeugen (Shadow-DB per --shadow-database-url über die Container-IP, falls nötig) oder von Hand nach Vorbild schreiben; dann von Hand den Pflicht-Kopfkommentar (Zweck, quick-261003-387, Zeilenschutz ohne Benutzerdimension weil gemeinsame Daten der Organisation, Rechte über ALTER DEFAULT PRIVILEGES, Schalter-Hinweis) und für BEIDE Tabellen ENABLE + FORCE ROW LEVEL SECURITY und CREATE POLICY tenant_isolation_policy … USING ("tenantId" = current_tenant_id()) ergänzen. Keine system_read_policy (kein Hintergrunddienst). Migration lokal anwenden: IP mit `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, dann `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` und `pnpm --filter @tessera/api exec prisma generate`.
Dienst apps/api/src/module-categories/module-categories.service.ts (injiziert nur PrismaService; je Methode eigener Klient `const tenantPrisma = forTenant(this.prisma, tenantId)`; Lesen des globalen Modulkatalogs über this.prisma.module wie in module-access.service.ts, mit gleichem Begründungskommentar). In diesem Task: (a) private ensure(tenantId) nach E-04 — keine Zeile vorhanden → createMany mit skipDuplicates für CUSTOM_MODULE_CATEGORIES in dieser Reihenfolge (sortOrder = Index, isSystem nur für CUSTOM_MODULE_CATEGORY); danach fehlende Kennungen aus wirksamer Modulkategorie (Zuordnung sonst Module.category) und distinct CustomModule.category der Organisation mit sortOrder = bisheriges Maximum + 1 nachlegen (skipDuplicates); liefert die Zeilen sortiert nach sortOrder, dann key. (b) listCategories(tenantId) → [{id, key, name, sortOrder, isSystem}]. (c) applyToModules(tenantId, modules) generisch über {id, category, name} nach E-07 (gibt category und sortOrder: number | null zurück, unbekannte Kategorie sortiert zuletzt). (d) rename(tenantId, key, name) — Name getrimmt 1–60 Zeichen, unbekannte Kennung 404, „Eigene Module“ erlaubt (D-Auftrag: umbenennbar).
Controller apps/api/src/module-categories/module-categories.controller.ts mit @Controller('module-categories'): GET '' für alle angemeldeten Benutzer → listCategories; PATCH ':key' mit @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) → rename (DTO RenameModuleCategoryDto in dto/module-category.dto.ts mit Transform-Trim, IsString, IsNotEmpty, MaxLength(60)). Alle späteren statischen Routen kommen VOR ':key' (NestJS-Route-Order). ModuleCategoriesModule (providers + exports ModuleCategoriesService, controllers) anlegen, in app.module.ts registrieren und von ModuleRegistryModule importieren. In ModuleRegistryController ModuleCategoriesService injizieren und findActive über applyToModules leiten (findAll/findCatalog folgen in Task 2).
Inventar: rls-access-inventory.spec.ts laufen lassen und für die neuen Paare (Datei module-categories.service.ts × moduleCategory, moduleCategoryPlacement, customModule, module) Zeilen in docs/mandantentrennung-zugriffsklassifikation.md im vorhandenen Format ergänzen (Stand gebunden bzw. für module der dokumentierte ungebundene Katalogzugriff); in Task 2 kommen weitere Treffer hinzu — die Rohzahlen dann nachziehen.
Web: apps/web/src/lib/module-categories-api.ts mit Typ ModuleCategoryInfo {id, key, name: string | null, sortOrder, isSystem} und listModuleCategories() (GET, credentials include, wirft bei !ok). apps/web/src/lib/stores/module-category-store.ts (zustand wie marketplace-store): categories, loaded, load() ruft listModuleCategories und schluckt Fehler still (Seitenleisten-Muster), ensureLoaded() lädt nur wenn !loaded. apps/web/src/lib/module-category-order.ts als reine Funktionen: categoryRank(categories, key) und compareSidebarEntries nach E-08; Tests in module-category-order.test.ts. use-category-label.ts: liest den Store; gespeicherter name (nicht null) vor Übersetzung, Übersetzung vor Kennung; abonniert den Store, damit Beschriftungen nach dem Laden neu rendern. Sidebar: SidebarModule und SidebarEntry um sortOrder (number | null) und custom-Kennzeichen erweitern (eigene Module aus der CustomModule-Antwort übernehmen sortOrder, siehe Task 2 für das API-Feld; bis dahin null); fetchActiveModules lädt zusätzlich den Store (load()) im selben Auffrisch-Takt; orderedCategories sortiert Gruppen nach categoryRank (unbekannte Kennungen zuletzt in Fundreihenfolge) und Einträge je Gruppe nach compareSidebarEntries — der bisherige feste Sonderfall „Eigene Module immer zuletzt“ entfällt, weil die Reihenfolge jetzt aus den Kategoriezeilen kommt (Standard: zuletzt). sidebar.test.tsx: @/lib/module-categories-api als Modul mocken (fetch-Zähler bleiben unverändert) und Tests ergänzen: Gruppen folgen der Store-Reihenfolge; umbenannte Kategorie zeigt den Namen; Einträge innerhalb einer Gruppe folgen sortOrder, null zuletzt, eingebaut vor eigenem.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-categories src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run sidebar module-category-order && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && docker exec tessera-ctl-db-1 psql -U tessera -d tessera -tAc "select count(*) from pg_class where relname in ('ModuleCategory','ModuleCategoryPlacement') and relrowsecurity and relforcerowsecurity" | grep -qx 2</automated>
</verify>
<done>Migration lokal angewendet, beide Tabellen mit FORCE RLS; GET /module-categories liefert für eine frische Organisation sieben Standardkategorien in Standardreihenfolge mit name null; PATCH benennt um (nur Administratoren); /modules/active liefert wirksame Kategorie + sortOrder; die Seitenleiste ordnet Gruppen und Einträge nach Store und zeigt umbenannte Namen; RLS-Gates grün.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: API — Anlegen, Sortieren, Zuordnen, Löschen mit Verschieben, Überlagerung in allen Modullisten, eigene Module gegen vorhandene Kategorien prüfen</name>
<files>apps/api/src/module-categories/module-categories.service.ts, apps/api/src/module-categories/module-categories.service.spec.ts, apps/api/src/module-categories/module-categories.controller.ts, apps/api/src/module-categories/module-categories.controller.spec.ts, apps/api/src/module-categories/dto/module-category.dto.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/groups/groups.module.ts, apps/api/src/groups/module-grants.controller.ts, apps/api/src/custom-modules/custom-modules.module.ts, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/src/custom-modules/dto/custom-module.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>apps/api/src/module-categories/module-categories.service.ts (aus Task 1), apps/api/src/groups/module-grants.controller.ts, apps/api/src/custom-modules/dto/custom-module.dto.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/prisma/prisma-tenant.extension.ts (withTenantTransaction)</read_first>
<behavior>
- create: Name „Werkzeuge & Tools“ ergibt Kennung „werkzeuge-tools“, sortOrder = Maximum + 1, name gespeichert; Name, dessen Kennung einem Modul-Slug (z. B. „proxmox“), „custom“ oder einer vorhandenen Kennung entspricht, bekommt „-2“; leerer Name 400.
- reorderCategories: keys muss genau eine Umstellung aller Kennungen der Organisation sein, sonst 400; danach sortOrder = Index.
- assign module: legt Zuordnung an oder schreibt sie um (categoryKey, sortOrder = Maximum der Zielkategorie + 1); Module.category bleibt unverändert; unbekannte Kategorie 400, unbekanntes Modul 404.
- assign custom: nur gemeinsame eigene Module (ownerUserId null), persönliches oder fremdes 404; schreibt CustomModule.category und sortOrder.
- reorderItems: items muss genau die Menge der nicht persönlichen Einträge der Kategorie sein (Marktplatz-Module mit wirksamer Kategorie + gemeinsame eigene Module), sonst 400; schreibt sortOrder = Index (Module per Upsert der Zuordnung).
- remove: isSystem → 400; unbekannte Kennung → 404; nicht leer (wirksame Module, gemeinsame oder persönliche eigene Module) ohne moveTo → 409 und nichts gelöscht; moveTo gleich key oder unbekannt → 400; mit Ziel: Zuordnungen umgeschrieben, Module mit Manifest-Kategorie ohne Zuordnung bekommen eine Zuordnung zum Ziel, alle CustomModule-Zeilen (auch persönliche) bekommen das Ziel, alles in einer Transaktion, dann Zeile gelöscht; leere Kategorie ohne moveTo wird gelöscht; nach dem Löschen legt ensure die Kategorie nicht wieder an.
- applyToModules: Zuordnung schlägt Manifest; Sortierung Kategorie-Reihenfolge, dann sortOrder (null zuletzt), dann Name.
- getOverview: je Kategorie in Reihenfolge {key, name, sortOrder, isSystem, items: [{type: 'module'|'custom', id, name, slug?}] in Reihenfolge, personalCount}.
- Custom modules: create/update mit Kategorie, die die Organisation nicht hat → 400; vorhandene Kennung (auch neu angelegte) → ok.
</behavior>
<action>
Dienst ergänzen (E-02, E-03, E-05, E-06): create(tenantId, name), reorderCategories(tenantId, keys), assign(tenantId, {type, id, categoryKey}), reorderItems(tenantId, key, items), remove(tenantId, key, moveTo?), getOverview(tenantId), assertCategoryKey(tenantId, key) (ruft ensure, wirft BadRequestException mit deutscher Meldung „Unbekannte Kategorie“). Mehrschrittige Schreibvorgänge (reorderCategories, reorderItems, remove) über withTenantTransaction(this.prisma, tenantId, async (tx) => …), damit das Inventar sie als gebunden erkennt. Eigene Module werden über den Organisations-Klienten OHNE Benutzer gelesen/geschrieben (die Regel aus 20260929130000 lässt dann alle Zeilen der Organisation zu); die Administrator-Prüfung sitzt im Controller. Zusätzlich jede Abfrage mit tenantId im where (Anwendungsprüfung, solange der RLS-Schalter aus ist). Fehlermeldungen deutsch, ohne das Wort Mandant.
Controller (statische Routen VOR ':key', alle schreibenden und overview nur ADMIN/SUPER_ADMIN): GET 'overview', POST '' (CreateModuleCategoryDto {name}), PUT 'order' ({keys: string[]}, ArrayMinSize 1, jedes Element passend zu /^[a-z0-9][a-z0-9-]{0,59}$/), PUT 'assignment' ({type: IsIn ['module','custom'], id: IsString, categoryKey}), dann PATCH ':key' (aus Task 1), PUT ':key/items' ({items: [{type, id}]} mit ValidateNested + Type), DELETE ':key' mit optionalem Query moveTo. Controller-Spec: Rollen-Metadaten je Verwaltungsmethode (Muster expectAdminOnly aus module-manage-handlers.spec.ts), GET '' ohne @Roles, und Reihenfolge der Routen (Index von 'overview', 'order', 'assignment' im Quelltext vor dem ersten ':key').
Überlagerung (E-07): ModuleRegistryController.findAll (mit @Req; ohne tenantId unverändert zurückgeben) und findCatalog über applyToModules leiten; ModuleGrantsController.matrix: Ergebnis von getMatrix nehmen und modules durch applyToModules ersetzen (GroupsModule importiert ModuleCategoriesModule; getMatrix im Dienst bleibt unverändert, damit bestehende Specs halten). Benutzer-Zugriffsdialog unverändert (E-09).
Eigene Module: CUSTOM_MODULE_SELECT um sortOrder erweitern (Antwortfeld für die Seitenleiste). DTO: @IsIn([...CUSTOM_MODULE_CATEGORIES]) durch IsString + Matches(/^[a-z0-9][a-z0-9-]{0,59}$/) ersetzen; Typ string. CustomModulesService injiziert ModuleCategoriesService (CustomModulesModule importiert ModuleCategoriesModule) und ruft assertCategoryKey in create und in update (nur wenn category gesetzt). Bestehende Specs auf den neuen Konstruktor-Parameter anpassen (Stub mit assertCategoryKey), neuen Fall „unbekannte Kategorie 400“ ergänzen. Danach rls-access-inventory.spec.ts laufen lassen und die Rohzahlen/Methodenliste der Zeilen für module-categories.service.ts nachziehen.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-categories src/module-registry src/groups src/custom-modules rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && node -e 'const s=require("fs").readFileSync("apps/api/src/module-categories/module-categories.controller.ts","utf8");const k=s.indexOf("\x27:key");for(const r of ["\x27overview\x27","\x27order\x27","\x27assignment\x27"]){const i=s.indexOf(r);if(i<0||k<0||i>k){console.error("route order",r);process.exit(1)}}' && grep -q "applyToModules" apps/api/src/groups/module-grants.controller.ts</automated>
</verify>
<done>Alle Verwaltungsendpunkte vorhanden, nur für Administratoren, statische Routen vor :key; Löschen verschiebt alle Einträge einschließlich persönlicher eigener Module in einer Transaktion und verweigert ohne Ziel mit 409; /modules, /modules/catalog und /module-grants/matrix liefern wirksame Kategorie und Reihenfolge; eigene Module akzeptieren jede vorhandene Kategorie und lehnen unbekannte mit 400 ab; API-Gates grün.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Web — Verwaltungsseite „Kategorien“, Marktplatz, Formular, Texte, CHANGELOG, Handbuch, Gesamtprüfung und Neubau</name>
<files>apps/web/src/lib/module-categories-api.ts, apps/web/src/app/(portal)/admin/modules/categories/page.tsx, apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx, apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/marketplace/page.tsx, apps/web/src/app/(portal)/modules/[category]/page.tsx, apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/lib/custom-modules-api.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-anwender.md</files>
<read_first>apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/page.tsx (Kopf, Zurück-Link), apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx (Mock-Muster), apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/app/(portal)/marketplace/page.tsx</read_first>
<behavior>
- Seite listet Kategorien in Reihenfolge mit Beschriftung aus useCategoryLabel und darunter ihre Einträge; Pfeil nach oben bei der ersten bzw. nach unten bei der letzten Kategorie/Eintrag gesperrt.
- „Kategorie anlegen“ sendet POST mit dem Namen; Umbenennen sendet PATCH; Pfeile senden PUT order bzw. PUT :key/items mit der vollständigen neuen Reihenfolge.
- Auswahlfeld je Eintrag sendet PUT assignment.
- Löschen-Knopf bei „Eigene Module“ gesperrt; leere Kategorie → einfache Rückfrage → DELETE ohne moveTo; nicht leere (items oder personalCount > 0) → Dialog mit Zielauswahl (ohne die zu löschende) → DELETE mit moveTo.
- Nach jeder Änderung: Übersicht neu laden, Kategorienstand neu laden, Seitenleiste auffrischen (bumpSidebarRefresh).
- Nicht-Administrator sieht den Zugriffshinweis und es wird nichts geladen.
- Formular für eigene Module bietet alle Kategorien aus dem Store in Reihenfolge an; ein vorhandener Wert, der (noch) nicht im Store steht, bleibt als Option erhalten.
</behavior>
<action>
module-categories-api.ts um getModuleCategoryOverview, createModuleCategory, renameModuleCategory, reorderModuleCategories, reorderModuleCategoryItems, assignModuleCategory, deleteModuleCategory(key, moveTo?) erweitern (Fehlerklasse mit status wie CustomModuleRequestError; Fehlermeldung der API anzeigen). CustomModule-Typ in custom-modules-api.ts um sortOrder: number | null ergänzen.
Neue Seite apps/web/src/app/(portal)/admin/modules/categories/page.tsx (Client-Komponente, Rollenprüfung und Layout wie admin/modules/page.tsx, Zurück-Link „Module“ wie in grants/page.tsx): Kopf „Kategorien“ mit Erklärung; Eingabe + Knopf „Kategorie anlegen“; je Kategorie eine Karte mit Name, Pfeilen nach oben/unten (aria-label „Kategorie nach oben/unten verschieben“), „Umbenennen“ (Eingabe an Ort und Stelle, Speichern/Abbrechen), „Löschen“ (bei isSystem gesperrt mit Hinweis „Diese Kategorie kann nicht gelöscht werden“); darin die Einträge (Marktplatz-Module und gemeinsame eigene Module, letztere mit kleinem Hinweis „Eigenes Modul“) mit Pfeilen und einem Auswahlfeld „Kategorie“ (alle Kategorien); bei personalCount > 0 der Satz „Außerdem N persönliche Einträge von Benutzern“; leere Kategorie zeigt „Keine Module in dieser Kategorie“. Löschdialog nach Vorbild DeleteGroupDialog nach E-05 (Text: die Module werden in die gewählte Kategorie verschoben, auch persönliche Einträge von Benutzern; nichts geht verloren). Pfeile verschieben durch Tauschen in der lokalen Liste und senden die vollständige neue Reihenfolge. admin/modules/page.tsx: neben dem Link „Freigaben-Matrix“ einen zweiten Link „Kategorien“ auf /admin/modules/categories; die Kategorie-Plakette zeigt categoryLabel(mod.category) statt der Kennung.
Marktplatz: Filterchips nach Store-Reihenfolge (categoryRank) statt alphabetisch, Store per ensureLoaded laden; Karten behalten die vom Server gelieferte Reihenfolge. Kategorieseite /modules/[category]/page.tsx: Titel über useCategoryLabel statt formatCategoryName (Filter auf mod.category bleibt, die API liefert jetzt die wirksame Kategorie). Prüfen und im SUMMARY festhalten, dass /modules/[category]/[moduleSlug] nur über den Slug auflöst (ModuleAccessGate + ModuleShell); der Zurück-Link nutzt das URL-Segment und darf bleiben. Formular custom-module-form-modal.tsx: Optionen aus dem Store (ensureLoaded beim Öffnen) statt CUSTOM_MODULE_CATEGORIES, Rückfall auf CUSTOM_MODULE_CATEGORIES nur solange der Store leer ist; Vorbelegung bleibt CUSTOM_MODULE_CATEGORY. Freigaben-Matrix braucht keine Änderung (Server sortiert, Beschriftung über useCategoryLabel); Matrix-Test muss weiter grün sein.
Texte: neue Schlüssel unter adminModules (categoriesLink sowie Bereich categories mit allen Seiten- und Dialogtexten) in de.json UND en.json mit identischer Schlüsselmenge; Deutsch mit echten Umlauten und „Sie“, Englisch sachlich; keines der Wörter „Mandant“ oder „tenant“ in Texten. Meldet der Umlaut-Wächter ein korrektes Wort, es in die Erlaubnisliste von umlaut-dictionary.ts aufnehmen. Tests in categories-page.test.tsx für die Fälle aus behavior (API-Helfer und Stores als Modul mocken wie in grants-matrix.test.tsx).
CHANGELOG.md unter „Unveröffentlicht → Neu“ ein Absatz in Alltagssprache (wo die Seite liegt, was Administratoren tun können, dass beim Löschen alle Einträge in eine gewählte Kategorie wandern und „Eigene Module“ nicht löschbar ist, dass Seitenleiste und Marktplatz der Reihenfolge folgen, dass Benutzer für ihre eigenen Einträge jede Kategorie wählen können). docs/anleitung-administration.md: neuer Abschnitt „### Kategorien“ in Kapitel 5 nach „Freigaben-Matrix“ (Anlegen, Umbenennen, Reihenfolge, Zuordnen, Sortieren, Löschen mit Ziel inkl. persönlicher Einträge, „Eigene Module“ nicht löschbar, alte Lesezeichen funktionieren weiter); docs/anleitung-anwender.md: Satz zu „Eigene Module … ganz unten“ anpassen (Reihenfolge legt der Administrator fest, standardmäßig unten; Auswahl umfasst alle Kategorien).
Abschluss: komplette Suites, tsc beider Apps, `biome check` auf alle NEUEN Dateien und `biome lint` auf die berührten bestehenden Dateien (diese haben schon heute Format-/Import-Abweichungen; nicht ganze Altdateien umformatieren, damit der Diff klein bleibt), dann `docker compose up -d --build api web` und prüfen, dass beide Dienste laufen und die API ohne Migrationsfehler startet. Nicht pushen; Browserprüfung macht der Orchestrator.
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome check apps/api/src/module-categories "apps/web/src/app/(portal)/admin/modules/categories" apps/web/src/lib/module-categories-api.ts apps/web/src/lib/module-category-order.ts apps/web/src/lib/module-category-order.test.ts apps/web/src/lib/stores/module-category-store.ts && pnpm exec biome lint apps/api/src/custom-modules apps/api/src/module-registry/module-registry.controller.ts apps/api/src/groups/module-grants.controller.ts "apps/web/src/app/(portal)/admin/modules/page.tsx" "apps/web/src/app/(portal)/marketplace/page.tsx" "apps/web/src/app/(portal)/modules/[category]/page.tsx" apps/web/src/components/layout/sidebar.tsx apps/web/src/components/custom-modules/custom-module-form-modal.tsx apps/web/src/lib/use-category-label.ts && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.adminModules&&de.adminModules.categories,"c",{}),b=w(en.adminModules&&en.adminModules.categories,"c",{});if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && grep -q "Kategorien" CHANGELOG.md && grep -q "### Kategorien" docs/anleitung-administration.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web</automated>
</verify>
<done>Administrator → Module → „Kategorien“ ist erreichbar und deckt Anlegen, Umbenennen, Sortieren, Löschen mit Zielauswahl, Zuordnen und Sortieren der Einträge ab; Marktplatz, Kategorieseite, Matrix und Formular folgen den eingestellten Kategorien; Texte de/en vollständig ohne „Mandant/tenant“; CHANGELOG und Handbuch ergänzt; beide Suites, tsc und biome grün; api und web neu gebaut und laufend; nichts gepusht.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → API /module-categories | Eingaben (Name, Kennungen, Modul-IDs, moveTo) sind unvertraut |
| Organisation A ↔ Organisation B | Kategorien und Zuordnungen sind je Organisation getrennt (tenantId + RLS) |
| Benutzer ↔ Administrator | Nur Administratoren ändern Kategorien; persönliche eigene Module bleiben für andere unsichtbar |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-387-01 | Elevation of Privilege | module-categories.controller.ts Schreibrouten + overview | high | mitigate | @UseGuards(RolesGuard) + @Roles(ADMIN, SUPER_ADMIN) je Methode; Controller-Spec prüft die Metadaten; GET '' liefert nur Kennung/Name/Reihenfolge |
| T-387-02 | Information Disclosure | ModuleCategory, ModuleCategoryPlacement | high | mitigate | tenant_isolation_policy mit FORCE RLS in Migration 20261003120000; forTenant/withTenantTransaction je Methode; tenantId zusätzlich im where; rls-coverage + rls-access-inventory grün |
| T-387-03 | Information Disclosure | getOverview / Löschen mit Verschieben | medium | mitigate | Übersicht nennt für persönliche eigene Module nur eine Anzahl (personalCount), nie Name oder Adresse; assign/reorderItems akzeptieren nur gemeinsame eigene Module (persönliche 404) |
| T-387-04 | Tampering | assign/reorderItems mit fremden IDs | medium | mitigate | Modul-ID gegen Katalog, eigene Module mit where {id, tenantId, ownerUserId: null}; reorderItems verlangt exakt die Menge der Einträge der Kategorie, sonst 400 |
| T-387-05 | Denial of Service | remove ohne Ziel / Datenverlust | medium | mitigate | Nicht leere Kategorie ohne moveTo → 409, nichts gelöscht; Verschieben und Löschen in einer Transaktion; „Eigene Module“ (isSystem) nicht löschbar |
| T-387-06 | Tampering | Kennung als URL-Segment | low | mitigate | Kennung serverseitig aus dem Namen gebildet (a-z0-9-), kollisionsfrei gegen Modul-Slugs und „custom“; Kennungen in DTOs per Regex geprüft |
| T-387-SC | Tampering | npm/pip/cargo installs | high | accept | Keine neuen Pakete in diesem Auftrag (nur vorhandene Abhängigkeiten) |
</threat_model>
<verification>
- Alle drei automatisierten Prüfungen grün; vollständige API- und Web-Suite grün.
- Migration lokal angewendet, FORCE RLS auf beiden neuen Tabellen.
- docker compose: api und web neu gebaut und laufend.
- Browserprüfung (Orchestrator, dunkel): Kategorie anlegen, Modul hineinschieben, umbenennen, sortieren, nicht leere Kategorie löschen mit Ziel; Seitenleiste und Marktplatz folgen; alte Modul-Adresse öffnet weiter.
</verification>
<success_criteria>
- Administratoren pflegen Kategorien vollständig über die neue Seite; kein Modul geht beim Löschen verloren.
- Wirksame Kategorie und Reihenfolge gelten in Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und Formular für eigene Module.
- Module.category bleibt unangetastet; Standardkategorien bleiben übersetzt, solange sie nicht umbenannt sind.
- RLS-Gates, Inventar-Doku, CHANGELOG und Handbuch aktuell; nichts gepusht.
</success_criteria>
<output>
Create `.planning/quick/261003-387-kategorien-durch-admins-bearbeitbar-umbe/261003-387-SUMMARY.md` when done (inkl. der Entscheidungen E-01 bis E-09 und des Befunds zur Slug-Auflösung von /modules/[category]/[moduleSlug]).
</output>
@@ -0,0 +1,141 @@
---
phase: quick-261003-387
plan: 01
subsystem: modules
tags: [module-categories, admin, sidebar, marketplace, rls, prisma, nestjs, nextjs]
status: complete
requirements: [QUICK-261003-387]
completed: 2026-10-03
duration: 23 min
commits: 3
plan_head_before: 53a49a109fc70dab6d5acf43d41649939cb1251d
plan_head_after: f2c0a896e840493d56305726ecaf98eb92a86889
actuals:
tokens: 215000
tasks: 3
commits: 3
key-files:
created:
- apps/api/prisma/migrations/20261003120000_module_categories/migration.sql
- apps/api/src/module-categories/module-categories.service.ts
- apps/api/src/module-categories/module-categories.controller.ts
- apps/api/src/module-categories/module-categories.module.ts
- apps/api/src/module-categories/dto/module-category.dto.ts
- apps/api/src/module-categories/module-categories.service.spec.ts
- apps/api/src/module-categories/module-categories.controller.spec.ts
- apps/api/src/module-categories/module-categories.fake-prisma.ts
- apps/api/src/module-registry/module-registry.controller.categories.spec.ts
- apps/web/src/app/(portal)/admin/modules/categories/page.tsx
- apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx
- apps/web/src/lib/module-category-order.ts
- apps/web/src/lib/stores/module-category-store.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/groups/module-grants.controller.ts
- apps/api/src/custom-modules/custom-modules.service.ts
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/lib/use-category-label.ts
- apps/web/src/lib/module-categories-api.ts
- apps/web/src/app/(portal)/marketplace/page.tsx
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx
- docs/mandantentrennung-zugriffsklassifikation.md
- CHANGELOG.md
---
# Quick 261003-387: Modulkategorien durch Administratoren bearbeitbar
Administratoren pflegen die Modulkategorien ihrer Organisation jetzt selbst (Administrator → Module → „Kategorien“): anlegen, umbenennen, sortieren, Module und gemeinsame eigene Module zuordnen und sortieren, löschen mit Zielkategorie. Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und das Formular für eigene Module folgen der Einstellung.
## Was gebaut wurde
**Task 1 (Tracer, 8ec116c):** Tabellen `ModuleCategory` und `ModuleCategoryPlacement` mit `FORCE ROW LEVEL SECURITY` und `tenant_isolation_policy`, Spalte `CustomModule.sortOrder`; Migration lokal angewendet (`prisma migrate diff` gegen die Datenbank ist leer). `ModuleCategoriesService` mit Grundbestand je Organisation (`ensure`), `listCategories`, `applyToModules`, `rename`. `GET /module-categories` (jeder Angemeldete), `PATCH :key` (nur Administratoren). `/modules/active` liefert die wirksame Kategorie samt `sortOrder`. Web: API-Client, Kategorienspeicher, `module-category-order.ts`, `useCategoryLabel` (gespeicherter Name vor Übersetzung vor Kennung), Seitenleiste ordnet Gruppen und Einträge nach dem Speicher.
**Task 2 (API, af1878d):** `create`, `reorderCategories`, `assign`, `reorderItems`, `remove`, `getOverview`, `assertCategoryKey`; Controller mit Routen `overview`, `POST`, `order`, `assignment` vor `:key`, dazu `:key/items` und `DELETE :key?moveTo=`. Überlagerung in `GET /modules`, `/modules/catalog` und `/module-grants/matrix`. Eigene Module prüfen die Kategorie gegen die Organisation (400), das DTO prüft nur das Format der Kennung. Zugriffsinventar (Zeilen, Bereichszeile, Summe, Paarzahl) nachgezogen.
**Task 3 (Web, f2c0a89):** Verwaltungsseite `/admin/modules/categories` mit Löschdialog (Zielauswahl), Knopf „Kategorien“ neben „Freigaben-Matrix“, Kategorie-Plakette mit Anzeigenamen, Marktplatz-Chips in Kategorienreihenfolge, Kategorieseite mit gespeichertem Namen, Formular mit allen Kategorien der Organisation (vorhandener Wert bleibt Option), Texte de/en, CHANGELOG, Handbuch Administration und Anwender.
## Entscheidungen E-01 bis E-09 (wie im Plan umgesetzt)
- **E-01** `ModuleCategory {id, tenantId, key, name?, sortOrder, isSystem}`, eindeutig `(tenantId, key)`; Kennung unveränderlich, `isSystem` nur für `custom-modules`.
- **E-02** `ModuleCategoryPlacement` je `(tenantId, moduleId)`; wirksam ist die Zuordnung, sonst `Module.category`. `Module.category` wird nie geschrieben (durch Test belegt).
- **E-03** Gemeinsame eigene Module: `CustomModule.category` und neue Spalte `sortOrder` direkt; persönliche bekommen nie eine `sortOrder`.
- **E-04** Grundbestand beim ersten Lesen, ohne SQL-Rückfüllung; fehlende benutzte Kennungen werden hinten nachgelegt; eine gelöschte Standardkategorie kommt nur zurück, wenn ein Modul sie wirksam benutzt.
- **E-05** Löschen verschiebt Marktplatz-Module (Zuordnung umgeschrieben bzw. neu angelegt), gemeinsame UND persönliche eigene Module in einer Transaktion; ohne Ziel 409.
- **E-06** Kennung aus dem Namen (ä→ae …, höchstens 40 Zeichen, leer → `kategorie`), bei Kollision mit Kennung, Modul-Slug oder `custom` `-2`, `-3` …
- **E-07** Überlagerung serverseitig in allen Modullisten (Kategorie-Reihenfolge, dann `sortOrder` mit null zuletzt, dann Name).
- **E-08** Seitenleiste: `sortOrder` aufsteigend, null zuletzt, eingebaut vor eigenem, dann Name.
- **E-09** Benutzer-Zugriffsdialog unverändert; keine „Auf Standardnamen zurücksetzen“-Funktion.
Zusätzlich festgelegt (Planer-Ermessen): Die letzte verbleibende Kategorie kann nie gelöscht werden, weil „Eigene Module“ nicht löschbar ist; dadurch fällt `ensure` nie auf den Standardbestand zurück.
## Befund zur Slug-Auflösung von /modules/[category]/[moduleSlug]
Die Seite löst das Modul ausschließlich über `moduleSlug` auf (`ModuleShell` → `ModuleAccessGate`); `category` wird nur für den „Zurück“-Link verwendet. Alte Adressen `/modules/<alte-kategorie>/<slug>` öffnen das Modul deshalb weiter. Einzige Folge: Der „Zurück“-Link einer solchen alten Adresse führt auf `/modules/<alte-kategorie>`, und diese Kategorieseite zeigt dann „Keine aktiven Module in dieser Kategorie“ (kein 404, nur leer). Der Plan lässt den Link bewusst bestehen; nicht geändert.
## Abweichungen vom Plan
### Automatisch behoben
**1. [Rule 3 - Blockierend] Kategorienspeicher übernahm eine unerwartete Antwort**
- **Gefunden bei:** Task 3, `tenant-selector.test.tsx` (zwei Tests rot: `categories.find is not a function`)
- **Problem:** Der Test-Fetch liefert für jede Adresse dieselben Daten; der Speicher übernahm sie als Kategorienliste.
- **Lösung:** `load()` übernimmt nur ein Feld (`Array.isArray`), sonst bleibt der bisherige Stand.
- **Dateien:** `apps/web/src/lib/stores/module-category-store.ts`
**2. [Rule 1 - Bug] Bestehende Tests an das neue Verhalten angepasst**
- `custom-module.dto.spec.ts`: „lehnt eine unbekannte Kategorie ab“ galt nur für die feste Liste; das DTO prüft jetzt das Format, die Existenz prüft der Dienst (neuer Dienst-Test, 400).
- `sidebar.test.tsx`: Kategorienspeicher als Fixture (Gruppenreihenfolge kommt nicht mehr aus dem festen Sonderfall „Eigene Module zuletzt“).
- `umlaut-dictionary.ts`: „Neuer“ in die Erlaubnisliste (korrektes Deutsch).
### Ergänzungen über den Plan hinaus
- `module-categories.fake-prisma.ts` (Speicher-Attrappe für den Dienst-Test) und `module-registry.controller.categories.spec.ts` (belegt die Überlagerung in `/modules`, `/active`, `/catalog` und Matrix).
- Zusätzliche Tests in `custom-modules-page.test.tsx` (Formular-Optionen) und `marketplace-filters.test.tsx` (Chip-Reihenfolge).
## Prüfergebnisse (ehrlich)
| Prüfung | Ergebnis |
|---|---|
| API-Tests (`pnpm --filter @tessera/api test`) | 125 Dateien, **2205 Tests, alle grün** |
| Web-Tests (`pnpm --filter @tessera/web test`) | 126 Dateien, **1361 Tests, alle grün** |
| `tsc --noEmit` api / web | beide fehlerfrei |
| `biome check` auf alle neuen Dateien | sauber (0 Fehler, 0 Warnungen) |
| `biome lint` auf berührte bestehende Dateien | 0 Fehler; 1 bereits vorhandene Warnung (`sidebar.tsx`, a11y `role="group"`), nicht von dieser Änderung |
| `rls-coverage` + `rls-access-inventory` | grün (35 Tests) |
| Routenreihenfolge (statisch vor `:key`) | Skript aus dem Plan grün, zusätzlich Controller-Test |
| Migration lokal | angewendet, `prisma migrate diff` leer, beide Tabellen `relrowsecurity` + `relforcerowsecurity` |
| `docker compose up -d --build api web` | api (healthy) und web laufen, API startet ohne Migrationsfehler, 9 Logzeilen zu `/module-categories`-Routen, `GET /module-categories` ohne Anmeldung → 401, Startseite antwortet |
Nicht geprüft: Anmeldung und Browserablauf (macht der Orchestrator). Keine Prüfung gegen die laufende API mit Administrator-Anmeldung durchgeführt.
## Zugriffsinventar
Vier neue Zeilen für `module-categories.service.ts` (`moduleCategory`, `moduleCategoryPlacement`, `customModule` gebunden, `module` ungebunden). Bereichszeile `module-categories` 4/22/0 (mit der Gate-Schleife nachgemessen), Summe 61/273/8 → 65/295/8, Paarzahl 96 → 100 (59 muss-mandantengebunden, 23 keine-mandantengebundene-tabelle, 16 beides, 2 bewusst-uebergreifend).
## Bekannte Stubs
Keine.
## Threat Flags
Keine neue Angriffsfläche außerhalb des Plan-Bedrohungsmodells. T-387-01 bis T-387-06 sind umgesetzt: `@Roles(ADMIN, SUPER_ADMIN)` je Verwaltungsmethode (Controller-Test, auch „keine Methode ohne Rolle außer `list`“), FORCE RLS plus `tenantId` in jedem `where`, persönliche Einträge nur als Anzahl, `assign`/`reorderItems` nur für gemeinsame Einträge (persönliche und fremde: 404/400), 409 ohne Ziel und Transaktion beim Löschen, Kennungen per Regex im DTO.
## Offene Hinweise
- Das Ändern der Kategorie eines gemeinsamen eigenen Moduls über dessen Formular setzt `sortOrder` nicht zurück; die alte Position kann in der neuen Kategorie also mitten in der Reihenfolge landen, bis ein Administrator neu sortiert. Zuordnen über die Kategorienseite hängt dagegen hinten an.
- Gepusht wurde nichts; `STATE.md`, `PLAN.md` und diese Datei sind nicht committet (Vorgabe).
## Self-Check: PASSED
- Neue Dateien vorhanden (Migration, Dienst, Controller, DTO, Seite, Tests): bestätigt über `git diff --stat` (43 Dateien).
- Commits vorhanden: 8ec116c, af1878d, f2c0a89 (`git rev-list --count 53a49a1..HEAD` = 3).
## Browser-Prüfung (Orchestrator, 03.10., lokal)
- Administrator → Module → Kategorien: alle Bereiche mit Modulen, „Eigene Module“ nicht löschbar.
- Neue Kategorie „Server“ angelegt, Proxmox per Auswahl hinein, nach ganz oben sortiert → Seitenleiste folgt.
- Umbenannt in „Serverraum“ (Kennung bleibt `server`).
- „Infrastruktur“ gelöscht mit Ziel „Serverraum“ → Nextcloud-Status umgezogen.
- Marktplatz und Freigaben-Matrix zeigen dieselbe Einteilung und Reihenfolge.
- Alte Adressen /modules/infrastructure/proxmox und …/nextcloud-status öffnen weiter das Modul.