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
@@ -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. |