docs(quick-260923-ad9): Akte - Rundgang mit zwoelf Punkten bestanden
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s

Bestand bleibt erhalten (5 Kacheln im ersten Reiter), Kacheln je Reiter
getrennt, Ziehen ordnet um, nach dem Neuladen kommt der erste Reiter,
letzter Reiter ohne Loeschknopf, Raster unveraendert.

Offener Kleinbefund notiert: die Knopf-Beschriftungen nennen den betroffenen
Reiter nicht, nur das Bestaetigungsfenster tut es.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 08:29:50 +02:00
parent 58ce88e29f
commit 0094a60d15
3 changed files with 342 additions and 3 deletions
@@ -0,0 +1,168 @@
---
phase: quick-260923-ad9
plan: 01
subsystem: dashboard
tags: [dashboard, reiter, rls, migration, frontend, drag-and-drop]
dependency-graph:
requires: []
provides: [dashboard-tabs, multi-dashboard-model]
affects: [apps/api/src/dashboard, apps/web/src/components/dashboard, apps/web/src/lib/stores/dashboard-store.ts]
tech-stack:
added: []
patterns: [forTenant-per-method, withTenantTransaction-multi-step, pointer-drag-with-threshold]
key-files:
created:
- apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql
- apps/api/src/dashboard/dto/rename-dashboard.dto.ts
- apps/api/src/dashboard/dto/reorder-dashboards.dto.ts
- apps/api/src/dashboard/dashboard.controller.spec.ts
- apps/web/src/components/dashboard/dashboard-tabs.tsx
- apps/web/src/components/dashboard/dashboard-tabs.test.tsx
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/dashboard/dashboard.service.ts
- apps/api/src/dashboard/dashboard.service.spec.ts
- apps/api/src/dashboard/dashboard.controller.ts
- apps/api/src/dashboard/dto/save-layout.dto.ts
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/api/src/dashboard/widget-module-map.spec.ts
- apps/web/src/lib/dashboard-api.ts
- apps/web/src/lib/stores/dashboard-store.ts
- apps/web/src/lib/stores/dashboard-store.test.ts
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/app/(portal)/page.test.tsx
- apps/web/src/app/(portal)/settings/dashboard/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-anwender.md
- CHANGELOG.md
decisions:
- "D-01 bis D-10 aus dem Plan wörtlich umgesetzt, keine Abweichung: kein Unique auf (userId, position), kein Standard-Feld (Nach-vorn-Ziehen IST der Standard), Namensvergabe füllt Lücken, letzter Reiter bleibt."
- "assertOwnedDashboard nimmt den bereits gebundenen Klienten als Parameter (statt selbst forTenant() zu rufen) — hält die bestehende Testinvariante 'genau ein gebundener Klient je Methode' aufrecht."
- "Reiterwechsel: ungespeicherte Anordnung wird über get().saveLayout() VOR dem set() der neuen activeDashboardId geschrieben — kein Parameter nötig, die Store-Closure liest die alte Kennung von selbst."
- "Ziehen der Reiter läuft über Pointer-Events mit 4px-Schwelle, computeReorderedIds bestimmt die Zielposition über Mittelpunkte der (in Tests gestubbten) Reiter-Rects — Muster xframe-config-form.tsx, keine neue Abhängigkeit (D-05)."
metrics:
duration: "~2.5h"
completed: 2026-09-23
actuals:
tokens: 45514
tasks: 5
commits: 5
plan_head_before: 84fe73e
status: complete
---
# Phase quick-260923-ad9 Plan 01: Dashboard-Reiter — mehrere Dashboards je Benutzer Summary
Jeder Benutzer hat jetzt mehrere Dashboards ("Reiter"), oben nebeneinander in einer Leiste, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, der erste geladen beim Öffnen — bestehende Kacheln landeten unverändert auf einem einzigen Reiter "Dashboard".
## Datenbankänderung — geht als reguläre Version über main
**Wichtig für die Freigabe (D-03):** dieser Plan enthält eine Datenbankmigration (`20260923120000_dashboard_tabs`), die Bestandsdaten umhängt (`WidgetInstance`/`DashboardLayout` bekommen `dashboardId`, `DashboardLayout` verliert die Eindeutigkeit auf `userId`). Das geht als reguläre Version über `main`, **nicht als Hotfix**.
## Gemessene Torzahlen
| Tor | Vorher (gemessen 23.09. vor Beginn) | Nachher (gemessen nach Task 5) |
|---|---|---|
| `pnpm --filter @tessera/api test` | 1202 Tests, 76 Dateien | **1240 Tests, 77 Dateien** |
| davon `dashboard.service.spec.ts` | 31 Tests | **61 Tests** |
| davon `dashboard.controller.spec.ts` | (Datei existierte nicht) | **8 Tests** (neu) |
| `rls-coverage.spec.ts` / `rls-access-inventory.spec.ts` | 5 / 30 | **5 / 30** (unverändert grün) |
| `pnpm --filter @tessera/web test` | 661 Tests, 81 Dateien | **693 Tests, 82 Dateien** |
| davon `dashboard-store.test.ts` | 6 Tests | **17 Tests** |
| davon `dashboard-tabs.test.tsx` | (Datei existierte nicht) | **21 Tests** (neu) |
| `dashboard-grid.test.tsx` | 12 Tests | **12 Tests** (unverändert, Datei nicht angefasst — D-06) |
| `pnpm type-check` | 4/4 | **4/4** |
| `pnpm lint` | 5/5, genau 53 Warnungen in web | **5/5, genau 53 Warnungen in web** |
| `as unknown as` in `apps/web/src` ohne Testdateien | 3 | **3** |
| Prisma-Abweichung lokal | „No difference detected.“ | **„No difference detected.“** |
## Zählabfrage der Bestandsübernahme (Task 1, gegen die lokale Datenbank)
```
kacheln_ohne_reiter | anordnungen_ohne_reiter | reiter | reiter_nicht_an_position_null
0 | 0 | 2 | 0
```
0 Kacheln ohne Reiter, 0 Anordnungen ohne Reiter, genau 2 Reiter (deckt sich mit der vor Beginn gemessenen Zahl von 2 Benutzern mit Bestand), 0 Reiter abseits von Position 0 — jeder der beiden vorhandenen Benutzer mit Kacheln/Anordnung hat jetzt genau einen Reiter „Dashboard" auf Position 0.
## Abweichungen von der im Plan gemessenen Erwartung (keine Rule-1/2/3-Fälle — reine Zahlendifferenzen, dokumentiert statt stillschweigend übersprungen)
1. **`apps/web` Dateizahl 82 statt der für Task 4 erwarteten 83.** Task 3 brachte die Web-Testdateizahl bereits auf 82 (neue Datei `dashboard-tabs.test.tsx`); Task 4 fügt laut seiner eigenen `files_modified`-Liste keine weitere neue Testdatei hinzu, sondern erweitert nur bestehende. Die Plan-Erwartung „≥83" für Task 4 war auf eine damals noch nicht vorhersehbare zusätzliche Datei ausgelegt, die nie gebraucht wurde — alle Verhaltenspunkte sind vollständig mit 21 Tests in `dashboard-tabs.test.tsx` und 17 in `dashboard-store.test.ts` abgedeckt.
2. **`grep -c "react-grid-layout|dnd|sortable" apps/web/package.json` liefert 2 statt 1.** Der zweite Treffer ist `@types/react-grid-layout`, bereits vor diesem Plan vorhanden — `git diff --stat` auf `apps/web/package.json` über alle fünf Commits ist leer. D-05 (keine neue Zieh-Abhängigkeit) ist damit nachgewiesen, nur über die leere package.json-Diff statt über die im Plan vorausgesagte Grep-Zahl.
## Auto-fixed Issues (Deviations, Rule 1/3)
**1. [Rule 1] `widget-module-map.spec.ts` — `CreateWidgetDto`-Validierungstest ohne `dashboardId`-Fixture**
- **Gefunden während:** Task 1, nach Hinzufügen von `dashboardId` als Pflichtfeld auf `CreateWidgetDto`.
- **Problem:** Der bestehende Whitelist-Test rief `plainToInstance(CreateWidgetDto, { widgetType })` ohne `dashboardId` — schlug jetzt mit einem zusätzlichen Validierungsfehler fehl.
- **Fix:** `dashboardId: 'dash-1'` fest mitgegeben, Kommentar ergänzt.
- **Commit:** 9c51823 (Task 1)
**2. [Rule 3] `settings/dashboard/page.tsx` — `fetchWidgets()` verlangt jetzt eine Reiter-Kennung**
- **Gefunden während:** Task 3, nach Umstellung von `fetchWidgets` auf `fetchWidgets(dashboardId)`.
- **Problem:** Diese Einstellungsseite (Widget-Konfiguration) war nicht Teil des Plan-Umfangs für Reiterbewusstsein, hätte aber nicht mehr kompiliert.
- **Fix:** Die Seite holt jetzt zuerst `fetchDashboards()` und zeigt die Kacheln des ERSTEN Reiters — deckungsgleich mit dem bisherigen Verhalten für den (weit überwiegenden) Fall genau eines Reiters. Volle Reiterauswahl auf dieser Seite ist außerhalb des Umfangs dieses Plans.
- **Commit:** d34f682 (Task 3)
**3. [Rule 3] `(portal)/page.test.tsx` — `mockStore` ohne die neuen Reiter-Felder**
- **Gefunden während:** Task 3, nach Einbau von `<DashboardTabs>` in `page.tsx`.
- **Problem:** `dashboards` wäre `undefined` gewesen — `DashboardTabs` hätte auf `.map` einer `undefined`-Liste geworfen.
- **Fix:** `dashboards: []`, `activeDashboardId: null`, `isSwitchingDashboard: false` sowie die vier neuen Store-Methoden als `vi.fn()` ergänzt.
- **Commit:** d34f682 (Task 3)
Kein Punkt aus „Nicht im Umfang" wurde angefasst (kein Freigeben/Teilen von Dashboards, keine Vorlagen, keine Reiter je Modul). Keine neue Abhängigkeit in `apps/web/package.json` oder `apps/api/package.json`. `DashboardGrid` selbst ist unverändert.
## Prüfliste für den Browser-Rundgang (vom Nutzer auszuführen — Container-Neubau nötig)
1. Anmelden, Dashboard öffnen: genau ein Reiter „Dashboard", alle bisherigen Kacheln liegen unverändert an ihrem Platz.
2. Bearbeitungsmodus, Reiter hinzufügen: neuer Reiter „Dashboard 2" am Ende, Fläche leer, der neue Reiter ist aktiv.
3. Auf „Dashboard 2" eine Kachel setzen, zurück auf „Dashboard" wechseln: die alten Kacheln stehen unverändert da, die neue Kachel ist NICHT dabei.
4. Kachel auf „Dashboard 2" verschieben, ohne zu speichern den Reiter wechseln und zurückwechseln: die verschobene Anordnung ist erhalten.
5. „Dashboard 2" an die erste Stelle ziehen, Seite neu laden: „Dashboard 2" steht vorn und wird geladen.
6. „Dashboard 2" umbenennen, Seite neu laden: der neue Name steht da.
7. „Dashboard 2" löschen: Kacheln dieses Reiters sind weg, der andere Reiter ist vollständig da.
8. Bis auf einen Reiter alles löschen: beim letzten wird Löschen nicht mehr angeboten.
9. Raster gegenmessen (D-06): eine Kachel auf einen belegten Platz ziehen — sie bleibt am Ausgangsort, nichts weicht aus; das Raster reicht bis zum rechten Rand des Inhaltsbereichs.
## Threat Flags
Keine — alle Punkte des Threat-Registers (T-AD9-01 bis T-AD9-SC) sind wie im Plan geplant mitigiert und mit eigenen Tests belegt (`assertOwnedDashboard` fail-closed über alle vier bestehenden Wege plus die vier neuen Reiter-Wege, Exakt-Abgleich vor jedem Schreiben bei `reorderDashboards`, Obergrenzen 20 Reiter/40 Zeichen, Transaktionssperre gegen Doppelanlage). Kein neuer Netzwerk-Endpunkt oder Auth-Pfad außerhalb der im Plan benannten fünf `tabs`-Routen.
## Self-Check: PASSED
Alle sechs im Plan neu erwarteten Dateien gefunden, alle fünf Task-Commits im Log gefunden (siehe git log).
## Rundgang durch den Orchestrator (23.09.2026, lokaler Stack, Abbilder aus dem Commit danach)
Container neu gebaut (`up -d --build web api`), Migration lag beim Start bereits an
(`41 migrations found`, `No pending migrations to apply`). Gemessen wurde am DOM, nicht per
`fetch` — und beim Raster gegen die GESETZTEN Werte (`style.width`), nicht gegen die gemalte
Box (Messfalle aus quick-260922-vdk).
| # | Geprueft | Ergebnis |
|---|----------|----------|
| 1 | Bestand nach der Migration | EIN Reiter „Dashboard" mit allen **5** vorhandenen Kacheln — nichts verloren |
| 2 | Reiterleiste vorhanden | `<nav aria-label="Dashboard-Reiter">`, aktiver Reiter traegt `aria-current="true"` |
| 3 | Ansichtsmodus | nur Reiter, keine Verwaltungsknoepfe |
| 4 | Bearbeitungsmodus | „Dashboard umbenennen", „Dashboard hinzufuegen", je Reiter „Dashboard loeschen" |
| 5 | Reiter anlegen | neuer Reiter „Dashboard 2", sofort aktiv, **leer** (0 Kacheln) |
| 6 | Kacheln je Reiter getrennt | Uhr auf Reiter 2 → Reiter 2 hat 1 Kachel, Reiter 1 unveraendert 5 |
| 7 | Ziehen ordnet um | echte Maus-Ereignisse: `[Dashboard, Dashboard 2]` → `[Dashboard 2, Dashboard]` |
| 8 | **Erster Reiter ist Standard** | nach vollem Neuladen: „Dashboard 2" steht vorn, ist aktiv, zeigt seine eigene Kachel |
| 9 | Umbenennen | Eingabefeld in der Leiste, `maxlength="40"`, Enter uebernimmt → „Technik" |
| 10 | Loeschen mit Rueckfrage | `role="alertdialog"` + `aria-modal`, Text benennt den Reiter und warnt, dass Kacheln und Anordnung mitgehen |
| 11 | Letzter Reiter bleibt | nach dem Loeschen: ein Reiter, **null** Loeschknoepfe |
| 12 | Raster unveraendert | Bereich 1625 px → Kachel `style.width: 531px` = `8 × 59,375 + 56`, exakt der Sollwert (lg, 24 Spalten) |
**Kleiner Befund, nicht behoben (kein Blocker):** die Beschriftungen der Verwaltungsknoepfe
lauten generisch „Dashboard umbenennen" / „Dashboard loeschen" und nennen nicht, WELCHEN Reiter
sie treffen; bei mehreren Reitern liest eine Sprachausgabe also mehrfach denselben Text. Das
Bestaetigungsfenster benennt den Reiter korrekt, der Schaden ist also begrenzt. Vorgemerkt fuer
die naechste Arbeit an der Leiste.
**Eigener Messfehler, damit er nicht als Produktfehler stehenbleibt:** der erste Loeschversuch
sah wie „passiert nichts" aus — tatsaechlich war das Bestaetigungsfenster offen, meine Abfrage
suchte aber nur nach `[role="dialog"]`. Das Fenster traegt `role="alertdialog"`. Beim Pruefen
auf beide Rollen abfragen.
@@ -0,0 +1,170 @@
---
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)_