--- phase: 15-modul-berechtigungen-gruppen-user-grants plan: 05 subsystem: dashboard tags: [nestjs, prisma, dashboard, module-access, vitest] # Dependency graph requires: - phase: 15-modul-berechtigungen-gruppen-user-grants plan: 01 provides: ModuleAccessService.getAccessibleModuleIds als Single Source of Truth für Modulzugriff (D-01) provides: - WIDGET_MODULE_MAP/getModuleSlugForWidgetType — statische Widget-Typ-zu-Modul-Zuordnung (D-22), am Ende dieser Phase bewusst leer - DashboardService.getWidgets(userId, tenantId, role) mit Modulfilter über ModuleAccessService - Erste Testsuite für DashboardService überhaupt (dashboard.service.spec.ts) affects: [] actuals: tokens: 4000 tasks: 2 commits: 3 tech-stack: added: [] patterns: - "vi.hoisted() für eine mutierbare Mock-Referenz, wenn ein per-Testfall veränderbarer Modulinhalt über vi.mock benötigt wird — vi.mock selbst wird von Vitest an den Dateianfang gehoben, ein normaler top-level const wäre zur Factory-Ausführungszeit noch nicht initialisiert" - "Bedingter Zugriffs-Lookup: erst die bestehende Query unverändert ausführen, dann nur bei mindestens einem Treffer in der Zuordnungstabelle die teurere Zugriffsauflösung aufrufen — hält den Leerlauf-Fall (heute: immer) auf Kosten einer einzigen bestehenden Query" key-files: created: - apps/api/src/dashboard/widget-module-map.ts - apps/api/src/dashboard/dashboard.service.spec.ts modified: - apps/api/src/dashboard/dashboard.service.ts - apps/api/src/dashboard/dashboard.controller.ts - apps/api/src/dashboard/dashboard.module.ts key-decisions: - "WIDGET_MODULE_MAP bleibt am Ende dieser Phase bewusst leer (D-22, 15-RESEARCH.md Pitfall 5) — alle acht bestehenden Widget-Typen sind Plattform-Widgets ohne Modulbezug, kein neuer Widget-Typ entsteht in diesem Plan" - "Code-Konstante statt Datenbankspalte auf WidgetInstance — eine Migration auf einer bereits befüllten Tabelle für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als eine TypeScript-Konstante mit identischer Aussagekraft" requirements-completed: [PERM-07] coverage: - id: D1 description: "WIDGET_MODULE_MAP + getModuleSlugForWidgetType als einziger Lesezugriff auf die Zuordnungstabelle, ohne Schemaänderung und ohne neuen Widget-Typ" requirement: "PERM-07" verification: - kind: unit ref: "pnpm --filter @tessera/api run type-check — fehlerfrei" status: pass - kind: manual_procedural ref: "grep -c widgetType apps/api/prisma/schema.prisma und ls apps/api/prisma/migrations/ | wc -l vor/nach diesem Task unverändert (1 bzw. 25)" status: pass human_judgment: false - id: D2 description: "DashboardService.getWidgets(userId, tenantId, role) filtert Widgets eines gesperrten Moduls serverseitig heraus, mit genau einem Aufruf von ModuleAccessService.getAccessibleModuleIds und Fail-Closed bei unauflösbarem Modul-Slug" requirement: "PERM-07" verification: - kind: unit ref: "apps/api/src/dashboard/dashboard.service.spec.ts (8 Tests, deckt jeden -Fall inkl. adjacency/empty/ordering/idempotency und D-03-ADMIN-Kurzschluss ab)" status: pass - kind: e2e ref: "curl gegen laufende lokale API + DB-Container: mit leerer WIDGET_MODULE_MAP liefert GET /dashboard/widgets für einen eigens angelegten Testbenutzer dieselben 2 Widgets (clock, search), die vor dem Aufruf angelegt wurden — kein Bestandswidget verschwindet" status: pass human_judgment: false duration: 9min completed: 2026-08-04 status: complete --- # Phase 15 Plan 05: Dashboard-Widget-Modulfilterung Summary **Statische `WIDGET_MODULE_MAP` (bewusst leer) plus `DashboardService.getWidgets(userId, tenantId, role)`, das Widgets eines für den Benutzer gesperrten Moduls über `ModuleAccessService.getAccessibleModuleIds` (D-01, aus Plan 15-01) herausfiltert, mit Fail-Closed bei unauflösbarem Modul-Slug — die erste Testsuite für `DashboardService` überhaupt, mit End-to-End-Nachweis gegen die laufende lokale API.** ## Performance - **Duration:** 9 min - **Started:** 2026-08-04T13:28:00Z - **Completed:** 2026-08-04T13:37:28Z - **Tasks:** 2 - **Files modified:** 5 ## Accomplishments - `apps/api/src/dashboard/widget-module-map.ts` (neu): exportiert `WIDGET_MODULE_MAP` (`Readonly>`) und `getModuleSlugForWidgetType`, mit einem Blockkommentar, der Zweck, Schlüssel/Werte-Bedeutung und die bewusste Entscheidung gegen eine Schemaspalte festhält. Die Tabelle ist zum Abschluss dieser Phase leer — alle acht heute registrierten Widget-Typen (clock/search/calendar/note/calculator/favorites/link/stopwatch) sind Plattform-Widgets ohne Modulbezug - `DashboardService.getWidgets` erweitert von `(userId)` auf `(userId, tenantId, role: Role)`: die bestehende `findMany`-Query mit `orderBy: { createdAt: 'asc' }` läuft unverändert zuerst; nur wenn mindestens ein geladenes Widget in `WIDGET_MODULE_MAP` steht, wird `ModuleAccessService.getAccessibleModuleIds` genau einmal aufgerufen (kein Lookup je Widget) und die betroffenen Modul-Slugs über eine einzelne `module.findMany`-Query auf IDs abgebildet; Widgets ohne Tabelleneintrag bleiben immer erhalten, Widgets mit unauflösbarem Modul-Slug werden entfernt (Fail-Closed) - `DashboardController.getWidgets` reicht `tenantId` und `role` (aus `req.user`, JWT-Herkunft) zusätzlich zu `userId` durch - `DashboardModule` importiert `ModuleRegistryModule`, damit `ModuleAccessService` injizierbar wird - `dashboard.service.spec.ts` (neu, 8 Tests): deckt jeden ``-Fall aus dem Plan ab — leere Tabelle liefert exakt die Prisma-Menge ohne Zugriffs-Lookup, sichtbar bei Zugriff, entfernt ohne Zugriff, D-03-ADMIN-Kurzschluss, Nicht-Tabellen-Typen bleiben immer erhalten (adjacency), Sortierung bleibt nach dem Filtern erhalten (ordering), zwei Aufrufe löschen keine Zeile (idempotency), Fail-Closed bei fehlendem `Module`-Datensatz zu einem eingetragenen Slug. `WIDGET_MODULE_MAP` wird je Testfall über eine mit `vi.hoisted()` deklarierte, gemockte Objektreferenz gesteuert (plain `const` wäre zur Ausführungszeit der gehobenen `vi.mock`-Factory noch nicht initialisiert) - End-to-End-Nachweis gegen die laufende lokale API (`pnpm start:dev` gegen den DB-Container, wie in 15-01/15-02): ein eigens per `psql` angelegter Testbenutzer mit zwei Plattform-Widgets (`clock`, `search`) erhält bei leerer `WIDGET_MODULE_MAP` über `GET /dashboard/widgets` exakt dieselben 2 Widgets zurück — Testbenutzer und -Widgets nach dem Nachweis wieder aus der DB entfernt ## Task Commits Jeder Task wurde atomar committet: 1. **Task 1: Statische Widget-Modul-Zuordnung anlegen** - `954cd6e` (feat) 2. **Task 2: getWidgets filtert über dieselbe Zugriffsauflösung wie Guard und Sidebar** - `d0ff6f0` (test, RED) + `0ff46ac` (feat, GREEN) **Plan metadata:** siehe Commit dieser SUMMARY.md (docs: complete plan) ## Files Created/Modified - `apps/api/src/dashboard/widget-module-map.ts` - `WIDGET_MODULE_MAP` (leer) + `getModuleSlugForWidgetType` (neu) - `apps/api/src/dashboard/dashboard.service.ts` - `getWidgets` um Modulfilter erweitert, `ModuleAccessService` injiziert - `apps/api/src/dashboard/dashboard.controller.ts` - `getWidgets`-Handler reicht `tenantId`/`role` durch - `apps/api/src/dashboard/dashboard.module.ts` - Import von `ModuleRegistryModule` - `apps/api/src/dashboard/dashboard.service.spec.ts` - 8 Tests (neu — erste Testsuite für `DashboardService`) ## Decisions Made - `WIDGET_MODULE_MAP` bleibt bewusst leer (kein neuer Widget-Typ, keine Schemaänderung) — wie im Plan-Objective vorgegeben, kein Abweichen - `vi.hoisted()` für die je-Testfall mutierbare `WIDGET_MODULE_MAP`-Mock-Referenz — nicht explizit im Plan-Action-Text benannt, aber notwendig, weil Vitest `vi.mock`-Aufrufe an den Dateianfang hebt und ein normaler `const mockMap = {}` zur Ausführungszeit der Factory noch nicht initialisiert wäre (verifiziert: erster Testlauf ohne `vi.hoisted()` schlug mit `ReferenceError: Cannot access 'mockMap' before initialization` fehl) ## Deviations from Plan None - plan wie geschrieben ausgeführt. ## Issues Encountered - Beim End-to-End-Nachweis lief zunächst ein API-Prozess aus einer vorherigen Verifikationsrunde ohne `JWT_SECRET` weiter im Hintergrund (Port 3001 belegt, `EADDRINUSE`); nach `pkill -f "nest start --watch"` und Prüfung, dass Port 3001 wieder frei ist, startete der Nachweis sauber durch — kein Einfluss auf den eigentlichen Code oder die Testsuite - Es existierte kein Bestandsbenutzer mit bekanntem Passwort in der lokalen DB (nur der SUPER_ADMIN aus einer vorherigen Sitzung, `AdminSeedService` übersprang das Seeding mangels `TESSERA_ADMIN_*`-Env-Vars). Für den E2E-Nachweis wurde per `psql` ein isolierter Testbenutzer (`argon2`-Hash über `node -e`) samt zwei Widgets angelegt, gegen die laufende lokale API eingeloggt und nach dem Nachweis vollständig wieder gelöscht — analog zum in 15-02 etablierten Verfahren für den Fremdmandanten-Nachweis ## User Setup Required None - keine externe Service-Konfiguration nötig. ## Next Phase Readiness - Die Mechanik aus D-22 ist vollständig, getestet und end-to-end bewiesen (PERM-07) - Bei leerer Zuordnungstabelle verändert sich für keinen Bestandsbenutzer etwas — durch Unit-Test (`empty`-Fall) und E2E-Nachweis doppelt belegt - Sobald ein künftiges modulgebundenes Widget entsteht, genügt ein Eintrag in `WIDGET_MODULE_MAP` — die Filterlogik selbst braucht keine Änderung - Kein offener Blocker aus diesem Plan - Voller API-Testsuite: 457/457 grün (449 aus 15-02 + 8 neue) --- *Phase: 15-modul-berechtigungen-gruppen-user-grants* *Completed: 2026-08-04* ## Self-Check: PASSED Alle in dieser SUMMARY genannten Dateien existieren auf der Platte, alle genannten Commit-Hashes (`954cd6e`, `d0ff6f0`, `0ff46ac`) sind im Git-Log auffindbar.