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

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

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