--- phase: quick-260923-ad9 verified: 2026-09-23T08:30:00Z status: human_needed score: 10/10 must-haves verified covered_files: - ".planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-PLAN.md" - ".planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-SUMMARY.md" - "CHANGELOG.md" - "apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql" - "apps/api/prisma/schema.prisma" - "apps/api/src/dashboard/dashboard.controller.spec.ts" - "apps/api/src/dashboard/dashboard.controller.ts" - "apps/api/src/dashboard/dashboard.service.spec.ts" - "apps/api/src/dashboard/dashboard.service.ts" - "apps/api/src/dashboard/dto/create-widget.dto.ts" - "apps/api/src/dashboard/dto/rename-dashboard.dto.ts" - "apps/api/src/dashboard/dto/reorder-dashboards.dto.ts" - "apps/api/src/dashboard/dto/save-layout.dto.ts" - "apps/api/src/dashboard/widget-module-map.spec.ts" - "apps/web/src/app/(portal)/page.test.tsx" - "apps/web/src/app/(portal)/page.tsx" - "apps/web/src/app/(portal)/settings/dashboard/page.tsx" - "apps/web/src/components/dashboard/dashboard-tabs.test.tsx" - "apps/web/src/components/dashboard/dashboard-tabs.tsx" - "apps/web/src/lib/dashboard-api.ts" - "apps/web/src/lib/stores/dashboard-store.test.ts" - "apps/web/src/lib/stores/dashboard-store.ts" - "apps/web/src/messages/de.json" - "apps/web/src/messages/en.json" - "docs/anleitung-anwender.md" - "docs/mandantentrennung-zugriffsklassifikation.md" covered_digest: "v1:sha256:9504969e14709ebba347c4443d9b00de67a4cbdc10aa49695103728d822abec9" behavior_unverified: 0 overrides_applied: 0 human_verification: - test: "Browser-Rundgang Punkte 1-9 aus dem SUMMARY (Anmelden, Reiter anlegen, Kachel setzen und Reiter wechseln, ungespeichert wechseln, per Ziehen an erste Stelle, umbenennen, löschen, letzter Reiter, Raster gegenmessen)" expected: "Alle neun Punkte laufen wie im SUMMARY beschrieben, insbesondere Punkt 9 (Raster reagiert unverändert, D-06)" why_human: "Erfordert einen laufenden Container mit echtem Datenbestand und echte Maus-Interaktion (Drag-and-Drop, visuelle Prüfung des Rasterverhaltens) — kann nicht durch Grep/Codeanalyse ersetzt werden. Der Nutzer baut/startet die Container selbst (Projektregel: kein Docker-Deploy durch Claude auf Testservern/lokal)." --- # Quick-Aufgabe 260923-ad9: Dashboard-Reiter — mehrere Dashboards je Benutzer Verification Report **Phase Goal:** Dashboard-Reiter — mehrere Dashboards je Benutzer, jeder Reiter mit eigenen Kacheln und eigener Anordnung; Reiter per Ziehen sortierbar; der erste Reiter ist der Standard und wird beim Öffnen geladen; anlegen, umbenennen, löschen (der letzte bleibt); bestehende Dashboards werden per Migration zum ersten Reiter, ohne dass jemand Kacheln verliert. **Verified:** 2026-09-23T08:30:00Z **Status:** human_needed **Re-verification:** No — initial verification ## Goal Achievement ### Observable Truths | # | Truth | Status | Evidence | |---|-------|--------|----------| | 1 | Ein Benutzer hat mehrere Dashboards als Reiter, jeder mit eigenen Kacheln/Anordnung | ✓ VERIFIED | `Dashboard` model + `dashboardId` FK on `WidgetInstance`/`DashboardLayout` (schema.prisma:200-253); `getWidgets`/`getLayout`/`addWidget`/`saveLayout` all scope by `dashboardId`, not `userId` (dashboard.service.ts); `dashboard-store.ts` `selectDashboard` fully replaces `layouts`/`widgets` on tab switch, tests 9/10 in `dashboard-store.test.ts` confirm no merging | | 2 | Erster Reiter (Position 0) ist Standard, wird beim Öffnen geladen, kein separates Standard-Feld | ✓ VERIFIED | `listDashboards` orders by `position: 'asc'`, `loadDashboard()` in store takes `dashboards[0]`; no `isDefault`/`starred` field anywhere in schema; store test 7 confirms | | 3 | Reiter per Maus ziehbar, Reihenfolge bleibt nach Neuladen; Klick ohne Ziehen wechselt nur | ✓ VERIFIED | `dashboard-tabs.tsx` pointer handlers with 4px threshold, `computeReorderedIds`; `PUT /dashboard/tabs/order` persists via `reorderDashboards`; tabs tests 13-21 cover click-only, drag right/left, preview/abort, first-tab promotion, save-failure rollback | | 4 | Anlegen (Namensvergabe füllt Lücken), Umbenennen, Löschen (letzter bleibt, Server + UI) | ✓ VERIFIED | `createDashboard` gap-filling name loop (dashboard.service.ts); `deleteDashboard` throws `ConflictException` at count≤1; `dashboard-tabs.tsx` omits delete button when `dashboards.length <= 1`; service tests for last-tab-conflict and controller/service tests for rename/delete found | | 5 | Migration: niemand verliert Kacheln/Anordnung; Benutzer ohne Bestand bekommt leeren Reiter beim ersten Öffnen | ✓ VERIFIED | Migration backfills exactly one `Dashboard` row per user with widgets-or-layout via `DISTINCT ON`, runs backfill before FKs; live-DB query returned 0 orphaned widgets, 0 orphaned layouts, 2 dashboards (matches pre-measured user count), 0 dashboards off position 0 (re-run by this verifier, see below); `listDashboards` auto-creates one tab for a user with none | | 6 | Fail-closed gegen fremde Reiter auf allen sieben Wegen (read/write) | ✓ VERIFIED | `assertOwnedDashboard` called first in `getLayout`, `saveLayout`, `getWidgets`, `addWidget`, `renameDashboard`, `deleteDashboard`; `reorderDashboards` uses exact-match-in-transaction (same `NotFoundException`/`BadRequestException`, no existence oracle); dedicated tests found for all 7+ paths (grep: 8 "fremd/NotFound" test names across the exact methods) | | 7 | Neue Tabelle trägt Mandant+Zeilenschutz (Form aus 20260911120000); RLS-Wächter grün; Zugriffsklassifikation nachgeführt | ✓ VERIFIED | Migration: `ENABLE`+`FORCE ROW LEVEL SECURITY` + `tenant_isolation_policy` with tenant AND user dimension; `rls-coverage.spec.ts` (generic schema/migration scanner, not hardcoded) passed 5/5 in this verifier's own full test run; `docs/mandantentrennung-zugriffsklassifikation.md` recomputed rows for `dashboard`/`dashboardLayout`/`widgetInstance` pairs, region and sum lines | | 8 | Raster unverändert: FREE_PLACEMENT_COMPACTOR/preventCollision, Breitenmessung aus quick-260922-vdk | ✓ VERIFIED | `git diff 84fe73e..HEAD --stat -- apps/web/src/components/dashboard/` shows only two NEW files (`dashboard-tabs.tsx`/`.test.tsx`); `dashboard-grid.tsx` has zero diff; `FREE_PLACEMENT_COMPACTOR`/`preventCollision` present unchanged; `dashboard-grid.test.tsx` stayed at 12 tests | | 9 | Keine neue Abhängigkeit für das Ziehen (Pointer-Events wie xframe-config-form.tsx) | ✓ VERIFIED | `git diff 84fe73e..HEAD -- apps/web/package.json apps/api/package.json` is empty (no diff at all); `dashboard-tabs.tsx` uses native `PointerEvent`/`setPointerCapture` with jsdom guard, same pattern as `xframe-config-form.tsx` | | 10 | Alle Tore grün mit den genannten Mindestzahlen | ✓ VERIFIED | Re-run by this verifier (not trusted from SUMMARY): api 1240/1240 tests in 77 files; web 693/693 tests in 82 files; `pnpm type-check` 4/4; `pnpm lint` 5/5 with exactly 53 warnings; `prisma migrate diff --exit-code` against live local DB returned "No difference detected." (exit 0) | **Score:** 10/10 truths verified (0 present, behavior-unverified) ### Required Artifacts | Artifact | Expected | Status | Details | |----------|----------|--------|---------| | `apps/api/prisma/schema.prisma` | `Dashboard` model, FK on WidgetInstance/DashboardLayout, no unique on position, DashboardLayout loses userId-unique | ✓ VERIFIED | Confirmed by direct read: `@@index([userId])`, `@@index([tenantId])`, no `@@unique`; `DashboardLayout.dashboardId @unique`, `userId` plain index | | `.../migrations/20260923120000_dashboard_tabs/migration.sql` | Hand-written, German header, RLS, backfill before FKs | ✓ VERIFIED | Confirmed by direct read: steps in the documented order, `DISTINCT ON` dedup for the "same user, two tenants" edge case on the INSERT (see minor note below) | | `apps/api/src/dashboard/dashboard.service.ts` | listDashboards/createDashboard/renameDashboard/deleteDashboard/reorderDashboards/assertOwnedDashboard; getLayout/saveLayout/getWidgets/addWidget per-tab | ✓ VERIFIED | All methods present, matches plan's documented locking/transaction reasoning | | `apps/api/src/dashboard/dashboard.controller.ts` | Five new `tabs` routes, `tabs/order` before `:id` routes | ✓ VERIFIED | Confirmed by direct read and by the passing source-order guard test in `dashboard.controller.spec.ts` | | `apps/api/src/dashboard/dto/` | rename/reorder DTOs with caps, extended save-layout/create-widget DTOs | ✓ VERIFIED | `RenameDashboardDto` (trim + Length(1,40)), `ReorderDashboardsDto` (ArrayMinSize/MaxSize(20)/Unique) | | `apps/api/src/dashboard/dashboard.controller.spec.ts` | NEW, incl. source-order guard | ✓ VERIFIED | File exists, 8 tests, guard test present and passing | | `apps/web/src/components/dashboard/dashboard-tabs.tsx` | NEW tab bar, pointer-drag pattern | ✓ VERIFIED | Confirmed by direct read; wired into `(portal)/page.tsx` | | `apps/web/src/lib/stores/dashboard-store.ts` | dashboards/activeDashboardId/select/create/rename/delete/reorder, dedupe-load, save-before-switch | ✓ VERIFIED | Confirmed by direct read | | `apps/web/src/messages/de.json` + `en.json` | `widgets.tabs.*` keys, real umlauts | ✓ VERIFIED | Confirmed keys present with real umlauts (ä/ö/ü/ß) | | `docs/mandantentrennung-zugriffsklassifikation.md` | new row, recomputed sums | ✓ VERIFIED | Confirmed by direct read; numbers are internally consistent and recomputed with rationale, not copy-pasted | | `docs/anleitung-anwender.md` + `CHANGELOG.md` | plain-language description | ✓ VERIFIED | Confirmed by direct read; real German, Sie-form, no jargon | ### Key Link Verification | From | To | Via | Status | Details | |------|-----|-----|--------|---------| | Open → `loadDashboard()` → `GET /dashboard/tabs` → first tab active → widgets+layout fetch | — | store→api→controller→service | ✓ WIRED | Confirmed end-to-end by reading `dashboard-store.ts` `loadDashboard`, `dashboard-api.ts`, controller, service | | Drag tabs → pointer events → `PUT /dashboard/tabs/order` → position rewrite in one transaction | — | tabs.tsx→store→api→service | ✓ WIRED | Confirmed; `reorderDashboards` service method matches `FavoritesService.reorder` pattern exactly | | Delete tab → `DELETE /dashboard/tabs/:id` → assertOwnedDashboard → last-tab reject → transactional cascade delete + position renumber | — | tabs.tsx→store→api→service | ✓ WIRED | Confirmed by direct read of `deleteDashboard` | | Add widget → store passes `activeDashboardId` → `POST /dashboard/widgets` with tab id → ownership check | — | store→api→service | ✓ WIRED | Confirmed `addWidget` in store reads `activeDashboardId`, service calls `assertOwnedDashboard` first | | New `Dashboard` model with `tenantId` → `rls-coverage.spec.ts` requires ENABLE+Policy | — | migration→generic scanner | ✓ WIRED | Confirmed test passed in this verifier's own run (5/5), scanner is generic (parses schema+migrations dynamically, not hardcoded per table) | | `tenantPrisma.dashboard`/`tx.dashboard` → `rls-access-inventory.spec.ts` → classification doc entry | — | service→inventory scanner→doc | ✓ WIRED | Confirmed test passed (30/30 in this verifier's run); doc row present with `gebunden` status | ### Data-Flow Trace (Level 4) | Artifact | Data Variable | Source | Produces Real Data | Status | |----------|---------------|--------|---------------------|--------| | `dashboard-tabs.tsx` `dashboards` prop | `useDashboardStore().dashboards` | `GET /dashboard/tabs` → Prisma query via `forTenant` | Yes | ✓ FLOWING | | `DashboardGrid` `layouts`/`widgets` props | store `layouts`/`widgets` | `GET /dashboard/layout`/`GET /dashboard/widgets` scoped by `activeDashboardId` | Yes | ✓ FLOWING | | Migration backfill counts (live DB) | `Dashboard`/`WidgetInstance`/`DashboardLayout` rows | Actual local Postgres container, re-queried by this verifier | Yes | ✓ FLOWING | ### Behavioral Spot-Checks | Behavior | Command | Result | Status | |----------|---------|--------|--------| | API full test suite (not filtered) | `pnpm --filter @tessera/api test` | 1240 tests, 77 files, all passed | ✓ PASS | | Web full test suite (not filtered) | `pnpm --filter @tessera/web test` | 693 tests, 82 files, all passed | ✓ PASS | | `pnpm type-check` | `pnpm type-check` | 4/4 successful (cached) | ✓ PASS | | `pnpm lint` | `pnpm lint` | 5/5 successful, exactly 53 warnings in web | ✓ PASS | | Migration applied + no drift vs. live local DB | `prisma migrate diff --exit-code` | "No difference detected.", exit 0 | ✓ PASS | | Backfill correctness (live DB re-query) | `SELECT ... kacheln_ohne_reiter, anordnungen_ohne_reiter, reiter, reiter_nicht_an_position_null` | `0, 0, 2, 0` | ✓ PASS | | No new drag/DnD dependency | `git diff 84fe73e..HEAD -- apps/web/package.json apps/api/package.json` | empty diff | ✓ PASS | | Grid component untouched | `git diff 84fe73e..HEAD --stat -- apps/web/src/components/dashboard/` | only 2 new files (`dashboard-tabs.*`), `dashboard-grid.tsx` absent from diff | ✓ PASS | ### Requirements Coverage This is a `/gsd-quick` task (no `.planning/REQUIREMENTS.md` entry expected/found for `QUICK-260923-AD9` — confirmed by grep, consistent with how quick tasks are tracked in this project). ### Anti-Patterns Found No `TBD`/`FIXME`/`XXX`/`TODO`/`HACK`/`PLACEHOLDER` markers found in any of the core changed files (`dashboard.service.ts`, `dashboard.controller.ts`, `dashboard-store.ts`, `dashboard-tabs.tsx`, `migration.sql`). No stub patterns (`return null`/empty-return handlers/hardcoded-empty props) found in the reviewed files — every prop and returned value traces to a real query or real store state. **Minor observation (not a blocker):** the migration's backfill INSERT (step 3) uses `DISTINCT ON ("userId")` to guarantee exactly one `Dashboard` row per user even in the theoretical "same user id, two tenant ids" case, exactly as the plan requires for that INSERT. However, the subsequent `UPDATE "WidgetInstance"`/`UPDATE "DashboardLayout"` backfill steps (4 and 5) join only on `d."userId" = wi."userId"`, not also on `tenantId` — in that same theoretical edge case, rows belonging to the "losing" tenant would be reassigned to the single `Dashboard` row's tenant. The plan's own task text explicitly scopes the dedup requirement ("Gegen den theoretischen Fall...") to the INSERT's row selection, and the live local database has no such multi-tenant-same-user rows (measured backfill: 2 dashboards for 2 users with data, 0 orphans). This does not block the phase goal — it is a pre-existing, explicitly acknowledged theoretical edge case, not a regression — but is noted here for the record since it touches the "niemand verliert etwas" must-have's edge-case robustness, not its measured/observed correctness. ### Human Verification Required 1. **Browser-Rundgang (9 Punkte aus dem SUMMARY)** **Test:** Anmelden und Dashboard öffnen; Reiter anlegen/wechseln/Kachel setzen; ungespeichert wechseln; per Ziehen an die erste Stelle bringen und neu laden; umbenennen; löschen; letzten Reiter prüfen; Raster gegenmessen (D-06: Kachel auf belegten Platz ziehen — bleibt am Ausgangsort). **Expected:** Alle neun Punkte laufen wie im SUMMARY beschrieben. **Why human:** Erfordert einen neu gebauten laufenden Container mit echtem Datenbestand und echte Maus-Interaktion — Drag-and-Drop-Verhalten und visuelle Rasterreaktion lassen sich nicht per Codeanalyse abschließend beurteilen, und laut Projektregel baut/startet der Nutzer die Container selbst. ### Gaps Summary None. Every must-have truth from the plan's frontmatter, plus the four explicit verification-focus points requested (migration completeness, fail-closed ownership, grid untouched, the three auto-fixed files), is backed by direct code reading and/or a freshly re-run, non-trusted measurement (full test suites, type-check, lint, live-DB migration diff, live-DB backfill re-query, git diff on package.json and on the dashboard components directory). All three "Rule 1/3" auto-fixed files documented in the SUMMARY were read directly and are correct, narrowly-scoped fixes that do not hide a gap — they were compile/test breakages caused by the new required `dashboardId` field, fixed with reasoning matching what SUMMARY claims. The only finding is the minor, pre-acknowledged theoretical edge case noted above under Anti-Patterns, which does not affect the phase goal as measured against the actual local database. --- _Verified: 2026-09-23T08:30:00Z_ _Verifier: Claude (gsd-verifier)_