diff --git a/.planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-PLAN.md b/.planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-PLAN.md new file mode 100644 index 0000000..9206152 --- /dev/null +++ b/.planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-PLAN.md @@ -0,0 +1,573 @@ +--- +phase: quick-260923-ad9 +plan: 01 +type: execute +wave: 1 +depends_on: [] +autonomous: true +requirements: [QUICK-260923-AD9] + +files_modified: + - apps/api/prisma/schema.prisma + - apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql + - 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/dashboard.controller.spec.ts + - apps/api/src/dashboard/dto/save-layout.dto.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 + - docs/mandantentrennung-zugriffsklassifikation.md + - 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/components/dashboard/dashboard-tabs.tsx + - apps/web/src/components/dashboard/dashboard-tabs.test.tsx + - apps/web/src/app/(portal)/page.tsx + - apps/web/src/messages/de.json + - apps/web/src/messages/en.json + - docs/anleitung-anwender.md + - CHANGELOG.md + +estimate: + tokens: 185000 + raw_tokens: 185000 + tasks: 5 + confidence: low + +must_haves: + truths: + - "Ein Benutzer hat mehrere Dashboards, die oben als Reiter nebeneinander stehen. Jeder Reiter trägt seine EIGENEN Kacheln und seine EIGENE Anordnung — was auf Reiter 1 liegt, erscheint nicht auf Reiter 2." + - "Beim Öffnen des Dashboards wird immer der ERSTE Reiter geladen. Es gibt kein zusätzliches Stern-Kennzeichen und kein getrenntes Standard-Feld: nach vorn ziehen IST das Festlegen des Standards." + - "Reiter lassen sich mit der Maus an eine andere Stelle ziehen; die neue Reihenfolge bleibt nach dem Neuladen erhalten. Ein Klick ohne Ziehen wechselt nur den Reiter." + - "Reiter lassen sich anlegen (leer, Name „Dashboard 2“, „Dashboard 3“, … — die nächste freie Zahl), umbenennen und löschen. Der letzte verbleibende Reiter kann nicht gelöscht werden; der Server weist das ab, die Oberfläche bietet es gar nicht erst an." + - "Niemand verliert beim Einspielen etwas: jeder Benutzer, der heute Kacheln ODER eine gespeicherte Anordnung hat, findet danach genau EINEN Reiter namens „Dashboard“ mit genau seinen bisherigen Kacheln in genau seiner bisherigen Anordnung vor. Ein Benutzer ohne beides bekommt beim ersten Öffnen einen leeren Reiter „Dashboard“ angelegt." + - "Fail-closed gegen fremde Reiter: Kacheln lesen, Kachel anlegen, Anordnung lesen, Anordnung speichern, umbenennen, löschen und umsortieren antworten für einen Reiter, der dem Aufrufer nicht gehört (fremder Benutzer, fremder Mandant, unbekannte Kennung), mit derselben Nicht-gefunden-Antwort — nie mit einer Antwort, aus der sich die Existenz des fremden Reiters ablesen lässt." + - "Die neue Tabelle trägt Mandantenkennung und denselben Zeilenschutz wie ihre Nachbarn (Mandant UND Benutzer, Form aus 20260911120000); der Wächter-Test der RLS-Abdeckung bleibt grün, und die Zugriffsklassifikation führt das neue Paar (Datei, Modell) mit gemessenem Stand." + - "Am Raster selbst ändert sich nichts: FREE_PLACEMENT_COMPACTOR mit preventCollision, belegte Plätze bleiben gesperrt, nichts weicht aus; die Breitenmessung aus quick-260922-vdk bleibt unverändert." + - "Keine neue Abhängigkeit: das Ziehen der Reiter läuft über dieselben Pointer-Ereignisse wie die Ausschnittwahl im XFrame-Einstellungsdialog (quick-260922-ge2)." + - "Alle Tore grün: api gesamt ≥ 1202 Tests in ≥ 77 Dateien, web gesamt ≥ 661 Tests in ≥ 83 Dateien, `pnpm type-check` 4/4, `pnpm lint` 5/5 mit weiterhin GENAU 53 Warnungen in web, `prisma migrate diff` gegen die lokale Datenbank meldet weiterhin keinen Unterschied." + artifacts: + - "apps/api/prisma/schema.prisma — neues Modell `Dashboard` (id, userId, tenantId, name, position, createdAt, updatedAt; Index auf userId und tenantId, KEIN Unique auf (userId, position)); `WidgetInstance.dashboardId` und `DashboardLayout.dashboardId @unique` mit Relation und `onDelete: Cascade`; `DashboardLayout.userId` verliert `@unique`, behält einen gewöhnlichen Index" + - "apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql — hand geschriebene Migration mit deutschem Kopf: Tabelle, Indizes, Zeilenschutz (ENABLE + FORCE + tenant_isolation_policy mit Benutzerdimension), Bestandsübernahme für jeden Benutzer mit Kacheln oder Anordnung, Nachtragen der Fremdschlüssel erst NACH der Übernahme" + - "apps/api/src/dashboard/dashboard.service.ts — `listDashboards` (legt bei null vorhandenen genau einen an, gegen Doppelanlage per Transaktions-Sperre gesichert), `createDashboard`, `renameDashboard`, `deleteDashboard`, `reorderDashboards`, privater Riegel `assertOwnedDashboard`; `getLayout`/`saveLayout`/`getWidgets`/`addWidget` arbeiten je Reiter" + - "apps/api/src/dashboard/dashboard.controller.ts — fünf neue Routen unter `tabs`, `tabs/order` VOR den Routen mit Platzhalter deklariert; `dashboardId` als Abfrageparameter bei den beiden Lesewegen, im Rumpf bei den beiden Schreibwegen" + - "apps/api/src/dashboard/dto/ — `rename-dashboard.dto.ts`, `reorder-dashboards.dto.ts` (Obergrenzen als Riegel gegen Massenanfragen), erweiterte `save-layout.dto.ts` und `create-widget.dto.ts`" + - "apps/api/src/dashboard/dashboard.controller.spec.ts — NEU: Durchreichen der Reiter-Kennung, Fehlerformen, und ein quelltextlesender Wächter, dass `tabs/order` VOR den Platzhalter-Routen steht (NestJS-Routenreihenfolge)" + - "apps/web/src/components/dashboard/dashboard-tabs.tsx — NEU: Reiterleiste mit Wechseln, Anlegen, Umbenennen, Löschen und Ziehen zum Umsortieren über Pointer-Ereignisse (Muster xframe-config-form.tsx, inklusive der jsdom-Schutzhülle um setPointerCapture)" + - "apps/web/src/lib/stores/dashboard-store.ts — `dashboards`, `activeDashboardId`, `selectDashboard`, `createDashboard`, `renameDashboard`, `deleteDashboard`, `reorderDashboards`; Schutz gegen doppeltes Laden; ungespeicherte Anordnung wird VOR dem Reiterwechsel auf den ALTEN Reiter geschrieben" + - "apps/web/src/messages/de.json + en.json — neue Zeichenketten unter `widgets.tabs.*`, deutsch in der Sie-Form mit echten Umlauten (der Umlaut-Wächter liest de.json)" + - "docs/mandantentrennung-zugriffsklassifikation.md — neue Zeile für das Paar (`dashboard.service.ts`, `dashboard`), nachgerechnete Bereichs- und Summenzeile, nachgezogene Paarzahl" + - "docs/anleitung-anwender.md + CHANGELOG.md — Beschreibung der Reiter in Alltagssprache" + key_links: + - "Öffnen → `loadDashboard()` → `GET /dashboard/tabs` (legt bei Bedarf den ersten an) → erster Reiter der nach `position` aufsteigend sortierten Liste wird aktiv → `GET /dashboard/widgets?dashboardId=…` + `GET /dashboard/layout?dashboardId=…` → `DashboardGrid` bekommt genau die Kacheln dieses Reiters" + - "Reiter ziehen → Pointer-Ereignisse in `dashboard-tabs.tsx` → beim Loslassen die vollständige Kennungsliste in neuer Reihenfolge → `PUT /dashboard/tabs/order` → `withTenantTransaction` schreibt alle `position`-Werte des Benutzers in EINER Transaktion neu (0…n-1) → nächstes Öffnen lädt den nun ersten Reiter" + - "Reiter löschen → `DELETE /dashboard/tabs/:id` → Riegel `assertOwnedDashboard` → Abweisung, wenn es der letzte Reiter ist → sonst in EINER Transaktion: Kacheln des Reiters, Anordnung des Reiters, Reiter selbst, danach Positionen der verbleibenden Reiter lückenlos neu geschrieben" + - "Kachel hinzufügen → Store reicht `activeDashboardId` durch → `POST /dashboard/widgets` mit Reiter-Kennung → Riegel prüft Besitz → Kachel hängt am richtigen Reiter" + - "Neues Modell `Dashboard` mit `tenantId` → `rls-coverage.spec.ts` Test 1/2 verlangen ENABLE + Policy in einer Migration → Migration liefert beides → Wächter bleibt grün" + - "`tenantPrisma.dashboard` / `tx.dashboard` in `dashboard.service.ts` → `rls-access-inventory.spec.ts` findet ein neues Paar (Datei, Modell) → Eintrag in `docs/mandantentrennung-zugriffsklassifikation.md` mit Stand `gebunden` → Wächter bleibt grün" +--- + +# Quick-Aufgabe 260923-ad9: Dashboard-Reiter — mehrere Dashboards je Benutzer + + +Das Dashboard trägt heute genau eine Kachelfläche je Benutzer. Diese Aufgabe gibt jedem Benutzer mehrere +Dashboards, die oben als Reiter nebeneinander stehen: jeder Reiter mit eigenen Kacheln und eigener +Anordnung, per Ziehen umsortierbar, der erste ist der Standard und wird beim Öffnen geladen. + +Purpose: Der Wunsch des Nutzers vom 22./23.09.2026, mit allen Entscheidungen bereits getroffen (siehe +„Gebundene Entscheidungen“). Der Plan setzt um und sichert ab — es ist nichts mehr zu erforschen und +nichts mehr rückzufragen. +Output: Neues Datenmodell mit Bestandsübernahme, fünf neue Endpunkte mit Besitz-Riegel, Reiterleiste in +der Oberfläche, Ziehen zum Umsortieren ohne neue Abhängigkeit, nachgezogene Zeilenschutz-Dokumentation, +Anwenderhandbuch und Changelog. Alle Tore grün, Prüfliste für den Browser-Rundgang im SUMMARY. + + +## Gebundene Entscheidungen (nicht neu verhandeln) + +- **D-01 — Datenmodell:** neues Modell `Dashboard` (id, userId, tenantId, name, position, createdAt, + updatedAt), Reihenfolge über `position` (Integer), aufsteigend sortiert. **KEIN** Unique auf + (userId, position) — beim Umsortieren werden alle Positionen des Benutzers in EINER Transaktion neu + geschrieben, ein Unique wäre dabei nur im Weg. Index auf `userId` und auf `tenantId` wie bei den + Nachbarmodellen. +- **D-02 — Anhängen:** `WidgetInstance` bekommt `dashboardId`. `DashboardLayout` hängt künftig am + Dashboard statt am Benutzer (das heutige `userId @unique` fällt, `dashboardId @unique` kommt); + `userId`/`tenantId` bleiben auf beiden Modellen für Besitz- und Mandantenprüfung erhalten. +- **D-03 — Bestandsübernahme:** für jeden Benutzer, der heute Kacheln ODER eine Anordnung hat, entsteht + genau EIN Dashboard mit `position = 0` und dem Namen „Dashboard“; vorhandene Kacheln und die vorhandene + Anordnung werden darauf umgehängt. Datenbankänderung heißt: geht über `main` als reguläre Version, + nicht als Hotfix. +- **D-04 — Zeilenschutz ist Pflicht:** das neue Modell braucht `tenantId` und denselben Zeilenschutz wie + die Nachbartabellen. Die Wächter in `apps/api/src/prisma/rls-coverage.spec.ts` und + `apps/api/src/prisma/rls-access-inventory.spec.ts` müssen grün bleiben. +- **D-05 — keine neue Abhängigkeit** für das Ziehen der Reiter. Dem vorhandenen Pointer-Ereignis-Muster + aus `apps/web/src/components/settings/xframe-config-form.tsx` (quick-260922-ge2) folgen. +- **D-06 — am Raster ändert sich nichts:** `FREE_PLACEMENT_COMPACTOR` mit `preventCollision` bleibt, + belegte Plätze bleiben gesperrt, nichts weicht aus (Nutzeransage 22.09.). Die Breitenmessung aus + quick-260922-vdk bleibt unverändert. +- **D-07 — Oberflächentexte** auf Deutsch in der Sie-Form über next-intl, keine rohen Zeichenketten; + Kommentare im Code auf Deutsch wie in den Nachbardateien. +- **D-08 — neuer Reiter** startet leer und heißt „Dashboard 2“, „Dashboard 3“, … (nächste freie Zahl). +- **D-09 — „Als Favorit festlegen“ = nach vorn ziehen.** Kein Stern-Kennzeichen, kein getrenntes + Standard-Feld, kein Merken des zuletzt benutzten Reiters: beim Öffnen wird immer der erste geladen. +- **D-10 — der letzte verbleibende Reiter kann nicht gelöscht werden.** + +**Nicht im Umfang:** Freigeben/Teilen von Dashboards an andere Benutzer, Vorlagen, Reiter je Modul. + +## Gemessener Ausgangsstand (nicht erneut zu erheben) + +Gemessen am 23.09.2026 vor Beginn, auf diesem Rechner: + +| Tor | Stand | +|---|---| +| `pnpm --filter @tessera/api test` | 1202 Tests in 76 Dateien, grün | +| davon `dashboard.service.spec.ts` | 31 Tests | +| davon `rls-coverage.spec.ts` / `rls-access-inventory.spec.ts` | 5 / 30 Tests | +| `pnpm --filter @tessera/web test` | 661 Tests in 81 Dateien, grün | +| davon `dashboard-store.test.ts` / `dashboard-grid.test.tsx` | 6 / 12 Tests | +| `pnpm type-check` | 4 von 4 erfolgreich | +| `pnpm lint` | 5 von 5 erfolgreich, **genau 53 Warnungen** in web | +| `as unknown as` in `apps/web/src` ohne Testdateien | 3 | +| Prisma-Abweichung lokal | „No difference detected.“ | +| Lokale Datenbank | 6 Kacheln bei 2 Benutzern, 1 gespeicherte Anordnung, 3 Benutzer — die Vereinigung „hat Kacheln oder Anordnung“ ergibt **2** Benutzer | + +Die lokale Datenbank ist vom Host aus über die Container-IP erreichbar (`docker inspect` auf +`tessera-ctl-db-1`, Zugangsdaten `tessera` / `tessera_dev`, Datenbank `tessera`) — sie hat bewusst keinen +Host-Port. Gemessen: `tessera` ist in diesem Abbild ein Superuser und umgeht den Zeilenschutz, eine +Migration sieht also alle Bestandszeilen. + +## Festgelegte technische Form (vom Planer entschieden, nicht rückzufragen) + +**Endpunkte** (alle unter dem vorhandenen Präfix `dashboard`, alle mit dem vorhandenen +`extractContext`-Muster für Benutzer und Mandant): + +| Weg | Zweck | +|---|---| +| `GET /dashboard/tabs` | Reiter des Benutzers, nach `position` aufsteigend; legt genau einen an, wenn keiner existiert | +| `POST /dashboard/tabs` | neuen, leeren Reiter am Ende anlegen (Name automatisch, D-08) | +| `PUT /dashboard/tabs/order` | vollständige Kennungsliste in Wunschreihenfolge | +| `PATCH /dashboard/tabs/:id` | umbenennen | +| `DELETE /dashboard/tabs/:id` | Reiter mit seinen Kacheln und seiner Anordnung löschen | +| `GET /dashboard/layout?dashboardId=…` | Anordnung eines Reiters | +| `PUT /dashboard/layout` | Rumpf trägt `dashboardId` und `layouts` | +| `GET /dashboard/widgets?dashboardId=…` | Kacheln eines Reiters | +| `POST /dashboard/widgets` | Rumpf trägt `widgetType` und `dashboardId` | + +`PATCH /dashboard/widgets/:id/config`, `DELETE /dashboard/widgets/:id` und die drei Suchanbieter-Wege +bleiben **unverändert** — eine Kachelkennung ist für sich eindeutig. + +**Riegel `assertOwnedDashboard(dashboardId, userId, tenantId)`:** liest den Reiter über den gebundenen +Klienten und wirft für „gibt es nicht“, „gehört einem Kollegen“ und „liegt bei einem fremden Mandanten“ +dieselbe `NotFoundException` — niemals eine abweichende Antwort, aus der sich die Existenz ablesen +ließe. Dasselbe Vorgehen wie der Widget-Riegel in `favorites.service.ts` (T-GWH-05). + +**Obergrenzen als Riegel gegen Massenanfragen:** höchstens 20 Reiter je Benutzer; Reitername nach dem +Beschneiden 1 bis 40 Zeichen; die Kennungsliste beim Umsortieren höchstens 20 Einträge, ohne Dubletten. + +**Umsortieren** folgt wörtlich dem Muster `FavoritesService.reorder` (260917-jdd): eine +`withTenantTransaction`, darin erst die vorhandenen Kennungen lesen, auf exakte Übereinstimmung mit der +gesendeten Liste prüfen (sonst Abweisung, kein Teilschreiben), dann je Eintrag ein `updateMany` mit +`id` UND `userId` in der Bedingung und einer Prüfung auf genau eine getroffene Zeile. `withTenantTransaction` +setzt keine Benutzerdimension in der Sitzung — deshalb trägt jede Bedingung innerhalb der Transaktion +`userId` selbst, als zweites Netz. + +## Tasks + + + + + Task 1: Datenmodell, Migration und Reiter-Grundlage — das heutige Dashboard wird zu Reiter 1 + apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260923120000_dashboard_tabs/migration.sql, 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, docs/mandantentrennung-zugriffsklassifikation.md + apps/api/prisma/schema.prisma (Modelle DashboardLayout, WidgetInstance, DashboardImage), apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql (Vorlage für Kopf, Indizes und Zeilenschutz einer persönlichen Tabelle), apps/api/src/prisma/prisma-tenant.extension.ts (forTenant, withTenantTransaction), apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.service.spec.ts (Zwei-Klienten-Nachbau), apps/api/src/favorites/favorites.service.spec.ts Zeile 24-29 und 166-175 (Mock-Form für withTenantTransaction), apps/api/src/prisma/rls-coverage.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md Zeile 160-180, 224-236 und 676-680 + +Schema (D-01/D-02): neues Modell `Dashboard` mit `id` (uuid-Vorgabe), `userId`, `tenantId`, `name`, +`position` als Integer, `createdAt`, `updatedAt`, Index auf `userId` und auf `tenantId`, KEIN Unique auf +der Positionsspalte. Keine Relation zu `User` oder `Tenant` — das ist die Form der Nachbarmodelle +`WidgetInstance` und `DashboardImage`, und eine Relation zu `User` würde an Bestandszeilen verwaister +Benutzer scheitern. `WidgetInstance` bekommt `dashboardId` als Pflichtfeld mit Relation auf `Dashboard` +und Löschweitergabe, dazu einen Index darauf; `DashboardLayout` bekommt `dashboardId` als Pflichtfeld mit +Relation, Löschweitergabe und Eindeutigkeit, verliert die Eindeutigkeit auf `userId` und behält dort einen +gewöhnlichen Index. Auf `Dashboard` die beiden Gegenseiten der Relationen eintragen. Der deutsche +Kommentar über dem neuen Modell nennt D-01 (warum kein Unique auf der Position) und D-09 (warum es kein +Standard-Feld gibt). + +Migration `20260923120000_dashboard_tabs/migration.sql` von Hand schreiben, in dieser Reihenfolge — die +Bestandsübernahme MUSS vor den Fremdschlüsseln stehen, sonst scheitert sie an genau diesen: +1. Deutscher Kopfkommentar nach der Form von `20260921120000_dashboard_image`: Zweck, D-01 (keine + Eindeutigkeit auf der Position, weil das Umsortieren alle Positionen eines Benutzers in einer + Transaktion neu schreibt), D-03 (niemand verliert etwas), D-04 (Zeilenschutz mit Benutzerdimension, + Form aus `20260911120000`), und der Hinweis, dass die Rechte für `tessera_app` über die + Vorgaberechte aus `20260909130000` kommen. +2. Tabelle `Dashboard` anlegen, Indizes auf `userId` und `tenantId`. +3. Zeilenschutz einschalten, erzwingen und die Regel `tenant_isolation_policy` anlegen: Mandant gleich + `current_tenant_id()` UND (`current_user_id()` ist NULL ODER Benutzer gleich `current_user_id()`) — + wörtlich die Form aus `20260921120000_dashboard_image`. +4. Bestandsübernahme: je Benutzer aus der Vereinigung der Benutzer mit Kacheln und der Benutzer mit + gespeicherter Anordnung genau eine Zeile einfügen, mit `gen_random_uuid()` als Textkennung, dem Namen + „Dashboard“, Position 0 und der Mandantenkennung aus der Bestandszeile. Gegen den theoretischen Fall + „derselbe Benutzer mit zwei Mandantenkennungen“ mit einer Auswahl absichern, die je Benutzer genau + eine Zeile liefert. +5. `dashboardId` auf `WidgetInstance` zunächst als NULLbare Spalte ergänzen, aus der neuen Tabelle über + die Benutzerkennung befüllen, dann auf NOT NULL setzen, Index anlegen, Fremdschlüssel mit + Löschweitergabe ergänzen. +6. Dasselbe für `DashboardLayout`; zusätzlich die Eindeutigkeit auf `userId` entfernen, dort einen + gewöhnlichen Index anlegen und die Eindeutigkeit auf `dashboardId` anlegen. + +Dienst `dashboard.service.ts`: +- `listDashboards(userId, tenantId)` liest die Reiter des Benutzers über `forTenant(...)` nach `position` + aufsteigend. Ist die Liste leer, wird genau ein Reiter „Dashboard“ mit Position 0 angelegt und die + Liste erneut gelesen. Das Anlegen läuft in einer `withTenantTransaction`, die als erste Anweisung eine + Transaktionssperre auf die Benutzerkennung nimmt (`pg_advisory_xact_lock` mit `hashtext` über die + Benutzerkennung und einer zweiten Ganzzahl; beides sind eingebaute Postgres-Funktionen) und danach + innerhalb der Sperre erneut zählt — zwei gleichzeitige erste Aufrufe desselben Benutzers dürfen nicht + zwei Reiter erzeugen. Der deutsche Kommentar erklärt genau diesen Grund. +- Privater Riegel `assertOwnedDashboard(dashboardId, userId, tenantId)` wie unter „Festgelegte technische + Form“ beschrieben, mit deutschem Kommentar, der die drei ununterscheidbaren Fälle benennt. +- `getLayout`, `saveLayout`, `getWidgets`, `addWidget` nehmen die Reiter-Kennung entgegen, rufen zuerst + den Riegel und arbeiten danach über `dashboardId` statt über `userId`. Die vorhandenen Besitzprüfungen + über die Benutzerkennung bleiben zusätzlich bestehen — zweites Netz, kein Ersatz, genau wie im + Kopfkommentar der Datei beschrieben. `saveLayout` behält die Übersetzung der + `PrismaClientUnknownRequestError` in die deutsche Konfliktmeldung, jetzt auf der Eindeutigkeit der + Reiter-Kennung. +- `getWidgets` behält den Modulfilter (D-22, PERM-07) und die bewusst ungebundene Katalogabfrage + unverändert — nur die Bedingung der ersten Abfrage wechselt von Benutzer auf Reiter. + +Controller: die vier betroffenen Wege reichen die Reiter-Kennung durch (bei den Lesewegen als +Abfrageparameter, bei den Schreibwegen aus dem Rumpf), und `GET /dashboard/tabs` kommt hinzu. Die +beiden DTOs bekommen ein Pflichtfeld für die Reiter-Kennung mit Zeichenkettenprüfung. Weitere Reiter-Wege +folgen in Task 2 — dieser Task hält den Baum übersetzbar und das Verhalten für den Benutzer +unverändert (ein Reiter, wie bisher). + +Tests in `dashboard.service.spec.ts`: die bestehenden 31 bleiben unverändert bestehen; der Mock von +`prisma-tenant.extension` bekommt `withTenantTransaction` nach der Form aus `favorites.service.spec.ts` +(protokollierender Durchreicher auf den gebundenen Klienten, der Transaktionsklient braucht zusätzlich +eine Attrappe für das rohe Ausführen der Sperranweisung), der gebundene Nachbau bekommt das neue Modell. +Neu mindestens: erster Aufruf ohne vorhandenen Reiter legt genau einen an und liefert ihn; zweiter Aufruf +legt keinen weiteren an; die Liste kommt nach Position aufsteigend; Kacheln und Anordnung werden über die +Reiter-Kennung gelesen und geschrieben; fremde Reiter-Kennung führt bei allen vier Wegen zur +Nicht-gefunden-Antwort; jeder dieser Wege lief über den gebundenen Klienten mit der richtigen +Mandantenkennung. + +Dokument `docs/mandantentrennung-zugriffsklassifikation.md`: neue Zeile in der Fundstellentabelle für das +Paar (`apps/api/src/dashboard/dashboard.service.ts`, `dashboard`) mit Klasse `muss-mandantengebunden` und +Stand `gebunden`, Begründung in der Form der Nachbarzeilen (persönliche Tabelle mit Mandanten- und +Benutzerdimension, Regel von Anfang an mit Benutzerdimension, Besitzprüfung zusätzlich in der Anwendung). +Bereichszeile `dashboard` und Summenzeile mit der Zählschleife aus dem Gate NACHRECHNEN, nicht +abschreiben; die Paarzahl in der Überschrift der Klassen-Verteilung und den Fließtext, der von den vier +Paaren des Bereichs spricht, nachziehen. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec prisma generate && DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" pnpm --filter @tessera/api exec prisma migrate diff --from-url "postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" --to-schema-datamodel prisma/schema.prisma --exit-code + docker exec tessera-ctl-db-1 psql -U tessera -d tessera -t -c 'SELECT (SELECT count(*) FROM "WidgetInstance" WHERE "dashboardId" IS NULL) AS kacheln_ohne_reiter, (SELECT count(*) FROM "DashboardLayout" WHERE "dashboardId" IS NULL) AS anordnungen_ohne_reiter, (SELECT count(*) FROM "Dashboard") AS reiter, (SELECT count(*) FROM "Dashboard" WHERE "position" <> 0) AS reiter_nicht_an_position_null;' + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -20 + cd /home/vicolab/projects/tessera-ctl && pnpm type-check + + +Die Migration läuft gegen die lokale Datenbank durch und `prisma migrate diff` meldet danach weiterhin +keinen Unterschied (Rückgabewert 0). Die Zählabfrage liefert 0 Kacheln ohne Reiter, 0 Anordnungen ohne +Reiter, genau 2 Reiter (die gemessene Zahl der Benutzer mit Bestand auf diesem Rechner) und 0 Reiter +abseits von Position 0. `pnpm --filter @tessera/api test` ist grün mit ≥ 1202 Tests in 76 Dateien, davon +≥ 39 in `dashboard.service.spec.ts`; `rls-coverage.spec.ts` (5) und `rls-access-inventory.spec.ts` (30) +sind unverändert grün — letzteres beweist, dass das neue Paar im Dokument steht. `pnpm type-check` ist +4 von 4. + + Die Bestandsübernahme hängt vorhandene Kacheln und Anordnungen auf neue Zeilen um und entfernt die Eindeutigkeit auf `DashboardLayout.userId` — zurück geht das nur über eine weitere Migration, die Daten selbst bleiben dabei erhalten. KEIN Entscheidungs-Halt davor: die Form der Migration ist als D-03 bereits gebunden und wird hier nicht erneut zur Abstimmung gestellt. + + + + Task 2: Reiter anlegen, umbenennen, löschen und umsortieren — Dienst, DTOs, Endpunkte + 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/dashboard.controller.spec.ts, apps/api/src/dashboard/dto/rename-dashboard.dto.ts, apps/api/src/dashboard/dto/reorder-dashboards.dto.ts + apps/api/src/favorites/favorites.service.ts (Methode `reorder` — Vorlage für Transaktion, Exakt-Abgleich und die Bedingung je Aktualisierung), apps/api/src/favorites/dto/reorder-favorites.dto.ts (Vorlage für Obergrenzen), apps/api/src/dashboard/dashboard-images.controller.spec.ts (Form einer Controller-Testdatei in diesem Bereich), apps/api/src/dashboard/dashboard.controller.ts + + - Anlegen ohne Namensvorgabe erzeugt „Dashboard 2“, wenn nur „Dashboard“ existiert; „Dashboard 3“, wenn „Dashboard“ und „Dashboard 2“ existieren; und füllt eine Lücke, wenn „Dashboard“ und „Dashboard 3“ existieren (dann „Dashboard 2“). + - Anlegen hängt den neuen Reiter ans Ende (höchste vorhandene Position plus eins) und liefert ihn mit leerer Kachelliste. + - Anlegen über der Obergrenze von 20 Reitern wird abgewiesen, ohne eine Zeile zu schreiben. + - Umbenennen beschneidet Leerraum; ein leerer Name und ein Name über 40 Zeichen werden abgewiesen. + - Umbenennen eines fremden Reiters liefert die Nicht-gefunden-Antwort. + - Löschen entfernt Reiter, seine Kacheln und seine Anordnung in EINER Transaktion und schreibt die Positionen der verbleibenden Reiter lückenlos von 0 an neu. + - Löschen des letzten verbleibenden Reiters wird abgewiesen und schreibt nichts. + - Löschen eines fremden Reiters liefert die Nicht-gefunden-Antwort. + - Umsortieren mit der vollständigen, dublettenfreien Kennungsliste schreibt die Positionen 0…n-1 in der gesendeten Reihenfolge. + - Umsortieren mit einer unvollständigen Liste, mit einer unbekannten Kennung oder mit der Kennung eines fremden Reiters wird abgewiesen, ohne eine einzige Position zu ändern. + + +Zuerst die Testfälle aus dem Verhaltensblock in `dashboard.service.spec.ts` schreiben (rot), dann den +Dienst ergänzen. + +Dienst: `createDashboard`, `renameDashboard`, `deleteDashboard`, `reorderDashboards`. Anlegen und +Umbenennen laufen als Einzeloperationen über `forTenant(...)`; Löschen und Umsortieren laufen je als EINE +`withTenantTransaction`, weil sie mehrere Schritte atomar brauchen — dieselbe Begründung, die der +Kopfkommentar von `prisma-tenant.extension.ts` für `favorites.service.ts` festhält. Innerhalb der +Transaktion trägt jede Bedingung die Benutzerkennung selbst, weil diese Form keine Benutzerdimension in +der Sitzung setzt. + +Namensvergabe (D-08): die vorhandenen Namen des Benutzers lesen und die kleinste Zahl ab 2 wählen, für +die der zusammengesetzte Name noch frei ist. Der deutsche Kommentar hält fest, dass dieser Name ein +gespeicherter Datenwert ist und keine Oberflächenbeschriftung — deshalb steht er hier und nicht in den +Übersetzungsdateien, genau wie der Name, den die Migration vergibt. + +Löschen: Riegel zuerst, dann die Zahl der Reiter des Benutzers prüfen (bei eins abweisen mit einer +deutschen Konfliktmeldung in der Sie-Form), dann in der Transaktion die Kacheln des Reiters, die +Anordnung des Reiters und den Reiter selbst entfernen und zuletzt die Positionen der verbleibenden Reiter +lückenlos neu schreiben. Die Löschweitergabe in der Datenbank bleibt als zweites Netz bestehen; der +geschriebene Weg ist der gebundene. + +Umsortieren: wörtlich nach dem Muster `FavoritesService.reorder`. + +DTOs: `rename-dashboard.dto.ts` mit Beschneiden und Längenprüfung 1 bis 40; `reorder-dashboards.dto.ts` +mit Feldprüfung auf ein dublettenfreies Feld von 1 bis 20 Kennungen. Beide mit deutschem Kommentar, der +die Obergrenze als Riegel gegen Massenanfragen benennt. + +Controller: die fünf Reiter-Wege ergänzen. Die Route mit dem festen Bestandteil für das Umsortieren MUSS +vor den Routen mit Platzhalter stehen — in dieser Anwendung hat eine Route mit Platzhalter schon einmal +eine dahinter stehende feste Route verdeckt, und Einzeltests am Dienst fangen das nicht. + +`dashboard.controller.spec.ts` neu anlegen (Form aus `dashboard-images.controller.spec.ts`): die +Reiter-Kennung wird aus Abfrageparameter bzw. Rumpf an den Dienst durchgereicht; fehlender Benutzer- oder +Mandantenkontext führt zur vorhandenen Abweisung; und ein quelltextlesender Wächter prüft, dass die +Stelle des festen Wegs für das Umsortieren im Dateitext VOR der ersten Stelle mit Platzhalter unter +demselben Präfix liegt — mit einer Fehlermeldung, die den Grund nennt. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -20 + cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint + + +`pnpm --filter @tessera/api test` grün mit ≥ 1202 Tests in ≥ 77 Dateien; `dashboard.service.spec.ts` +trägt ≥ 45 Tests (die 31 alten unverändert), `dashboard.controller.spec.ts` ≥ 6. Jeder Punkt des +Verhaltensblocks hat einen eigenen Testfall, insbesondere je einer für „fremder Reiter“ bei Umbenennen, +Löschen und Umsortieren und einer für „letzter Reiter bleibt“. `pnpm type-check` 4/4, `pnpm lint` 5/5 mit +unverändert 53 Warnungen in web. + + + + + Task 3: Reiterleiste in der Oberfläche — wechseln, anlegen, umbenennen, löschen + 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/components/dashboard/dashboard-tabs.tsx, apps/web/src/components/dashboard/dashboard-tabs.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json + apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/stores/dashboard-store.test.ts, apps/web/src/lib/dashboard-api.ts, apps/web/src/app/(portal)/page.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx (Form eines deutschen Bedienbausteins mit next-intl), apps/web/src/messages/de.json Abschnitt `widgets` + + - Beim Laden holt der Store zuerst die Reiter, macht den ERSTEN aktiv und lädt erst danach dessen Kacheln und Anordnung. + - Zweimaliges Aufrufen des Ladens hintereinander löst nur EINEN Abruf der Reiterliste aus (Schutz gegen doppeltes Einhängen). + - Ein Reiterwechsel mit ungespeicherter Anordnung schreibt die Anordnung zuerst für den ALTEN Reiter und wechselt erst danach — die gespeicherte Kennung ist die des alten Reiters, nicht die des neuen. + - Nach einem Reiterwechsel stehen im Zustand ausschließlich die Kacheln und die Anordnung des neuen Reiters. + - Eine hinzugefügte Kachel wird mit der Kennung des aktiven Reiters angelegt. + - Anlegen eines Reiters hängt ihn hinten an und macht ihn aktiv; die Kachelfläche ist leer. + - Löschen des aktiven Reiters macht den dann ersten Reiter aktiv und lädt dessen Inhalt. + - Die Reiterleiste zeigt Umbenennen und Löschen nur im Bearbeitungsmodus; beim letzten verbleibenden Reiter wird Löschen gar nicht erst angeboten. + + +Zuerst die Testfälle aus dem Verhaltensblock schreiben (rot), dann umsetzen. + +`dashboard-api.ts`: Abrufe für die fünf Reiter-Wege ergänzen und die vier bestehenden Aufrufe um die +Reiter-Kennung erweitern (Leseweg als Abfrageparameter, Schreibweg im Rumpf), in derselben Form wie die +vorhandenen Funktionen (gleiche Basisadresse, `credentials`, Fehlerwurf bei nicht erfolgreicher Antwort). + +`dashboard-store.ts`: Zustand um die Reiterliste, die aktive Reiter-Kennung und ein Kennzeichen für den +laufenden Reiterwechsel erweitern. `loadDashboard` holt die Reiter, setzt den ersten als aktiv und lädt +dessen Inhalt; ein auf Modulebene gehaltenes Versprechen verhindert, dass ein zweites Einhängen einen +zweiten Abruf auslöst. `selectDashboard` schreibt bei ungespeicherter Anordnung zuerst für den alten +Reiter (Kennung zum Aufrufzeitpunkt festhalten, nicht nach dem Wechsel lesen) und lädt danach Kacheln und +Anordnung des neuen Reiters. `createDashboard`, `renameDashboard`, `deleteDashboard` pflegen die +Reiterliste und den aktiven Reiter. Die einmalige Umrechnung alter Rastereinheiten +(`migrateGridLayouts`/`withGridVersion`, quick-260916-bwo) bleibt unverändert und gilt weiterhin je +Reiter — der Marker muss auch beim Speichern nach einem Reiterwechsel mitgeschrieben werden. + +`dashboard-tabs.tsx` neu: waagerechte Leiste über dem Raster, jeder Reiter ein Bedienelement mit seinem +Namen, der aktive sichtbar hervorgehoben, Beschriftungen und Hinweise ausschließlich über next-intl. +Klick wechselt. Im Bearbeitungsmodus zusätzlich: ein Knopf zum Anlegen am Ende der Leiste, je Reiter ein +Knopf zum Löschen (beim letzten verbleibenden nicht vorhanden) und auf dem aktiven Reiter ein Knopf zum +Umbenennen, der den Namen an Ort und Stelle in ein Eingabefeld verwandelt (Eingabetaste übernimmt, +Escape verwirft). Vor dem Löschen eine kurze Rückfrage mit dem Namen des Reiters. Die Bedienelemente +tragen barrierefreie Beschriftungen in derselben Form wie die vorhandenen Dashboard-Bausteine. + +`(portal)/page.tsx`: die Leiste über dem Raster einhängen und während eines Reiterwechsels statt des +Rasters eine kurze Ladezeile zeigen — die Leiste selbst bleibt dabei stehen. Die bestehende +ganzseitige Ladeschranke für den allerersten Abruf bleibt unverändert, ebenso die Aktionsleiste unten +rechts und der Kachelkatalog. An `DashboardGrid` wird NICHTS geändert (D-06). + +Übersetzungen: neue Schlüssel unter `widgets.tabs.*` in `de.json` UND `en.json` anlegen. Deutsch in der +Sie-Form mit echten Umlauten — der Wächter in `apps/web/src/messages/umlaut-guard.spec.ts` liest `de.json` +und schlägt bei Ersatzschreibweisen fehl. + +Die Tests für die Leiste laufen unter jsdom; der Store wird darin nach dem Muster der vorhandenen +Dashboard-Tests über gemockte Abrufe bedient. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -12 + cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint + + +`pnpm --filter @tessera/web test` grün mit ≥ 661 Tests in ≥ 82 Dateien; `dashboard-store.test.ts` trägt +≥ 14 Tests (die 6 alten unverändert), `dashboard-tabs.test.tsx` ≥ 6. `dashboard-grid.test.tsx` bleibt bei +12 Tests und unverändertem Inhalt. `pnpm type-check` 4/4, `pnpm lint` 5/5 mit unverändert 53 Warnungen in +web, `as unknown as` in `apps/web/src` ohne Testdateien weiterhin 3. + + + + + Task 4: Reiter per Ziehen umsortieren — der erste ist der Standard + apps/web/src/components/dashboard/dashboard-tabs.tsx, apps/web/src/components/dashboard/dashboard-tabs.test.tsx, apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/stores/dashboard-store.test.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json + apps/web/src/components/settings/xframe-config-form.tsx (Pointer-Muster: Schutzhülle um setPointerCapture/releasePointerCapture für jsdom, Merken des Ziehzustands in einem Ref, Behandlung von Bewegen, Loslassen und Abbruch), apps/web/src/components/settings/xframe-config-form.test.tsx (wie Ziehen unter jsdom gemessen wird) + + - Drücken und Loslassen ohne nennenswerte Bewegung wechselt nur den Reiter und sendet KEINE neue Reihenfolge. + - Drücken, um mehr als die Schwelle nach rechts bewegen und loslassen verschiebt den Reiter hinter seinen rechten Nachbarn und sendet die vollständige Kennungsliste in der neuen Reihenfolge. + - Dasselbe nach links verschiebt vor den linken Nachbarn. + - Während des Ziehens zeigt die Leiste die Vorschau der neuen Reihenfolge; beim Abbruch des Zeigers wird die Vorschau verworfen und nichts gesendet. + - Ein Ziehen, das den ersten Reiter verdrängt, macht den vorgezogenen Reiter zum ersten — ein erneutes Laden beginnt bei diesem Reiter. + - Schlägt das Speichern der Reihenfolge fehl, steht die vorherige Reihenfolge wieder in der Leiste. + + +Zuerst die Testfälle aus dem Verhaltensblock schreiben (rot), dann umsetzen. + +Das Ziehen in `dashboard-tabs.tsx` über Pointer-Ereignisse nach dem Muster aus `xframe-config-form.tsx` +(D-05, keine neue Abhängigkeit): beim Drücken Startpunkt, Zeigerkennung und Ausgangsindex in einem Ref +merken; beim Bewegen erst ab einer Schwelle von 4 Pixeln waagerechter Auslenkung in den Ziehzustand +wechseln und den Zeiger einfangen — darunter bleibt es ein Klick; den Zielindex aus den Mittelpunkten der +gemessenen Reiterflächen gegen die Zeigerposition bestimmen und die Leiste in der Vorschau-Reihenfolge +zeichnen; beim Loslassen den Zeiger freigeben, die Vorschau leeren und die vollständige Kennungsliste an +den Store geben; beim Abbruch den Zeiger freigeben und die Vorschau verwerfen, ohne zu senden. Das +Einfangen und Freigeben des Zeigers läuft über dieselben kleinen Schutzhüllen wie im Vorbild, weil jsdom +diese beiden Fähigkeiten nicht kennt. + +Ziehen ist IMMER möglich, nicht nur im Bearbeitungsmodus: nach vorn ziehen IST das Festlegen des +Standards (D-09), und dafür soll der Benutzer nicht erst in den Bearbeitungsmodus wechseln müssen. Ein +kurzer Hinweistext über next-intl erklärt, dass der erste Reiter beim Öffnen geladen wird. + +Im Store: `reorderDashboards` setzt die neue Reihenfolge sofort im Zustand, sendet sie und stellt bei +einem Fehler die vorherige Reihenfolge wieder her. Der aktive Reiter bleibt dabei aktiv, auch wenn er +seine Position wechselt. + +Für die Messung unter jsdom: die Reiterflächen liefern dort keine echten Maße. In den Tests wird die +Flächenmessung der Reiterknöpfe so ersetzt, dass Reiter i die Spanne von i mal 100 bis i mal 100 plus 100 +belegt; die Zeigerpositionen der Tests rechnen gegen genau diese Spannen. Der deutsche Kommentar im Test +hält fest, warum das nötig ist. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -12 + cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint + cd /home/vicolab/projects/tessera-ctl && grep -c "react-grid-layout\|dnd\|sortable" apps/web/package.json + + +`pnpm --filter @tessera/web test` grün mit ≥ 661 Tests in ≥ 83 Dateien; `dashboard-tabs.test.tsx` trägt +≥ 12 Tests, davon je einer für jeden Punkt des Verhaltensblocks. Die Zählung in `apps/web/package.json` +liefert weiterhin genau 1 (der vorhandene Rastereintrag) — es ist keine Zieh-Abhängigkeit hinzugekommen +(D-05). `pnpm type-check` 4/4, `pnpm lint` 5/5 mit unverändert 53 Warnungen in web. + + + + + Task 5: Anwenderhandbuch, Changelog und Nachmessung aller Tore + docs/anleitung-anwender.md, CHANGELOG.md, docs/mandantentrennung-zugriffsklassifikation.md + docs/anleitung-anwender.md Zeile 59-70 (Abschnitt Dashboard), CHANGELOG.md Kopf (Abschnitt „Unveröffentlicht“) + +`docs/anleitung-anwender.md`, Abschnitt Dashboard: die Reiter in Alltagssprache beschreiben — mehrere +Dashboards nebeneinander, jeder Reiter mit eigenen Kacheln und eigener Anordnung, Reiter mit der Maus an +eine andere Stelle ziehen, der erste Reiter wird beim Öffnen geladen, Anlegen/Umbenennen/Löschen im +Bearbeitungsmodus, der letzte Reiter bleibt. Kein Fachbegriff, Sie-Form, echte Umlaute, Stil der +umliegenden Absätze. + +`CHANGELOG.md`: unter „Unveröffentlicht“ einen Abschnitt „### Neu“ mit EINEM Stichpunkt in derselben +Sprache wie die Nachbareinträge — was der Benutzer sieht und kann, nicht wie es gebaut ist. Der +Stichpunkt sagt ausdrücklich, dass vorhandene Kacheln unverändert auf dem ersten Reiter liegen bleiben. + +`docs/mandantentrennung-zugriffsklassifikation.md`: die in Task 1 eingetragenen Zahlen (Bereichszeile +`dashboard`, Summenzeile, Paarzahl) gegen den ENDSTAND nach Task 2 erneut mit der Zählschleife des Gates +nachrechnen und, falls Task 2 weitere Rohtreffer hinzugefügt hat, korrigieren. Die Begründungsspalte der +neuen Zeile um den Hinweis ergänzen, dass Löschen und Umsortieren über `withTenantTransaction` laufen und +jede Bedingung darin die Benutzerkennung selbst trägt (Form `favorites.service.ts`/`reorder`). + +Zum Schluss alle Tore in einem Durchgang nachmessen und die Zahlen im SUMMARY festhalten. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -6 && pnpm --filter @tessera/web test 2>&1 | tail -6 && pnpm type-check && pnpm lint + cd /home/vicolab/projects/tessera-ctl && grep -c "Reiter" docs/anleitung-anwender.md CHANGELOG.md + + +Handbuch und Changelog beschreiben die Reiter in Alltagssprache (die Zählung liefert für beide Dateien +mindestens 1). Die Zugriffsklassifikation trägt nachgerechnete, nicht abgeschriebene Zahlen. Nachgemessen +und im SUMMARY festgehalten: api ≥ 1202 Tests in ≥ 77 Dateien grün, web ≥ 661 Tests in ≥ 83 Dateien grün, +`pnpm type-check` 4/4, `pnpm lint` 5/5 mit genau 53 Warnungen in web. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → API | Jede Reiter-Kennung kommt aus dem Browser und ist unvertrauenswürdig — Abfrageparameter und Rümpfe sind frei wählbar | +| Benutzer → Benutzer (derselbe Mandant) | Die Regeln der persönlichen Tabellen tragen die Benutzerdimension, wirken aber erst mit der Rolle ohne Umgehungsrecht (Schalter heute aus) — die anwendungsseitigen Besitzprüfungen sind bis dahin der einzige wirksame Schutz | +| Mandant → Mandant | `forTenant()` setzt die Mandantenkennung je Abfrage; die neue Tabelle braucht dieselbe Regel wie ihre Nachbarn | +| Migration → Bestandsdaten | Die Migration läuft als Superuser und umgeht den Zeilenschutz — eine falsche Zuordnung würde Kacheln über Benutzergrenzen verschieben | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-AD9-01 | Information Disclosure | `GET /dashboard/widgets`, `GET /dashboard/layout` mit fremder Reiter-Kennung | high | mitigate | `assertOwnedDashboard` vor jeder Abfrage; identische Nicht-gefunden-Antwort für „gibt es nicht“, „Kollege“, „fremder Mandant“ (Task 1, je ein Test) | +| T-AD9-02 | Tampering | `POST /dashboard/widgets`, `PUT /dashboard/layout` mit fremder Reiter-Kennung | high | mitigate | Derselbe Riegel vor dem Schreiben, auf demselben gebundenen Klienten wie die anschließende Schreiboperation (Task 1, je ein Test) | +| T-AD9-03 | Tampering | `PATCH`/`DELETE /dashboard/tabs/:id` mit fremder Kennung | high | mitigate | Riegel zuerst; Löschen zusätzlich mit Bedingung auf die Benutzerkennung innerhalb der Transaktion (Task 2, je ein Test) | +| T-AD9-04 | Tampering | `PUT /dashboard/tabs/order` mit fremden oder unbekannten Kennungen | high | mitigate | Exakt-Abgleich gegen die gelesenen Kennungen innerhalb der Transaktion, Prüfung auf genau eine getroffene Zeile je Schritt, Abweisung ohne Teilschreiben (Task 2, zwei Tests) | +| T-AD9-05 | Information Disclosure | Neue Tabelle `Dashboard` ohne Zeilenschutz | high | mitigate | `tenantId` als Pflichtspalte, ENABLE + FORCE + `tenant_isolation_policy` mit Benutzerdimension in der Migration; `rls-coverage.spec.ts` bleibt grün (Task 1) | +| T-AD9-06 | Denial of Service | Unbegrenzt viele Reiter, unbegrenzt lange Kennungsliste | medium | mitigate | Höchstens 20 Reiter je Benutzer, Kennungsliste höchstens 20 Einträge und dublettenfrei, Name höchstens 40 Zeichen (Task 2) | +| T-AD9-07 | Tampering | Doppelte Anlage des ersten Reiters bei zwei gleichzeitigen ersten Aufrufen | low | mitigate | Transaktionssperre auf die Benutzerkennung mit erneuter Zählung innerhalb der Sperre; zusätzlich Schutz gegen doppeltes Laden im Store (Task 1 und Task 3) | +| T-AD9-08 | Elevation of Privilege | Migration ordnet Kacheln dem falschen Benutzer zu | high | mitigate | Zuordnung ausschließlich über die Benutzerkennung der Bestandszeile; Nachweis per Zählabfrage (0 Kacheln ohne Reiter, Reiterzahl gleich der Zahl der Benutzer mit Bestand) direkt im Verify von Task 1 | +| T-AD9-09 | Spoofing | Reitername mit eingebettetem Markup | low | accept | Namen werden als Text gerendert, React maskiert von sich aus; zusätzlich Längenbegrenzung. Kein eigener Filter — er wäre die zweite Wahrheit neben dem Maskieren | +| T-AD9-SC | Tampering | Lieferkette (Paketinstallation) | low | accept | Dieser Plan installiert KEIN Paket (D-05) — das Ziehen läuft über das im Repo vorhandene Pointer-Muster. Der Prüfpunkt für Paketechtheit entfällt mangels Installation; Task 4 misst die Abwesenheit einer neuen Zieh-Abhängigkeit nach | + + + +Nach Task 5, in einem Durchgang und mit notierten Zahlen: + +``` +cd /home/vicolab/projects/tessera-ctl +pnpm --filter @tessera/api test +pnpm --filter @tessera/web test +pnpm type-check +pnpm lint +DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) +DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" \ + pnpm --filter @tessera/api exec prisma migrate diff \ + --from-url "postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" \ + --to-schema-datamodel prisma/schema.prisma --exit-code +``` + +**Prüfliste für den Browser-Rundgang** (gehört ins SUMMARY, nicht in diesen Plan auszuführen — der Nutzer +baut und startet die Container selbst): + +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. + + + +- Jeder Punkt unter `must_haves.truths` ist erfüllt und durch einen Test oder eine Messung belegt. +- Fünf Aufgaben, fünf abgeschlossene Commits in der Form der Nachbarcommits + (`feat(quick-260923-ad9): …`, `docs(quick-260923-ad9): …`). +- Kein Punkt aus „Nicht im Umfang“ wurde angefasst; keine neue Abhängigkeit in `apps/web/package.json` + oder `apps/api/package.json`. +- `DashboardGrid` ist unverändert; `dashboard-grid.test.tsx` steht unverändert bei 12 Tests. +- Die Zugriffsklassifikation ist nachgerechnet, nicht abgeschrieben. + + + +SUMMARY nach `.planning/quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/260923-ad9-SUMMARY.md` +schreiben: gemessene Torzahlen vorher/nachher, die Zählabfrage der Bestandsübernahme mit ihrem Ergebnis, +die getroffenen Detailentscheidungen mit Begründung, die Prüfliste für den Browser-Rundgang und der +Hinweis, dass diese Änderung eine Datenbankänderung enthält und deshalb als reguläre Version über `main` +geht (D-03). +