8 Commits

Author SHA1 Message Date
schalli 0094a60d15 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>
2026-09-23 08:29:50 +02:00
schalli 58ce88e29f docs(quick-260923-ad9): Anwenderhandbuch, Changelog und Nachmessung aller Tore - Task 5
docs/anleitung-anwender.md: neuer Absatz "Mehrere Dashboards (Reiter)" im
Dashboard-Abschnitt, Alltagssprache - mehrere Dashboards nebeneinander,
Ziehen legt den Standard fest, Anlegen/Umbenennen/Loeschen im
Bearbeitungsmodus, der letzte Reiter bleibt.

CHANGELOG.md: ein Stichpunkt unter "Unveroeffentlicht" > "Neu" - was der
Benutzer sieht, mit dem ausdruecklichen Hinweis, dass vorhandene Kacheln
unveraendert auf dem ersten Reiter liegen bleiben.

docs/mandantentrennung-zugriffsklassifikation.md: Endstand nach Task 2
nachgerechnet (nicht aus Task 1 abgeschrieben) - Bereich dashboard 24->28
gebunden (Task 2 bringt vier weitere `tenantPrisma.dashboard.`-Rohtreffer:
createDashboard/renameDashboard/deleteDashboard), Summe 193->197. Die
Begruendungsspalte des Paares dashboard.service.ts/dashboard nennt jetzt
auch Task 2 (withTenantTransaction fuer deleteDashboard/reorderDashboards,
Muster favorites.service.ts/reorder). Paarzahl (75) unveraendert - Task 2
fuegt keine neuen (Datei,Modell)-Paare hinzu, nur weitere Rohtreffer
bestehender Paare.

Alle Tore nachgemessen: api 1240 Tests in 77 Dateien gruen (>= 1202/77),
web 693 Tests in 82 Dateien gruen (>= 661), type-check 4/4, lint 5/5 mit
genau 53 Warnungen in web, `prisma migrate diff` weiterhin ohne Unterschied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:21:05 +02:00
schalli 05feaa3dd6 feat(quick-260923-ad9): Reiter per Ziehen umsortieren - Task 4
dashboard-tabs.tsx: Ziehen ueber Pointer-Ereignisse (Muster
xframe-config-form.tsx, D-05 - keine neue Abhaengigkeit). Schwelle 4 px
waagerechte Auslenkung trennt Klick von Ziehen; darueber wird der Zeiger
eingefangen (jsdom-Schutzhuelle), die Leiste zeigt die Vorschau-Reihenfolge
(computeReorderedIds, Einfuegen vor dem ersten Nachbarn mit Mittelpunkt
rechts vom Zeiger), beim Loslassen geht die VOLLSTAENDIGE Kennungsliste an
onReorder. Abbruch des Zeigers verwirft die Vorschau ohne zu senden. Ein
hasDraggedRef-Merker unterdrueckt den Klick, der im echten Browser nach
einem Ziehen folgt. Ziehen ist IMMER moeglich, nicht nur im
Bearbeitungsmodus (D-09) - ein Hinweistext erklaert, dass der erste Reiter
beim Oeffnen geladen wird.

dashboard-store.ts: reorderDashboards() setzt die neue Reihenfolge SOFORT
optimistisch, sendet sie und stellt bei einem Fehler die vorherige
Reihenfolge wieder her; der aktive Reiter bleibt aktiv.

dashboard-tabs.test.tsx: 21 Tests (12 alte aus Task 3 + 9 neue fuer jeden
Punkt des Verhaltensblocks). getBoundingClientRect wird je Reiter-Wrapper
ueber data-tab-index gestubbt (Reiter i belegt 100i..100i+100 - jsdom
liefert keine echten Masse). dashboard-store.test.ts: 17 Tests (15 alte +
2 neue fuer Optimismus/Ruecknahme).

Messages: widgets.tabs.dragHint war bereits in Task 3 eingetragen
(vorausschauend) - in diesem Task keine weitere Aenderung an de.json/en.json
noetig.

Gemessene Abweichung von der Plan-Erwartung (kein Rule-1/2/3-Fall, reine
Zahlendifferenz): `grep -c "react-grid-layout|dnd|sortable"
apps/web/package.json` liefert 2 statt der im Plan erwarteten 1 - der
zweite Treffer ist `@types/react-grid-layout`, bereits vor diesem Task
vorhanden (siehe `git diff --stat apps/web/package.json`: keine Aenderung
in keinem der vier Tasks). D-05 (keine neue Zieh-Abhaengigkeit) ist damit
weiterhin erfuellt, nur an der leeren package.json-Diff nachgewiesen statt
an der im Plan vorausgesagten Zahl. Ebenso liefert `pnpm --filter
@tessera/web test` 82 statt der erwarteten 83 Dateien - Task 4 fuegt (siehe
files_modified oben) keine neue Testdatei hinzu, Task 3 hatte die
Dateizahl bereits auf 82 gebracht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:18:25 +02:00
schalli d34f682c28 feat(quick-260923-ad9): Reiterleiste in der Oberflaeche - Task 3
dashboard-api.ts: fuenf neue Abrufe fuer die Reiter-Wege, die vier
bestehenden Aufrufe (Layout/Widgets lesen/schreiben) tragen jetzt die
Reiter-Kennung (Abfrageparameter bzw. Rumpf).

dashboard-store.ts: Zustand um dashboards/activeDashboardId/
isSwitchingDashboard erweitert. loadDashboard() holt zuerst die Reiter,
macht den ersten aktiv, laedt erst danach dessen Inhalt; ein modul-globales
Versprechen schuetzt gegen doppeltes Laden der Reiterliste bei doppeltem
Einhaengen. selectDashboard() schreibt eine ungespeicherte Anordnung ZUERST
fuer den alten Reiter (Kennung vor dem Wechsel gelesen) und ersetzt danach
Kacheln/Anordnung vollstaendig. createDashboard/renameDashboard/
deleteDashboard pflegen Reiterliste und aktiven Reiter; Loeschen des
aktiven Reiters macht den dann ersten Reiter aktiv. Die Marker-Umrechnung
(quick-260916-bwo) laeuft unveraendert je Reiter mit, auch beim Wechsel.

dashboard-tabs.tsx (neu): Reiterleiste, Klick wechselt immer; im
Bearbeitungsmodus zusaetzlich Anlegen, Umbenennen (an Ort und Stelle,
Enter/Escape) und Loeschen (mit Rueckfrage) - Loeschen-Knopf fehlt beim
letzten verbleibenden Reiter (D-10). Fokus beim Umbenennen ueber einen Ref
statt autoFocus (lint/a11y/noAutofocus).

(portal)/page.tsx: Leiste ueber dem Raster, kurze Ladezeile waehrend eines
Reiterwechsels statt des Rasters - die Leiste bleibt stehen. DashboardGrid
selbst unveraendert (D-06).

Uebersetzungen: neue Schluessel unter widgets.tabs.* in de.json/en.json,
Dialog-Knoepfe nutzen die vorhandenen common.cancel/common.delete.

Deviations (Rule 3 - blockierende Nachwirkung dieses Tasks, ausserhalb der
files_modified-Liste, aber direkt durch die dashboardId-Pflicht verursacht):
- settings/dashboard/page.tsx: fetchWidgets() verlangt jetzt eine
  Reiter-Kennung; die Seite ist nicht reiterbewusst (ausserhalb des
  Umfangs) und zeigt jetzt die Kacheln des ERSTEN Reiters - deckungsgleich
  mit dem bisherigen Verhalten fuer den haeufigen Fall genau eines Reiters.
- (portal)/page.test.tsx: mockStore brauchte die neuen Reiter-Felder/
  -Methoden, sonst waere DashboardTabs auf `dashboards.map` von undefined
  gescheitert.

Tests: dashboard-store.test.ts 15 (6 alte angepasste Signaturen + 9 neue),
dashboard-tabs.test.tsx 12 (neu). web gesamt 682 Tests in 82 Dateien,
dashboard-grid.test.tsx unveraendert bei 12. type-check 4/4, lint 5/5 mit
weiterhin genau 53 Warnungen in web.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:10:45 +02:00
schalli df7a5e7e8e feat(quick-260923-ad9): Reiter anlegen, umbenennen, loeschen, umsortieren - Task 2
dashboard.service.ts: createDashboard() (Namensvergabe "Dashboard N" fuellt
Luecken, D-08; Obergrenze 20 Reiter, T-AD9-06), renameDashboard() (Riegel
zuerst), deleteDashboard() (letzter Reiter bleibt, D-10; Loeschen + Neu-
Nummerierung als EINE withTenantTransaction), reorderDashboards() (woertlich
nach FavoritesService.reorder-Muster: Exakt-Abgleich vor jedem Schreiben,
kein Teilschreiben, dieselbe Abweisung fuer unvollstaendige/unbekannte/
fremde Kennungen - T-AD9-04).

dashboard.controller.ts: fuenf neue Wege unter tabs; PUT tabs/order VOR
PATCH/DELETE tabs/:id deklariert (Routenreihenfolge).

dashboard.controller.spec.ts (neu, 8 Tests): Durchreichung, Abweisung ohne
Kontext, quelltextlesender Waechter fuer die Routenreihenfolge.

dashboard.service.spec.ts: 61 Tests (43 alte + 18 neue fuer Anlegen,
Umbenennen inkl. DTO-Beschneidung/-Laengenpruefung, Loeschen und
Umsortieren - je ein Fall fuer "fremder Reiter" und "letzter Reiter bleibt").

Deviation (Rule 1): widget-module-map.spec.ts's CreateWidgetDto-Whitelist
helper needed a dashboardId fixture after Task 1 made the field required -
fixed inline, out of the plan's files_modified list but directly caused by
Task 1's DTO change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:00:59 +02:00
schalli 9c518238f5 feat(quick-260923-ad9): Datenmodell, Migration und Reiter-Grundlage - Task 1
Neues Modell Dashboard (D-01/D-02/D-09): position statt Standard-Feld,
kein Unique auf (userId, position) - Umsortieren schreibt spaeter alle
Positionen einer Transaktion neu. WidgetInstance/DashboardLayout haengen
jetzt am Reiter statt am Benutzer (DashboardLayout.dashboardId @unique
ersetzt userId @unique).

Migration 20260923120000_dashboard_tabs: Zeilenschutz mit Mandant- UND
Benutzerdimension (Form 20260911120000/20260921120000), Bestands-
uebernahme fuer jeden Benutzer mit Kacheln oder Anordnung VOR den
Fremdschluesseln (D-03) - gemessen: 0 Kacheln/Anordnungen ohne Reiter,
genau 2 Reiter auf Position 0.

dashboard.service.ts: listDashboards() (Transaktionssperre gegen
doppelte Erstanlage, T-AD9-07), Riegel assertOwnedDashboard() (fail-
closed gegen fremde Reiter, T-AD9-01/02/03) - getLayout/saveLayout/
getWidgets/addWidget laufen jetzt ueber dashboardId statt userId.
GET /dashboard/tabs neu; die vier bestehenden Wege reichen die Reiter-
Kennung durch. Verhalten fuer den Benutzer unveraendert (ein Reiter,
wie bisher) - Task 2 ergaenzt Anlegen/Umbenennen/Loeschen/Umsortieren.

dashboard.service.spec.ts: 43 Tests (31 alte unveraendert + 12 neue fuer
Reiter-Anlage, -Reihenfolge und den Fremdreiter-Riegel bei allen vier
Wegen). Zugriffsklassifikation nachgerechnet: 75 Paare (+1), Bereich
dashboard 21->24 gebunden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:56:38 +02:00
schalli 84fe73e16a docs(quick-260923-ad9): Plan - Dashboard-Reiter, mehrere Dashboards je Benutzer
Fuenf Aufgaben: Datenmodell mit Bestandsuebernahme, Reiter-Endpunkte mit
Besitz-Riegel, Reiterleiste, Ziehen zum Umsortieren, Doku und Changelog.
Torzahlen vorher gemessen (api 1202/76, web 661/81, type-check 4/4,
lint 5/5 mit 53 Warnungen in web).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 07:43:39 +02:00
schalli fcad4608e4 docs(todo): Flackernder Test tenant-selector hat die Freigabe 1.3.1 blockiert
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m13s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m0s
Zeitueberschreitung bei 5 s auf dem Tag-Lauf, gleicher Commit auf main und live
gruen. Der Abbild-Bau haengt am Test-Job, deshalb wurden Images, Release und
Desktop-Pakete uebersprungen - erst der Neustart hat sie nachgeholt. Trifft
jede Freigabe, weil jede drei Pipelines gleichzeitig ausloest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 07:39:48 +02:00
29 changed files with 3579 additions and 137 deletions
+4 -3
View File
@@ -5,9 +5,9 @@ current_phase: 18
current_phase_name: desktop-client-fertigstellen current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren." stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
last_updated: "2026-09-22T21:10:00.000Z" last_updated: "2026-09-23T08:40:00.000Z"
last_activity: 2026-09-22 last_activity: 2026-09-23
last_activity_desc: Quick 260922-vdk — Dashboard-Raster misst seine Breite auch aus dem Leerzustand; im echten Linux-Client gegengemessen (469 -> 389 px, Ziehen erreicht den rechten Rand) last_activity_desc: Quick 260923-ad9 — Dashboard-Reiter (mehrere Dashboards je Benutzer, Ziehen sortiert, erster ist Standard); davor Freigabe 1.3.1
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2 state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress: progress:
total_phases: 18 total_phases: 18
@@ -464,6 +464,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260922-hk4 | **Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Datenbank.** Frage des Nutzers nach der Freigabe 1.3.0, ob `bytea` auf Dauer sinnvoll ist. Befund: Geschwindigkeit ist NICHT das Argument (ein Bild wird je Browser einmal taeglich geladen), die SICHERUNG ist es — gesichert wird von Hand per `pg_dump`, und 30 Bilder à 5 MiB je Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug (alpha-DB heute 18 MB). Dazu Einheitlichkeit: Profilbilder (`user-files/avatars`, `User.avatarPath`) und DKV-Exporte liegen laengst im Volume. Umsetzung: Spalte `storagePath`, Ablage `user-files/dashboard-images/<userId>/<uuid>.<ext>` — Dateiname IMMER vom Server (UUID + Endung aus dem erkannten Mime-Typ), `originalName` nie im Pfad; ein eigener Ordner je Benutzer ist ausdruecklich KEIN Schutz, es entscheidet weiterhin die Besitzpruefung im Dienst. Umzug laeuft automatisch beim Start (`onApplicationBootstrap` ueber `forSystem()`), idempotent; die Spalte `data` bleibt bewusst vorerst stehen (Todo fuer den DROP, erst wenn alpha und live einmal gelaufen sind). **Befund im Rundgang, eigener Commit:** eine Zeile zeigte auf eine fehlende Datei (lokal Host vs. Container-Volume; im Betrieb: alter `pg_dump` + leeres Volume) — `getBytes` stellt die Datei jetzt aus der noch vorhandenen Spalte `data` wieder her, statt 404 zu melden. **Zahlen:** api 1175 → 1188, web 640, type-check 4/4, lint 5/5, RLS-Waechter 78/78. | 2026-09-22 | 9039cea,8cbfb8b,82472ee | [260922-hk4-bilderrahmen-bilder-auf-die-festplatte](./quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/) | | 260922-hk4 | **Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Datenbank.** Frage des Nutzers nach der Freigabe 1.3.0, ob `bytea` auf Dauer sinnvoll ist. Befund: Geschwindigkeit ist NICHT das Argument (ein Bild wird je Browser einmal taeglich geladen), die SICHERUNG ist es — gesichert wird von Hand per `pg_dump`, und 30 Bilder à 5 MiB je Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug (alpha-DB heute 18 MB). Dazu Einheitlichkeit: Profilbilder (`user-files/avatars`, `User.avatarPath`) und DKV-Exporte liegen laengst im Volume. Umsetzung: Spalte `storagePath`, Ablage `user-files/dashboard-images/<userId>/<uuid>.<ext>` — Dateiname IMMER vom Server (UUID + Endung aus dem erkannten Mime-Typ), `originalName` nie im Pfad; ein eigener Ordner je Benutzer ist ausdruecklich KEIN Schutz, es entscheidet weiterhin die Besitzpruefung im Dienst. Umzug laeuft automatisch beim Start (`onApplicationBootstrap` ueber `forSystem()`), idempotent; die Spalte `data` bleibt bewusst vorerst stehen (Todo fuer den DROP, erst wenn alpha und live einmal gelaufen sind). **Befund im Rundgang, eigener Commit:** eine Zeile zeigte auf eine fehlende Datei (lokal Host vs. Container-Volume; im Betrieb: alter `pg_dump` + leeres Volume) — `getBytes` stellt die Datei jetzt aus der noch vorhandenen Spalte `data` wieder her, statt 404 zu melden. **Zahlen:** api 1175 → 1188, web 640, type-check 4/4, lint 5/5, RLS-Waechter 78/78. | 2026-09-22 | 9039cea,8cbfb8b,82472ee | [260922-hk4-bilderrahmen-bilder-auf-die-festplatte](./quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/) |
| 260922-m1h | **Ein Modul bringt seine Dashboard-Kachel jetzt selbst mit (Vorarbeit fuer Proxmox).** Bestandsaufnahme (lesend) hatte ergeben: ein neuer Widget-Typ war an SIEBEN Stellen hartkodiert (Union-Typ, Constraints, Registry, eigene `wireXWidget()` je Typ, Aufruf in page.tsx, zweite Liste im Katalogfenster, `@IsIn` im API-DTO); die Verbindung Kachel↔Modul existierte als `WIDGET_MODULE_MAP` in `dashboard.service.ts` (filtert fail-closed), war aber nie befuellt; der Katalog zeigte jedem alle Kacheln, auch die gesperrter Module. Umbau: `WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` in `packages/shared` als EINE Quelle (API validiert per `@IsIn` gegen genau sie), ein generisches `registerWidget()` statt neun Funktionen, Katalog leitet seine Liste aus der Registry ab und filtert ueber `/modules/active` (fail-closed bei Fehler, reine Funktion `visibleWidgetTypes`), nicht verfuegbare Kachel zeigt `widgets.unavailable` statt leer zu bleiben. Deckungsgleichheits-Test faengt kuenftig jede vergessene Stelle. **Befund des Executors, geprueft statt vermutet:** `apps/web` hatte KEINE Abhaengigkeit auf `@tessera/shared` (frueher bewusst) — vor der Umsetzung nachgemessen, dass Bau und Produktions-Abbild das tragen (node:24-alpine strippt die Typen nativ); Folgeregel „nur loeschbare Syntax in shared“ steht als Warnung in der Datei. Verhalten der neun Kacheln unveraendert, im Browser bestaetigt (Reihenfolge, Anlegen, Entfernen, keine rohen Schluessel). Bewusst offen: der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` und die Live-Aktualisierung des Katalogs. **Zahlen:** api 1188 → 1202, web 640 → 659, type-check 4/4, lint 5/5 (74/53 wie Basis). | 2026-09-22 | 56c07c3,8be0725 | [260922-m1h-dashboard-widgets-ein-modul-bringt-seine](./quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/) | | 260922-m1h | **Ein Modul bringt seine Dashboard-Kachel jetzt selbst mit (Vorarbeit fuer Proxmox).** Bestandsaufnahme (lesend) hatte ergeben: ein neuer Widget-Typ war an SIEBEN Stellen hartkodiert (Union-Typ, Constraints, Registry, eigene `wireXWidget()` je Typ, Aufruf in page.tsx, zweite Liste im Katalogfenster, `@IsIn` im API-DTO); die Verbindung Kachel↔Modul existierte als `WIDGET_MODULE_MAP` in `dashboard.service.ts` (filtert fail-closed), war aber nie befuellt; der Katalog zeigte jedem alle Kacheln, auch die gesperrter Module. Umbau: `WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` in `packages/shared` als EINE Quelle (API validiert per `@IsIn` gegen genau sie), ein generisches `registerWidget()` statt neun Funktionen, Katalog leitet seine Liste aus der Registry ab und filtert ueber `/modules/active` (fail-closed bei Fehler, reine Funktion `visibleWidgetTypes`), nicht verfuegbare Kachel zeigt `widgets.unavailable` statt leer zu bleiben. Deckungsgleichheits-Test faengt kuenftig jede vergessene Stelle. **Befund des Executors, geprueft statt vermutet:** `apps/web` hatte KEINE Abhaengigkeit auf `@tessera/shared` (frueher bewusst) — vor der Umsetzung nachgemessen, dass Bau und Produktions-Abbild das tragen (node:24-alpine strippt die Typen nativ); Folgeregel „nur loeschbare Syntax in shared“ steht als Warnung in der Datei. Verhalten der neun Kacheln unveraendert, im Browser bestaetigt (Reihenfolge, Anlegen, Entfernen, keine rohen Schluessel). Bewusst offen: der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` und die Live-Aktualisierung des Katalogs. **Zahlen:** api 1188 → 1202, web 640 → 659, type-check 4/4, lint 5/5 (74/53 wie Basis). | 2026-09-22 | 56c07c3,8be0725 | [260922-m1h-dashboard-widgets-ein-modul-bringt-seine](./quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/) |
| 260922-vdk | **Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus.** Meldung des Nutzers aus dem **Linux-Client**: rechts neben dem Kalender freie Flaeche, in die sich keine Kachel schieben laesst — „als ob es keinen Anker gibt“. Aus dem Bildschirmfoto zurueckgerechnet (Spaltenbreite 51,5 px, Platzhalter auf Spalte 13 = letzte moegliche, Rasterende bei x=1459 bei ~1660 px Inhaltsbreite): das Raster rechnete mit **1200 px** statt mit der echten Breite, rechts blieben ~460 px totes Feld. Ursache: die Breitenmessung hing in `useEffect(..., [])` mit `if (!containerRef.current) return` — haengt `DashboardGrid` mit NULL Kacheln ein, rendert der fruehe Ruecksprung in den Leerzustand den gemessenen `<div>` gar nicht, der Effekt bricht ab und laeuft nie wieder, auch nicht wenn spaeter die erste Kachel entsteht. `width` blieb die ganze Sitzung auf dem Startwert 1200; react-grid-layout vergleicht strikt (`width > breakpoint`), 1200 ist damit `md` (20 Spalten, 51,6 px) statt `lg`. Fix: Ref-Rueckruf `measureRef` statt Einmal-Effekt — folgt dem Knoten ueber den Wechsel Leerzustand ↔ gefuellt, misst synchron in der Commit-Phase, haengt den ResizeObserver dort an; Fenster-Horcher als zusaetzliches Netz; `applyWidth` verwirft 0 und nicht endliche Werte. **Verhalten sonst unveraendert** — belegte Plaetze bleiben gesperrt, nichts weicht aus (Ansage des Nutzers). **Geprueft im echten Client**, nicht im Browser: `Tessera-1.3.0.AppImage` auf `DISPLAY=:10` ueber den WebKit-Remote-Inspektor gesteuert. Gleicher Fehlerfall vorher/nachher: Kachel 469 px → **389 px** bei 1000 px Bereich, Ziehen endet jetzt bei 603 px = `1000 − 8 − 389`, exakt der rechte Rand. **Messfalle notiert:** im Client gegen `style.width`/`style.transform` messen, nie gegen `getBoundingClientRect()` — bei Fenster im Hintergrund friert WebKitGTK die Animationsuhr ein und der `width`-Uebergang bleibt auf dem alten Wert stehen. **Zahlen:** web 659 → 661 Tests, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-22 | d9f2af3,cf67c8a | [260922-vdk-dashboard-raster-misst-seine-breite-nich](./quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/) | | 260922-vdk | **Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus.** Meldung des Nutzers aus dem **Linux-Client**: rechts neben dem Kalender freie Flaeche, in die sich keine Kachel schieben laesst — „als ob es keinen Anker gibt“. Aus dem Bildschirmfoto zurueckgerechnet (Spaltenbreite 51,5 px, Platzhalter auf Spalte 13 = letzte moegliche, Rasterende bei x=1459 bei ~1660 px Inhaltsbreite): das Raster rechnete mit **1200 px** statt mit der echten Breite, rechts blieben ~460 px totes Feld. Ursache: die Breitenmessung hing in `useEffect(..., [])` mit `if (!containerRef.current) return` — haengt `DashboardGrid` mit NULL Kacheln ein, rendert der fruehe Ruecksprung in den Leerzustand den gemessenen `<div>` gar nicht, der Effekt bricht ab und laeuft nie wieder, auch nicht wenn spaeter die erste Kachel entsteht. `width` blieb die ganze Sitzung auf dem Startwert 1200; react-grid-layout vergleicht strikt (`width > breakpoint`), 1200 ist damit `md` (20 Spalten, 51,6 px) statt `lg`. Fix: Ref-Rueckruf `measureRef` statt Einmal-Effekt — folgt dem Knoten ueber den Wechsel Leerzustand ↔ gefuellt, misst synchron in der Commit-Phase, haengt den ResizeObserver dort an; Fenster-Horcher als zusaetzliches Netz; `applyWidth` verwirft 0 und nicht endliche Werte. **Verhalten sonst unveraendert** — belegte Plaetze bleiben gesperrt, nichts weicht aus (Ansage des Nutzers). **Geprueft im echten Client**, nicht im Browser: `Tessera-1.3.0.AppImage` auf `DISPLAY=:10` ueber den WebKit-Remote-Inspektor gesteuert. Gleicher Fehlerfall vorher/nachher: Kachel 469 px → **389 px** bei 1000 px Bereich, Ziehen endet jetzt bei 603 px = `1000 − 8 − 389`, exakt der rechte Rand. **Messfalle notiert:** im Client gegen `style.width`/`style.transform` messen, nie gegen `getBoundingClientRect()` — bei Fenster im Hintergrund friert WebKitGTK die Animationsuhr ein und der `width`-Uebergang bleibt auf dem alten Wert stehen. **Zahlen:** web 659 → 661 Tests, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-22 | d9f2af3,cf67c8a | [260922-vdk-dashboard-raster-misst-seine-breite-nich](./quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/) |
| 260923-ad9 | **Dashboard-Reiter: mehrere Dashboards je Benutzer.** Wunsch des Nutzers (23.09.): mehrere Dashboards als Reiter, per Ziehen sortierbar, der erste ist der Standard und wird beim Oeffnen geladen; „als Favorit festlegen“ = nach vorn ziehen, kein zusaetzliches Kennzeichen. Umsetzung in 5 Schritten: neues Modell `Dashboard` (userId, tenantId, name, position) mit RLS wie die Nachbartabellen; `WidgetInstance.dashboardId` und `DashboardLayout.dashboardId @unique` — Kacheln und Anordnung haengen jetzt am Reiter statt am Benutzer. Handgeschriebene Migration `20260923120000_dashboard_tabs` haengt den Bestand um: Bestandsuebernahme VOR `NOT NULL`/Fremdschluessel, danach 0 verwaiste Kacheln, 0 verwaiste Anordnungen, je Benutzer genau ein Reiter auf Position 0. Fuenf Endpunkte unter `/dashboard/tabs`; `assertOwnedDashboard` laeuft als erstes in JEDEM Lese- und Schreibweg und antwortet fuer „gibt es nicht“, „Kollege“ und „fremder Mandant“ identisch (kein Orakel) — acht eigene Tests dafuer. Umsortieren und Loeschen je EINE Transaktion nach dem Muster `FavoritesService.reorder`. Riegel: 20 Reiter, 40 Zeichen, 20 Kennungen je Anfrage. Ziehen per Pointer-Ereignissen ohne neue Abhaengigkeit (Muster xframe-Ausschnitt), ausserhalb des Bearbeitungsmodus moeglich, weil „nach vorn ziehen“ das Festlegen des Standards IST; Umbenennen und Loeschen bleiben im Bearbeitungsmodus, Loeschen mit `alertdialog`-Rueckfrage. **Raster unangetastet** (`FREE_PLACEMENT_COMPACTOR`/`preventCollision` und die Breitenmessung aus 260922-vdk) — vom Verifizierer per `git diff` nachgewiesen. **Rundgang mit zwoelf Punkten bestanden** (Bestand 5 Kacheln erhalten, Reiter leer angelegt, Kacheln je Reiter getrennt, Ziehen ordnet um, nach Neuladen kommt der erste Reiter, Umbenennen, Loeschen mit Rueckfrage, letzter Reiter ohne Loeschknopf, Kachelbreite 531 px bei 1625 px Bereich). **Kleiner Befund, offen:** die Knopf-Beschriftungen nennen den betroffenen Reiter nicht (nur das Bestaetigungsfenster tut es). **Zahlen:** api 1202 → 1240 Tests, web 661 → 693, type-check 4/4, lint 5/5 mit 53 Warnungen unveraendert, `migrate diff` ohne Unterschied. | 2026-09-23 | 9c51823,df7a5e7,d34f682,05feaa3,58ce88e | [260923-ad9-dashboard-reiter-mehrere-dashboards-je-b](./quick/260923-ad9-dashboard-reiter-mehrere-dashboards-je-b/) |
## Deferred Items ## Deferred Items
@@ -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
<objective>
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.
</objective>
## 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
<tasks>
<task type="tracer">
<name>Task 1: Datenmodell, Migration und Reiter-Grundlage — das heutige Dashboard wird zu Reiter 1</name>
<files>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</files>
<read_first>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</read_first>
<action>
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.
</action>
<verify>
<automated>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</automated>
<automated>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;'</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -20</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check</automated>
</verify>
<done>
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.
</done>
<reversibility rating="costly">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.</reversibility>
</task>
<task type="auto" tdd="true">
<name>Task 2: Reiter anlegen, umbenennen, löschen und umsortieren — Dienst, DTOs, Endpunkte</name>
<files>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</files>
<read_first>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</read_first>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api test 2>&1 | tail -20</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint</automated>
</verify>
<done>
`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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Reiterleiste in der Oberfläche — wechseln, anlegen, umbenennen, löschen</name>
<files>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</files>
<read_first>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`</read_first>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -12</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint</automated>
</verify>
<done>
`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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Reiter per Ziehen umsortieren — der erste ist der Standard</name>
<files>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</files>
<read_first>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)</read_first>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web test 2>&1 | tail -12</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check && pnpm lint</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "react-grid-layout\|dnd\|sortable" apps/web/package.json</automated>
</verify>
<done>
`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.
</done>
</task>
<task type="auto">
<name>Task 5: Anwenderhandbuch, Changelog und Nachmessung aller Tore</name>
<files>docs/anleitung-anwender.md, CHANGELOG.md, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>docs/anleitung-anwender.md Zeile 59-70 (Abschnitt Dashboard), CHANGELOG.md Kopf (Abschnitt „Unveröffentlicht“)</read_first>
<action>
`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.
</action>
<verify>
<automated>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</automated>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "Reiter" docs/anleitung-anwender.md CHANGELOG.md</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
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).
</output>
@@ -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)_
@@ -0,0 +1,51 @@
---
created: 2026-09-23
title: Flackernder Test "TenantContextSelector" — Zeitüberschreitung bei 5 s, hat die Freigabe 1.3.1 blockiert
area: apps/web
severity: flake
trigger: sobald wieder an apps/web gearbeitet wird — spätestens vor der nächsten Freigabe, weil der Fall dort teuer ist.
relates_to: Freigabe v1.3.1 (CI-Lauf 418, Job 1262)
---
## Was passiert ist
Beim Freigeben von 1.3.1 lösen drei Pipelines gleichzeitig aus (`main`, Zweig
`live`, Tag `v1.3.1`). Auf dem Tag-Lauf fiel der Test
```
src/app/(portal)/marketplace/tenant-selector.test.tsx
> TenantContextSelector > renders tenant options for SUPER_ADMIN; renders nothing for ADMIN
```
mit `Error: Test timed out in 5000ms` aus. **Derselbe Commit** (`ad004b28`) war
im selben Zeitraum auf `main` (Lauf 1025) und auf `live` (Lauf 1026) grün — es
ist also kein Fehler im Code, sondern der Läufer war mit drei parallelen
Pipelines ausgelastet und der Test lief in sein 5-Sekunden-Limit.
## Warum das teuer war
Der Abbild-Bau hängt am Test-Job. Rot heißt: **„Build & Publish Images" und
„Desktop-Pakete bauen" wurden übersprungen** — die Freigabe war damit getaggt,
aber nicht gebaut. Kein `live`-Abbild, kein Gitea-Release, keine
Desktop-Pakete. Erst ein Neustart des Laufs (Gitea-API,
`POST /actions/runs/418/rerun`) hat alles nachgeholt.
Das trifft **jede** Freigabe, weil jede Freigabe drei gleichzeitige Läufe
auslöst. Der Fall wiederholt sich also, nicht zufällig.
## Was zu tun ist
1. Den Test ansehen: warum braucht er überhaupt nahe 5 s? Verdacht ist ein
`waitFor` auf etwas, das erst nach einem Datenabruf erscheint, oder zwei
Fälle (SUPER_ADMIN und ADMIN) in EINEM `it`, das dadurch doppelt so lange
läuft — der Testname nennt beide Fälle in einem Satz.
2. Entweder auftrennen (zwei `it`-Blöcke) oder die Wartezeit gezielt erhöhen.
Ein globales Hochsetzen von `testTimeout` versteckt nur, dass hier etwas
langsam ist.
3. Prüfen, ob weitere Tests nahe am Limit liegen — die Läuferlast bleibt ja.
## Nicht die Lösung
Die drei parallelen Pipelines abschalten: der Lauf auf `main` und der auf dem
Tag prüfen unterschiedliche Dinge, und der `live`-Lauf ist die Absicherung,
dass der Zweig für sich genommen grün ist.
+4
View File
@@ -4,6 +4,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
### Neu
- Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen.
## 1.3.1 – 2026-09-23 ## 1.3.1 – 2026-09-23
### Geändert ### Geändert
@@ -0,0 +1,113 @@
-- 260923-ad9 — Dashboard-Reiter: mehrere Dashboards je Benutzer.
--
-- Zweck: das Dashboard traegt heute genau eine Kachelflaeche je Benutzer.
-- Diese Migration gibt jedem Benutzer mehrere Dashboards ("Reiter"), die
-- oben nebeneinander stehen: jeder Reiter mit eigenen Kacheln und eigener
-- Anordnung, per Ziehen umsortierbar.
--
-- D-01: `position` (Integer) traegt die Reihenfolge, aufsteigend sortiert.
-- KEIN Unique auf (userId, position) — beim Umsortieren werden alle
-- Positionen eines Benutzers in EINER Transaktion neu geschrieben
-- (dashboard.service.ts, reorderDashboards, Muster FavoritesService.reorder);
-- ein Unique waere dabei nur im Weg.
--
-- D-03: niemand verliert etwas. Fuer jeden Benutzer, der heute Kacheln ODER
-- eine gespeicherte Anordnung hat, entsteht genau EIN Dashboard mit
-- position = 0 und dem Namen "Dashboard"; vorhandene Kacheln und die
-- vorhandene Anordnung werden darauf umgehaengt. Diese Bestandsuebernahme
-- MUSS vor den Fremdschluesseln laufen, sonst scheitert sie an genau diesen
-- — deshalb steht sie unten vor den ALTER-TABLE-Schritten fuer
-- WidgetInstance/DashboardLayout.
--
-- D-04: Zeilenschutz ist Pflicht. Die neue Tabelle traegt `tenantId` und
-- dieselbe Regel wie ihre Nachbarn — Mandant UND Benutzerdimension von
-- Anfang an (Form aus 20260911120000_rls_user_dimension_personal_tables,
-- uebernommen aus 20260921120000_dashboard_image).
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen ueber ALTER DEFAULT
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirkt die Regel erst, wenn
-- die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS,
-- siehe docs/mandantentrennung-datenbankrolle.md).
-- 1) Tabelle Dashboard anlegen, Indizes auf userId und tenantId.
CREATE TABLE "Dashboard" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Dashboard_userId_idx" ON "Dashboard"("userId");
CREATE INDEX "Dashboard_tenantId_idx" ON "Dashboard"("tenantId");
-- 2) Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000/20260921120000).
ALTER TABLE "Dashboard" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Dashboard" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "Dashboard"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
-- 3) Bestandsuebernahme (D-03): je Benutzer aus der Vereinigung der
-- Benutzer mit Kacheln und der Benutzer mit gespeicherter Anordnung genau
-- EINE Zeile einfuegen. DISTINCT ON sichert "je Benutzer genau eine Zeile"
-- auch fuer den theoretischen Fall "derselbe Benutzer mit zwei
-- Mandantenkennungen" ab (deterministische Wahl ueber die Sortierung nach
-- tenantId als zweitem Kriterium).
INSERT INTO "Dashboard" ("id", "userId", "tenantId", "name", "position", "createdAt", "updatedAt")
SELECT gen_random_uuid(), bestand."userId", bestand."tenantId", 'Dashboard', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (
SELECT DISTINCT ON ("userId") "userId", "tenantId"
FROM (
SELECT "userId", "tenantId" FROM "WidgetInstance"
UNION ALL
SELECT "userId", "tenantId" FROM "DashboardLayout"
) AS vereinigung
ORDER BY "userId", "tenantId"
) AS bestand;
-- 4) WidgetInstance.dashboardId: zunaechst NULLbar ergaenzen, aus der neuen
-- Tabelle ueber die Benutzerkennung befuellen (fuer jeden Benutzer mit
-- Kacheln existiert nach Schritt 3 GENAU ein Dashboard), dann NOT NULL,
-- Index, Fremdschluessel mit Loeschweitergabe.
ALTER TABLE "WidgetInstance" ADD COLUMN "dashboardId" TEXT;
UPDATE "WidgetInstance" wi
SET "dashboardId" = d."id"
FROM "Dashboard" d
WHERE d."userId" = wi."userId";
ALTER TABLE "WidgetInstance" ALTER COLUMN "dashboardId" SET NOT NULL;
CREATE INDEX "WidgetInstance_dashboardId_idx" ON "WidgetInstance"("dashboardId");
ALTER TABLE "WidgetInstance" ADD CONSTRAINT "WidgetInstance_dashboardId_fkey"
FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 5) DashboardLayout.dashboardId: dieselbe Uebernahme; zusaetzlich die
-- Eindeutigkeit auf userId entfernen (mehrere Reiter je Benutzer sind jetzt
-- erlaubt), dort einen gewoehnlichen Index anlegen, und die Eindeutigkeit
-- auf dashboardId anlegen (ein Reiter hat hoechstens eine gespeicherte
-- Anordnung).
ALTER TABLE "DashboardLayout" ADD COLUMN "dashboardId" TEXT;
UPDATE "DashboardLayout" dl
SET "dashboardId" = d."id"
FROM "Dashboard" d
WHERE d."userId" = dl."userId";
ALTER TABLE "DashboardLayout" ALTER COLUMN "dashboardId" SET NOT NULL;
DROP INDEX "DashboardLayout_userId_key";
CREATE INDEX "DashboardLayout_userId_idx" ON "DashboardLayout"("userId");
CREATE UNIQUE INDEX "DashboardLayout_dashboardId_key" ON "DashboardLayout"("dashboardId");
ALTER TABLE "DashboardLayout" ADD CONSTRAINT "DashboardLayout_dashboardId_fkey"
FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+42 -6
View File
@@ -187,14 +187,45 @@ model ModuleGrant {
@@index([moduleId]) @@index([moduleId])
} }
model DashboardLayout { // Dashboard-Reiter (quick-260923-ad9, D-01/D-02/D-09): mehrere Dashboards je
id String @id @default(uuid()) // Benutzer, ueber `position` (Integer) aufsteigend sortiert. KEIN Unique auf
userId String @unique // (userId, position) — `reorderDashboards` (Muster FavoritesService.reorder)
// schreibt beim Umsortieren ALLE Positionen eines Benutzers in EINER
// Transaktion neu; ein Unique waere dabei nur im Weg (kollidiert waehrend
// des Umschreibens mit sich selbst). KEIN eigenes Standard-Feld: "als
// Favorit festlegen" IST das Nach-vorn-Ziehen (D-09) — Position 0 ist der
// Standard, es gibt keine zweite Wahrheit daneben. Keine Relation zu
// User/Tenant — Form der Nachbarmodelle WidgetInstance/DashboardImage (eine
// Relation zu User wuerde an Bestandszeilen verwaister Benutzer scheitern).
model Dashboard {
id String @id @default(uuid())
userId String
tenantId String tenantId String
layouts Json @default("{}") name String
createdAt DateTime @default(now()) position Int
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
widgets WidgetInstance[]
layout DashboardLayout?
@@index([userId])
@@index([tenantId])
}
model DashboardLayout {
id String @id @default(uuid())
userId String
tenantId String
// quick-260923-ad9 (D-02): haengt jetzt am Dashboard statt am Benutzer —
// die Eindeutigkeit wandert von userId auf dashboardId, userId/tenantId
// bleiben fuer Besitz- und Mandantenpruefung erhalten.
dashboardId String @unique
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
layouts Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([tenantId]) @@index([tenantId])
} }
@@ -202,6 +233,10 @@ model WidgetInstance {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
tenantId String tenantId String
// quick-260923-ad9 (D-02): Kacheln haengen ab jetzt am Reiter, nicht mehr
// nur am Benutzer.
dashboardId String
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
widgetType String widgetType String
config Json @default("{}") config Json @default("{}")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -210,6 +245,7 @@ model WidgetInstance {
@@index([userId]) @@index([userId])
@@index([tenantId]) @@index([tenantId])
@@index([dashboardId])
} }
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers. // Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
@@ -0,0 +1,143 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { DashboardController } from './dashboard.controller';
/**
* dashboard.controller.spec — NEU (quick-260923-ad9, Task 2). Form aus
* `dashboard-images.controller.spec.ts`: die Reiter-Kennung wird aus
* Abfrageparameter bzw. Rumpf an den Dienst durchgereicht, fehlender
* Benutzer-/Mandantenkontext führt zur vorhandenen Abweisung, und ein
* quelltextlesender Wächter prüft, dass die feste Route `tabs/order` VOR
* jeder Route mit Platzhalter unter demselben Präfix im Dateitext steht
* (NestJS-Routenreihenfolge — in dieser Anwendung hat eine Route mit
* Platzhalter schon einmal eine dahinter stehende feste Route verdeckt).
*/
function makeService() {
return {
listDashboards: vi.fn(async () => [{ id: 'dash-1' }]),
createDashboard: vi.fn(async () => ({ id: 'dash-2' })),
reorderDashboards: vi.fn(async () => [{ id: 'dash-1' }, { id: 'dash-2' }]),
renameDashboard: vi.fn(async (id: string) => ({ id })),
deleteDashboard: vi.fn(async (id: string) => ({ id })),
getLayout: vi.fn(async () => ({ lg: [] })),
saveLayout: vi.fn(async () => ({ id: 'layout-1' })),
getWidgets: vi.fn(async () => []),
addWidget: vi.fn(async () => ({ id: 'w1' })),
updateWidgetConfig: vi.fn(async () => ({ id: 'w1' })),
removeWidget: vi.fn(async () => ({ id: 'w1' })),
getSearchProviders: vi.fn(async () => []),
addSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
removeSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
};
}
function makeRequest(overrides: Partial<AuthenticatedRequest> = {}): AuthenticatedRequest {
return {
user: { id: 'user-1', tenantId: 'tenant-1', role: 'USER', username: 'anna', mustChangePassword: false },
tenantId: 'tenant-1',
...overrides,
} as AuthenticatedRequest;
}
describe('DashboardController — Reiter (quick-260923-ad9, Task 2)', () => {
it('listDashboards: reicht userId/tenantId aus dem Sitzungsnachweis durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.listDashboards(makeRequest());
expect(service.listDashboards).toHaveBeenCalledWith('user-1', 'tenant-1');
});
it('createDashboard: reicht userId/tenantId durch, kein Rumpf nötig', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.createDashboard(makeRequest());
expect(service.createDashboard).toHaveBeenCalledWith('user-1', 'tenant-1');
});
it('reorderDashboards: reicht die Kennungsliste aus dem Rumpf durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
const dto = { ids: ['dash-2', 'dash-1'] } as never;
await controller.reorderDashboards(makeRequest(), dto);
expect(service.reorderDashboards).toHaveBeenCalledWith('user-1', 'tenant-1', dto);
});
it('renameDashboard: reicht Pfad-Kennung und Rumpf durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
const dto = { name: 'Neuer Name' } as never;
await controller.renameDashboard('dash-1', makeRequest(), dto);
expect(service.renameDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1', dto);
});
it('deleteDashboard: reicht die Pfad-Kennung durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.deleteDashboard('dash-1', makeRequest());
expect(service.deleteDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1');
});
it('getLayout/getWidgets: reichen die Reiter-Kennung aus dem Abfrageparameter durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.getLayout(makeRequest(), 'dash-1');
await controller.getWidgets(makeRequest(), 'dash-1');
expect(service.getLayout).toHaveBeenCalledWith('user-1', 'tenant-1', 'dash-1');
expect(service.getWidgets).toHaveBeenCalledWith('user-1', 'tenant-1', 'USER', 'dash-1');
});
it('fehlender Mandantenkontext führt bei den neuen Reiter-Wegen zur vorhandenen Abweisung', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
const req = makeRequest({ user: undefined, tenantId: undefined } as never);
await expect(controller.listDashboards(req)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.createDashboard(req)).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.reorderDashboards(req, { ids: [] } as never),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.renameDashboard('dash-1', req, { name: 'x' } as never),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.deleteDashboard('dash-1', req)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('Wächter: die feste Route "tabs/order" steht im Dateitext VOR jeder Route mit Platzhalter unter demselben Präfix (NestJS-Routenreihenfolge)', () => {
const source = readFileSync(join(__dirname, 'dashboard.controller.ts'), 'utf-8');
const orderIndex = source.indexOf("@Put('tabs/order')");
const patchIdIndex = source.indexOf("@Patch('tabs/:id')");
const deleteIdIndex = source.indexOf("@Delete('tabs/:id')");
expect(orderIndex, '@Put(\'tabs/order\') fehlt im Quelltext').toBeGreaterThan(-1);
expect(patchIdIndex, '@Patch(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
expect(deleteIdIndex, '@Delete(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
expect(
orderIndex,
'tabs/order muss VOR PATCH tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
).toBeLessThan(patchIdIndex);
expect(
orderIndex,
'tabs/order muss VOR DELETE tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
).toBeLessThan(deleteIdIndex);
});
});
+73 -9
View File
@@ -8,12 +8,15 @@ import {
Patch, Patch,
Post, Post,
Put, Put,
Query,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto'; import { CreateWidgetDto } from './dto/create-widget.dto';
import { RenameDashboardDto } from './dto/rename-dashboard.dto';
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
import { SaveLayoutDto } from './dto/save-layout.dto'; import { SaveLayoutDto } from './dto/save-layout.dto';
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
@@ -25,10 +28,17 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
* and scopes all operations to the calling user (T-05-01, T-05-02). * and scopes all operations to the calling user (T-05-01, T-05-02).
* *
* Routes: * Routes:
* - GET /dashboard/layout — get user's saved layout * - GET /dashboard/tabs — list the user's dashboard tabs (quick-260923-ad9)
* - PUT /dashboard/layout — upsert user's layout * - POST /dashboard/tabs — create a new, empty tab
* - GET /dashboard/widgets — list user's widget instances * - PUT /dashboard/tabs/order — persist the tab order (MUST be declared
* - POST /dashboard/widgets — create a new widget instance * before the `:id` routes below, see the
* source-order guard in dashboard.controller.spec.ts)
* - PATCH /dashboard/tabs/:id — rename a tab
* - DELETE /dashboard/tabs/:id — delete a tab, its widgets and its layout
* - GET /dashboard/layout — get the saved layout of one tab
* - PUT /dashboard/layout — upsert the layout of one tab
* - GET /dashboard/widgets — list the widget instances of one tab
* - POST /dashboard/widgets — create a new widget instance on one tab
* - PATCH /dashboard/widgets/:id/config — update widget config * - PATCH /dashboard/widgets/:id/config — update widget config
* - DELETE /dashboard/widgets/:id — remove a widget instance * - DELETE /dashboard/widgets/:id — remove a widget instance
* - GET /dashboard/search-providers — list default + user's custom providers * - GET /dashboard/search-providers — list default + user's custom providers
@@ -66,10 +76,61 @@ export class DashboardController {
return { userId: user.id, tenantId, role: user.role }; return { userId: user.id, tenantId, role: user.role };
} }
@Get('layout') /**
async getLayout(@Req() req: AuthenticatedRequest) { * Reiter des Benutzers (quick-260923-ad9), nach Position aufsteigend;
* legt beim ersten Aufruf genau einen an.
*/
@Get('tabs')
async listDashboards(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req); const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getLayout(userId, tenantId); return this.dashboardService.listDashboards(userId, tenantId);
}
@Post('tabs')
async createDashboard(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.createDashboard(userId, tenantId);
}
/**
* MUSS vor `PATCH tabs/:id` / `DELETE tabs/:id` stehen — in dieser
* Anwendung hat eine Route mit Platzhalter schon einmal eine dahinter
* stehende feste Route verdeckt (siehe Projektnotiz „NestJS Route-
* Order“); ein quelltextlesender Wächter in
* `dashboard.controller.spec.ts` prüft die Reihenfolge im Dateitext.
*/
@Put('tabs/order')
async reorderDashboards(
@Req() req: AuthenticatedRequest,
@Body() dto: ReorderDashboardsDto,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.reorderDashboards(userId, tenantId, dto);
}
@Patch('tabs/:id')
async renameDashboard(
@Param('id') id: string,
@Req() req: AuthenticatedRequest,
@Body() dto: RenameDashboardDto,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.renameDashboard(id, userId, tenantId, dto);
}
@Delete('tabs/:id')
async deleteDashboard(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.deleteDashboard(id, userId, tenantId);
}
@Get('layout')
async getLayout(
@Req() req: AuthenticatedRequest,
@Query('dashboardId') dashboardId: string,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getLayout(userId, tenantId, dashboardId);
} }
@Put('layout') @Put('layout')
@@ -79,9 +140,12 @@ export class DashboardController {
} }
@Get('widgets') @Get('widgets')
async getWidgets(@Req() req: AuthenticatedRequest) { async getWidgets(
@Req() req: AuthenticatedRequest,
@Query('dashboardId') dashboardId: string,
) {
const { userId, tenantId, role } = this.extractContext(req); const { userId, tenantId, role } = this.extractContext(req);
return this.dashboardService.getWidgets(userId, tenantId, role); return this.dashboardService.getWidgets(userId, tenantId, role, dashboardId);
} }
@Post('widgets') @Post('widgets')
+606 -42
View File
@@ -30,12 +30,20 @@ vi.mock('./widget-module-map', () => ({
* ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus * ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus
* `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine * `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine
* Bindung wäre heute wirkungslos. * Bindung wäre heute wirkungslos.
*
* quick-260923-ad9 (Task 1): `withTenantTransaction` kommt zum Mock hinzu
* (Muster favorites.service.spec.ts) — sie reicht den gebundenen Klienten
* als `tx` durch und protokolliert den Aufruf. `listDashboards` nutzt sie
* fürs Anlegen des ersten Reiters unter einer Transaktionssperre.
*/ */
vi.mock('../prisma/prisma-tenant.extension', () => ({ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
withTenantTransaction: vi.fn((prisma: any, tenantId: string, fn: any) =>
prisma.__withTenantTransaction(tenantId, fn),
),
})); }));
import { ConflictException } from '@nestjs/common'; import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
@@ -43,14 +51,42 @@ import { DashboardService } from './dashboard.service';
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das /** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst * Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider` * NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
* ergaenzt seit Aufgabe 3 (260910-krx). */ * ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance', 'searchProvider']; * quick-260923-ad9 (Task 1). */
const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider'];
/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter
* `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */
const DASH_1 = 'dash-1';
function makeDashboard(
overrides: Partial<{
id: string;
userId: string;
tenantId: string;
name: string;
position: number;
createdAt: Date;
updatedAt: Date;
}> = {},
) {
return {
id: overrides.id ?? DASH_1,
userId: overrides.userId ?? 'user-1',
tenantId: overrides.tenantId ?? 'tenant-1',
name: overrides.name ?? 'Dashboard',
position: overrides.position ?? 0,
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
updatedAt: overrides.updatedAt ?? new Date('2026-01-01'),
};
}
function makeWidget( function makeWidget(
overrides: Partial<{ overrides: Partial<{
id: string; id: string;
userId: string; userId: string;
tenantId: string; tenantId: string;
dashboardId: string;
widgetType: string; widgetType: string;
config: Record<string, unknown>; config: Record<string, unknown>;
createdAt: Date; createdAt: Date;
@@ -60,6 +96,7 @@ function makeWidget(
id: overrides.id ?? 'w1', id: overrides.id ?? 'w1',
userId: overrides.userId ?? 'user-1', userId: overrides.userId ?? 'user-1',
tenantId: overrides.tenantId ?? 'tenant-1', tenantId: overrides.tenantId ?? 'tenant-1',
dashboardId: overrides.dashboardId ?? DASH_1,
widgetType: overrides.widgetType ?? 'clock', widgetType: overrides.widgetType ?? 'clock',
config: overrides.config ?? {}, config: overrides.config ?? {},
createdAt: overrides.createdAt ?? new Date('2026-01-01'), createdAt: overrides.createdAt ?? new Date('2026-01-01'),
@@ -92,35 +129,100 @@ function makeFakePrisma(
opts: { opts: {
widgets?: ReturnType<typeof makeWidget>[]; widgets?: ReturnType<typeof makeWidget>[];
modules?: { id: string; slug: string }[]; modules?: { id: string; slug: string }[];
layout?: { userId: string; tenantId: string; layouts: unknown } | null; layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
searchProviders?: ReturnType<typeof makeSearchProvider>[]; searchProviders?: ReturnType<typeof makeSearchProvider>[];
dashboards?: ReturnType<typeof makeDashboard>[];
} = {}, } = {},
) { ) {
const widgets = opts.widgets ?? []; const widgets = opts.widgets ?? [];
const modules = opts.modules ?? []; const modules = opts.modules ?? [];
let layoutRow = opts.layout ?? null; let layoutRow = opts.layout ?? null;
const searchProviders = opts.searchProviders ?? []; const searchProviders = opts.searchProviders ?? [];
// Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört
// — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern.
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
// eigenem Reiter, leere Reiterliste), übergeben `dashboards` explizit.
const dashboards = opts.dashboards ?? [makeDashboard()];
const boundCallLog: { tenantId: string; model: string; method: string }[] = []; const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
const fake: any = { const fake: any = {
dashboard: {
findMany: vi.fn(async ({ where }: any) => {
return dashboards
.filter((d) => d.userId === where.userId)
.slice()
.sort((a, b) => a.position - b.position);
}),
findUnique: vi.fn(async ({ where }: any) => {
return dashboards.find((d) => d.id === where.id) ?? null;
}),
count: vi.fn(async ({ where }: any) => {
return dashboards.filter(
(d) => d.userId === where.userId && d.tenantId === where.tenantId,
).length;
}),
create: vi.fn(async ({ data }: any) => {
const created = makeDashboard({ id: `new-dash-${dashboards.length + 1}`, ...data });
dashboards.push(created);
return created;
}),
update: vi.fn(async ({ where, data }: any) => {
const dashboard = dashboards.find((d) => d.id === where.id);
if (!dashboard) return null;
Object.assign(dashboard, data);
return dashboard;
}),
updateMany: vi.fn(async ({ where, data }: any) => {
const matches = dashboards.filter(
(d) => d.id === where.id && d.userId === where.userId,
);
for (const d of matches) Object.assign(d, data);
return { count: matches.length };
}),
deleteMany: vi.fn(async ({ where }: any) => {
const before = dashboards.length;
for (let i = dashboards.length - 1; i >= 0; i--) {
if (dashboards[i].id === where.id && dashboards[i].userId === where.userId) {
dashboards.splice(i, 1);
}
}
return { count: before - dashboards.length };
}),
},
dashboardLayout: { dashboardLayout: {
findUnique: vi.fn(async ({ where }: any) => { findUnique: vi.fn(async ({ where }: any) => {
if (layoutRow && layoutRow.userId === where.userId) return layoutRow; if (layoutRow && layoutRow.dashboardId === where.dashboardId) return layoutRow;
return null; return null;
}), }),
upsert: vi.fn(async ({ where, update, create }: any) => { upsert: vi.fn(async ({ where, update, create }: any) => {
if (layoutRow && layoutRow.userId === where.userId) { if (layoutRow && layoutRow.dashboardId === where.dashboardId) {
layoutRow = { ...layoutRow, layouts: update.layouts }; layoutRow = { ...layoutRow, layouts: update.layouts };
return layoutRow; return layoutRow;
} }
layoutRow = { userId: create.userId, tenantId: create.tenantId, layouts: create.layouts }; layoutRow = {
dashboardId: create.dashboardId,
userId: create.userId,
tenantId: create.tenantId,
layouts: create.layouts,
};
return layoutRow; return layoutRow;
}), }),
deleteMany: vi.fn(async ({ where }: any) => {
if (
layoutRow &&
layoutRow.dashboardId === where.dashboardId &&
layoutRow.userId === where.userId
) {
layoutRow = null;
return { count: 1 };
}
return { count: 0 };
}),
}, },
widgetInstance: { widgetInstance: {
findMany: vi.fn(async ({ where }: any) => { findMany: vi.fn(async ({ where }: any) => {
return widgets return widgets
.filter((w) => w.userId === where.userId) .filter((w) => w.dashboardId === where.dashboardId)
.slice() .slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}), }),
@@ -138,6 +240,15 @@ function makeFakePrisma(
Object.assign(widget, data); Object.assign(widget, data);
return widget; return widget;
}), }),
deleteMany: vi.fn(async ({ where }: any) => {
const before = widgets.length;
for (let i = widgets.length - 1; i >= 0; i--) {
if (widgets[i].dashboardId === where.dashboardId && widgets[i].userId === where.userId) {
widgets.splice(i, 1);
}
}
return { count: before - widgets.length };
}),
delete: vi.fn(async ({ where }: any) => { delete: vi.fn(async ({ where }: any) => {
const idx = widgets.findIndex((w) => w.id === where.id); const idx = widgets.findIndex((w) => w.id === where.id);
if (idx === -1) return null; if (idx === -1) return null;
@@ -188,8 +299,16 @@ function makeFakePrisma(
} }
bound[modelName] = wrapped; bound[modelName] = wrapped;
} }
// quick-260923-ad9: `listDashboards` setzt die Transaktionssperre über
// ein rohes `$executeRaw` auf `tx` — Attrappe genügt, das Ergebnis
// wird nicht ausgewertet.
bound.$executeRaw = vi.fn(async () => undefined);
return bound; return bound;
}, },
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
return fn(fake.__makeBoundClient(tenantId));
},
}; };
return fake; return fake;
@@ -245,7 +364,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual(widgets); expect(result).toEqual(widgets);
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
@@ -261,7 +380,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual([widget]); expect(result).toEqual([widget]);
}); });
@@ -276,7 +395,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -287,12 +406,13 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const prisma = makeFakePrisma({ const prisma = makeFakePrisma({
widgets: [widget], widgets: [widget],
modules: [{ id: 'mod-1', slug: 'tender-radar' }], modules: [{ id: 'mod-1', slug: 'tender-radar' }],
dashboards: [makeDashboard({ userId: 'admin-1' })],
}); });
// Simuliert den D-03-Kurzschluss der Zugriffsauflösung aus 15-01: ADMIN erhält alle aktiven Module. // Simuliert den D-03-Kurzschluss der Zugriffsauflösung aus 15-01: ADMIN erhält alle aktiven Module.
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('admin-1', 'tenant-1', 'ADMIN' as any); const result = await service.getWidgets('admin-1', 'tenant-1', 'ADMIN' as any, DASH_1);
expect(result).toEqual([widget]); expect(result).toEqual([widget]);
expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith( expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith(
@@ -313,7 +433,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set()); // kein Zugriff auf tender-radar const moduleAccessService = makeFakeModuleAccessService(new Set()); // kein Zugriff auf tender-radar
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual([platformWidget]); expect(result).toEqual([platformWidget]);
}); });
@@ -334,7 +454,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result.map((w: any) => w.id)).toEqual(['w1', 'w2', 'w3']); expect(result.map((w: any) => w.id)).toEqual(['w1', 'w2', 'w3']);
}); });
@@ -349,8 +469,8 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.getWidgets('user-1', 'tenant-1', 'USER' as any); await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
await service.getWidgets('user-1', 'tenant-1', 'USER' as any); await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(prisma.widgetInstance.delete).not.toHaveBeenCalled(); expect(prisma.widgetInstance.delete).not.toHaveBeenCalled();
expect(prisma.widgetInstance.findMany).toHaveBeenCalledTimes(2); expect(prisma.widgetInstance.findMany).toHaveBeenCalledTimes(2);
@@ -363,7 +483,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set(['irgendeine-id'])); const moduleAccessService = makeFakeModuleAccessService(new Set(['irgendeine-id']));
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -379,15 +499,16 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
it('getLayout: der Lesezugriff läuft über den gebundenen Klienten, mit der übergebenen Mandantenkennung im Protokoll', async () => { it('getLayout: der Lesezugriff läuft über den gebundenen Klienten, mit der übergebenen Mandantenkennung im Protokoll', async () => {
const prisma = makeFakePrisma({ const prisma = makeFakePrisma({
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: { lg: [{ i: 'w1' }] } }, layout: { dashboardId: DASH_1, userId: 'user-1', tenantId: 'tenant-1', layouts: { lg: [{ i: 'w1' }] } },
}); });
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getLayout('user-1', 'tenant-1'); const result = await service.getLayout('user-1', 'tenant-1', DASH_1);
expect(result).toEqual({ lg: [{ i: 'w1' }] }); expect(result).toEqual({ lg: [{ i: 'w1' }] });
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1'); expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1');
}); });
@@ -397,7 +518,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getLayout('user-1', 'tenant-1'); const result = await service.getLayout('user-1', 'tenant-1', DASH_1);
expect(result).toEqual({ lg: [], md: [], sm: [], xs: [], xxs: [] }); expect(result).toEqual({ lg: [], md: [], sm: [], xs: [], xxs: [] });
}); });
@@ -407,17 +528,18 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any); await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any);
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
}); });
/** /**
* Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.userId` ist * Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.dashboardId`
* plattformweit eindeutig, ohne Mandantenanteil. Ist die vorhandene Zeile * (bis quick-260923-ad9: `userId`) ist die eindeutige Spalte. Ist die
* unter dem gebundenen Kontext unsichtbar, laeuft das `upsert` in einen * vorhandene Zeile unter dem gebundenen Kontext unsichtbar, laeuft das
* Konflikt — und der aeussert sich hier NICHT als der bekannte P2002-Fehler * `upsert` in einen Konflikt — und der aeussert sich hier NICHT als der
* (`PrismaClientKnownRequestError`), sondern als * bekannte P2002-Fehler (`PrismaClientKnownRequestError`), sondern als
* `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den * `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den
* Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit * Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit
* ueberhaupt geprueft wird. Gemessen in `rls-scratch-check.mjs` * ueberhaupt geprueft wird. Gemessen in `rls-scratch-check.mjs`
@@ -440,7 +562,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect( await expect(
service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any), service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
}); });
@@ -457,20 +579,20 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect( await expect(
service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any), service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any),
).rejects.toBe(known); ).rejects.toBe(known);
}); });
}); });
it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => { it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => {
const prisma = makeFakePrisma({ const prisma = makeFakePrisma({
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} }, layout: { dashboardId: DASH_1, userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
}); });
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.getLayout('user-1', 'tenant-1'); await service.getLayout('user-1', 'tenant-1', DASH_1);
await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any); await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any);
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
@@ -481,7 +603,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual([]); expect(result).toEqual([]);
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany'); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany');
@@ -493,9 +615,10 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any); await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any);
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'create'); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'create');
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
}); });
it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => { it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => {
@@ -548,16 +671,16 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const widget = makeWidget({ id: 'w1', userId: 'user-1' }); const widget = makeWidget({ id: 'w1', userId: 'user-1' });
const prisma = makeFakePrisma({ const prisma = makeFakePrisma({
widgets: [widget], widgets: [widget],
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} }, layout: { dashboardId: DASH_1, userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
}); });
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
for (const call of [ for (const call of [
() => service.getLayout('user-1', 'tenant-1'), () => service.getLayout('user-1', 'tenant-1', DASH_1),
() => service.saveLayout('user-1', 'tenant-1', { layouts: {} } as any), () => service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any),
() => service.getWidgets('user-1', 'tenant-1', 'USER' as any), () => service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1),
() => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any), () => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any),
() => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any), () => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any),
() => service.removeWidget('w1', 'user-1', 'tenant-1'), () => service.removeWidget('w1', 'user-1', 'tenant-1'),
]) { ]) {
@@ -575,7 +698,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -593,9 +716,9 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
const stored = { lg: [{ i: 'w1', x: 0, y: 0, w: 4, h: 4 }], __gridVersion: 2 }; const stored = { lg: [{ i: 'w1', x: 0, y: 0, w: 4, h: 4 }], __gridVersion: 2 };
await service.saveLayout('user-1', 'tenant-1', { layouts: stored } as any); await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: stored } as any);
const result = await service.getLayout('user-1', 'tenant-1'); const result = await service.getLayout('user-1', 'tenant-1', DASH_1);
expect(result).toEqual(stored); expect(result).toEqual(stored);
expect((result as Record<string, unknown>).__gridVersion).toBe(2); expect((result as Record<string, unknown>).__gridVersion).toBe(2);
@@ -623,6 +746,178 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
}); });
}); });
// --- Reiter (quick-260923-ad9, Task 1) --------------------------------------
describe('DashboardService.listDashboards — Reiter anlegen/lesen (quick-260923-ad9, Task 1)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('erster Aufruf ohne vorhandenen Reiter legt genau einen mit Position 0 und Namen "Dashboard" an und liefert ihn', async () => {
const prisma = makeFakePrisma({ dashboards: [] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.listDashboards('user-1', 'tenant-1');
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ name: 'Dashboard', position: 0, userId: 'user-1' });
expect(prisma.dashboard.create).toHaveBeenCalledTimes(1);
});
it('zweiter Aufruf legt keinen weiteren Reiter an', async () => {
const prisma = makeFakePrisma({ dashboards: [] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.listDashboards('user-1', 'tenant-1');
const second = await service.listDashboards('user-1', 'tenant-1');
expect(second).toHaveLength(1);
expect(prisma.dashboard.create).toHaveBeenCalledTimes(1);
});
it('das Anlegen läuft als EINE withTenantTransaction (Sperre + erneute Zählung), nicht als Einzelbefehl (T-AD9-07)', async () => {
const prisma = makeFakePrisma({ dashboards: [] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.listDashboards('user-1', 'tenant-1');
const found = prisma.__boundCallLog.some(
(c: any) => c.tenantId === 'tenant-1' && c.model === '$transaction' && c.method === 'withTenantTransaction',
);
expect(found, 'erwartete withTenantTransaction fehlt im Protokoll').toBe(true);
});
it('die Liste kommt nach Position aufsteigend, unabhängig von der Einfügereihenfolge', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd2', position: 1 }),
makeDashboard({ id: 'd1', position: 0 }),
],
});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.listDashboards('user-1', 'tenant-1');
expect(result.map((d: any) => d.id)).toEqual(['d1', 'd2']);
});
it('der Lesezugriff läuft über den gebundenen Klienten mit der richtigen Mandantenkennung', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.listDashboards('user-1', 'tenant-1');
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findMany');
});
});
describe('DashboardService — Riegel gegen fremde Reiter (quick-260923-ad9, Task 1, T-AD9-01/02)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('getLayout: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort', async () => {
const prisma = makeFakePrisma({
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(service.getLayout('user-1', 'tenant-1', DASH_1)).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('saveLayout: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort, ohne zu schreiben', async () => {
const prisma = makeFakePrisma({
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(
service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any),
).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.dashboardLayout.upsert).not.toHaveBeenCalled();
});
it('getWidgets: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort', async () => {
const prisma = makeFakePrisma({
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(
service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1),
).rejects.toBeInstanceOf(NotFoundException);
});
it('addWidget: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort, ohne zu schreiben', async () => {
const prisma = makeFakePrisma({
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(
service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any),
).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.widgetInstance.create).not.toHaveBeenCalled();
});
it('unbekannte Reiter-Kennung (existiert nicht) führt ebenso zur Nicht-gefunden-Antwort — dieselbe Antwort wie "gehört einem Kollegen"', async () => {
const prisma = makeFakePrisma({ dashboards: [] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(service.getLayout('user-1', 'tenant-1', 'unknown-dash')).rejects.toThrow(
"Dashboard with id 'unknown-dash' not found",
);
});
});
describe('DashboardService — Kacheln und Anordnung sind je Reiter getrennt (quick-260923-ad9, Task 1)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('getWidgets liest über die Reiter-Kennung, nicht über die Benutzerkennung — zwei Reiter desselben Benutzers teilen keine Kacheln', async () => {
const widgetOnDash1 = makeWidget({ id: 'w1', dashboardId: 'dash-1' });
const widgetOnDash2 = makeWidget({ id: 'w2', dashboardId: 'dash-2' });
const prisma = makeFakePrisma({
widgets: [widgetOnDash1, widgetOnDash2],
dashboards: [makeDashboard({ id: 'dash-1' }), makeDashboard({ id: 'dash-2' })],
});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, 'dash-2');
expect(result.map((w: any) => w.id)).toEqual(['w2']);
});
it('saveLayout schreibt die Anordnung unter der Reiter-Kennung (create-Zweig)', async () => {
const prisma = makeFakePrisma({});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any);
expect(prisma.dashboardLayout.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { dashboardId: DASH_1 },
create: expect.objectContaining({ dashboardId: DASH_1 }),
}),
);
});
});
// --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) --------- // --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) ---------
describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => { describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => {
@@ -710,7 +1005,7 @@ describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog be
name: 'Intranet', name: 'Intranet',
urlTemplate: 'https://intranet.test/?q={query}', urlTemplate: 'https://intranet.test/?q={query}',
} as any); } as any);
await service.getWidgets('user-1', 'tenant-1', 'USER' as any); await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
// Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die // Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die
// Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren // Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren
@@ -743,3 +1038,272 @@ describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog be
} }
}); });
}); });
// --- Reiter anlegen/umbenennen/löschen/umsortieren (quick-260923-ad9, Task 2) ---
describe('DashboardService.createDashboard — Reiter anlegen (quick-260923-ad9, Task 2, D-08)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('erzeugt "Dashboard 2", wenn nur "Dashboard" existiert', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.name).toBe('Dashboard 2');
});
it('erzeugt "Dashboard 3", wenn "Dashboard" und "Dashboard 2" existieren', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.name).toBe('Dashboard 3');
});
it('füllt eine Lücke: "Dashboard" und "Dashboard 3" existieren -> "Dashboard 2"', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
makeDashboard({ id: 'd3', name: 'Dashboard 3', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.name).toBe('Dashboard 2');
});
it('hängt ans Ende — Position ist die höchste vorhandene plus eins', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 5 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.position).toBe(6);
});
it('liefert den neuen Reiter mit leerer Kachelliste', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const created = await service.createDashboard('user-1', 'tenant-1');
const widgets = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, created.id);
expect(widgets).toEqual([]);
});
it('wird ab 20 vorhandenen Reitern abgewiesen, ohne eine Zeile zu schreiben', async () => {
const dashboards = Array.from({ length: 20 }, (_, i) =>
makeDashboard({ id: `d${i}`, name: i === 0 ? 'Dashboard' : `Dashboard ${i + 1}`, position: i }),
);
const prisma = makeFakePrisma({ dashboards });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.createDashboard('user-1', 'tenant-1')).rejects.toThrow();
expect(prisma.dashboard.create).not.toHaveBeenCalled();
});
});
describe('DashboardService.renameDashboard — Reiter umbenennen (quick-260923-ad9, Task 2)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('benennt den eigenen Reiter um', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: DASH_1 })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'Finanzen' } as any);
expect(result.name).toBe('Finanzen');
});
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
const prisma = makeFakePrisma({
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'x' } as any),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('RenameDashboardDto — Beschneiden und Längenprüfung (quick-260923-ad9, Task 2)', () => {
it('beschneidet führende/nachgestellte Leerräume vor der Längenprüfung', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
const dto = plainToInstance(RenameDashboardDto, { name: ' Finanzen ' });
expect(dto.name).toBe('Finanzen');
expect(await validate(dto)).toEqual([]);
});
it('ein leerer Name (nach dem Beschneiden) wird abgewiesen', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
const dto = plainToInstance(RenameDashboardDto, { name: ' ' });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe('name');
});
it('ein Name über 40 Zeichen wird abgewiesen', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
const dto = plainToInstance(RenameDashboardDto, { name: 'x'.repeat(41) });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe('name');
});
});
describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9, Task 2, D-10)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('entfernt Reiter, seine Kacheln und seine Anordnung in EINER Transaktion und schreibt die verbleibenden Positionen lückenlos neu', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
makeDashboard({ id: 'd3', position: 2 }),
],
widgets: [makeWidget({ id: 'w1', dashboardId: 'd2' })],
layout: { dashboardId: 'd2', userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await service.deleteDashboard('d2', 'user-1', 'tenant-1');
const remaining = await service.listDashboards('user-1', 'tenant-1');
expect(remaining.map((d: any) => d.id)).toEqual(['d1', 'd3']);
expect(remaining.map((d: any) => d.position)).toEqual([0, 1]);
expect(prisma.widgetInstance.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
);
expect(prisma.dashboardLayout.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
);
});
it('der letzte verbleibende Reiter kann nicht gelöscht werden — nichts wird geschrieben', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.dashboard.deleteMany).not.toHaveBeenCalled();
});
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', userId: 'other-user' }),
makeDashboard({ id: 'd2', userId: 'other-user', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('schreibt die Positionen 0…n-1 in der gesendeten Reihenfolge', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
makeDashboard({ id: 'd3', position: 2 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.reorderDashboards('user-1', 'tenant-1', {
ids: ['d3', 'd1', 'd2'],
} as any);
expect(result.map((d: any) => d.id)).toEqual(['d3', 'd1', 'd2']);
expect(result.map((d: any) => d.position)).toEqual([0, 1, 2]);
});
it('eine unvollständige Liste wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1'] } as any),
).rejects.toBeInstanceOf(BadRequestException);
const unchanged = await service.listDashboards('user-1', 'tenant-1');
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
});
it('eine unbekannte Kennung wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'unknown'] } as any),
).rejects.toBeInstanceOf(BadRequestException);
const unchanged = await service.listDashboards('user-1', 'tenant-1');
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
});
it('die Kennung eines fremden Reiters wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'foreign', userId: 'other-user', position: 0 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'foreign'] } as any),
).rejects.toBeInstanceOf(BadRequestException);
const unchanged = await service.listDashboards('user-1', 'tenant-1');
expect(unchanged.map((d: any) => d.position)).toEqual([0]);
});
});
+265 -26
View File
@@ -1,18 +1,27 @@
import { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma, Role } from '@prisma/client'; import { Prisma, Role } from '@prisma/client';
import { ModuleAccessService } from '../module-registry/module-access.service'; import { ModuleAccessService } from '../module-registry/module-access.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto'; import { CreateWidgetDto } from './dto/create-widget.dto';
import { RenameDashboardDto } from './dto/rename-dashboard.dto';
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
import { SaveLayoutDto } from './dto/save-layout.dto'; import { SaveLayoutDto } from './dto/save-layout.dto';
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
import { getModuleSlugForWidgetType } from './widget-module-map'; import { getModuleSlugForWidgetType } from './widget-module-map';
/**
* T-AD9-06 — Riegel gegen Massenanfragen: hoechstens 20 Reiter je Benutzer
* (quick-260923-ad9, Task 2).
*/
const DASHBOARD_MAX_COUNT = 20;
/** /**
* Default search providers (D-15). * Default search providers (D-15).
* Returned as part of getSearchProviders even when no DB rows exist. * Returned as part of getSearchProviders even when no DB rows exist.
@@ -88,13 +97,233 @@ export class DashboardService {
) {} ) {}
/** /**
* Returns the user's saved layout, or a default empty layout * Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
* with all breakpoint arrays initialized. * Benutzers, nach `position` aufsteigend — Position 0 ist der Standard
* und wird beim Öffnen geladen. Ist die Liste leer (erster Aufruf des
* Benutzers ueberhaupt), wird genau EIN Reiter „Dashboard“ angelegt.
*
* Das Anlegen laeuft in einer `withTenantTransaction`, deren ERSTE
* Anweisung eine Transaktionssperre auf die Benutzerkennung nimmt
* (`pg_advisory_xact_lock`, `hashtext` ueber die Benutzerkennung als
* ersten Schluessel, 0 als zweiten — beides eingebaute Postgres-
* Funktionen). Zwei gleichzeitige erste Aufrufe desselben Benutzers
* warten dadurch aufeinander statt beide "kein Reiter vorhanden" zu
* sehen; die erneute Zaehlung INNERHALB der Sperre verhindert die
* doppelte Anlage (T-AD9-07). `withTenantTransaction` setzt keine
* Benutzerdimension in der Sitzung — die Bedingung traegt `userId` UND
* `tenantId` deshalb selbst, als zweites Netz.
*/ */
async getLayout(userId: string, tenantId: string) { async listDashboards(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const record = await tenantPrisma.dashboardLayout.findUnique({ let dashboards = await tenantPrisma.dashboard.findMany({
where: { userId }, where: { userId },
orderBy: { position: 'asc' },
});
if (dashboards.length === 0) {
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${userId}), 0)`;
const existing = await tx.dashboard.count({
where: { userId, tenantId },
});
if (existing === 0) {
await tx.dashboard.create({
data: { userId, tenantId, name: 'Dashboard', position: 0 },
});
}
});
dashboards = await tenantPrisma.dashboard.findMany({
where: { userId },
orderBy: { position: 'asc' },
});
}
return dashboards;
}
/**
* Riegel gegen fremde Reiter (T-AD9-01/02/03, Muster `FavoritesService.
* create`/T-GWH-05): liest den Reiter ueber den BEREITS gebundenen
* Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft
* fuer drei ununterscheidbare Faelle dieselbe `NotFoundException` — "gibt
* es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden
* Mandanten" (die Mandantengrenze zieht bereits der gebundene Klient).
* Niemals eine abweichende Antwort, aus der sich die Existenz eines
* fremden Reiters ablesen liesse.
*/
private async assertOwnedDashboard(
tenantPrisma: ReturnType<typeof forTenant>,
dashboardId: string,
userId: string,
): Promise<void> {
const dashboard = await tenantPrisma.dashboard.findUnique({
where: { id: dashboardId },
});
if (!dashboard || dashboard.userId !== userId) {
throw new NotFoundException(`Dashboard with id '${dashboardId}' not found`);
}
}
/**
* Legt einen neuen, leeren Reiter an (quick-260923-ad9, Task 2, D-08).
* Name automatisch: "Dashboard 2", "Dashboard 3", … — die kleinste noch
* freie Zahl ab 2 (füllt eine Lücke, wenn z. B. "Dashboard 2" gelöscht
* wurde). Dieser Name ist ein gespeicherter Datenwert, keine
* Oberflächenbeschriftung — deshalb ein TypeScript-Text hier statt eines
* Übersetzungsschlüssels, genau wie der Name "Dashboard", den die
* Migration/`listDashboards` vergeben. Hängt ans Ende (höchste
* vorhandene Position plus eins) und liefert den neuen Reiter mit
* leerer Kachelliste (es existiert noch keine `WidgetInstance`-Zeile
* dafür).
*/
async createDashboard(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.dashboard.findMany({ where: { userId } });
if (existing.length >= DASHBOARD_MAX_COUNT) {
throw new BadRequestException(
`Es sind bereits ${DASHBOARD_MAX_COUNT} Dashboards vorhanden — mehr sind nicht möglich.`,
);
}
const existingNames = new Set(existing.map((d) => d.name));
let n = 2;
while (existingNames.has(`Dashboard ${n}`)) n++;
const nextPosition = existing.reduce((max, d) => Math.max(max, d.position), -1) + 1;
return tenantPrisma.dashboard.create({
data: { userId, tenantId, name: `Dashboard ${n}`, position: nextPosition },
});
}
/**
* Benennt einen Reiter um (quick-260923-ad9, Task 2). `assertOwnedDashboard`
* läuft zuerst, über denselben gebundenen Klienten — eine fremde Kennung
* liefert die Nicht-gefunden-Antwort (T-AD9-03). Beschneiden und
* Längenprüfung (1–40 Zeichen) liegen bereits im DTO.
*/
async renameDashboard(
id: string,
userId: string,
tenantId: string,
dto: RenameDashboardDto,
) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, id, userId);
return tenantPrisma.dashboard.update({
where: { id },
data: { name: dto.name },
});
}
/**
* Löscht einen Reiter mit seinen Kacheln und seiner Anordnung
* (quick-260923-ad9, Task 2). `assertOwnedDashboard` läuft zuerst; danach
* wird geprüft, ob es der letzte verbleibende Reiter ist (D-10) — der
* Server weist das ab, die Oberfläche bietet den Knopf dafür gar nicht
* erst an. Löschen, Anordnung-/Kachel-Entfernen und das lückenlose
* Neuschreiben der verbleibenden Positionen laufen als EINE
* `withTenantTransaction` (mehrschrittig, muss atomar sein — dieselbe
* Begründung wie `FavoritesService.reorder`). Die Löschweitergabe in der
* Datenbank (`onDelete: Cascade`) bleibt als zweites Netz bestehen; der
* geschriebene Weg unten ist der gebundene. `withTenantTransaction`
* setzt keine Benutzerdimension in der Sitzung — jede Bedingung trägt
* `userId` deshalb selbst.
*/
async deleteDashboard(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, id, userId);
const count = await tenantPrisma.dashboard.count({ where: { userId, tenantId } });
if (count <= 1) {
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
}
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
await tx.dashboard.deleteMany({ where: { id, userId } });
const remaining = await tx.dashboard.findMany({
where: { userId },
orderBy: { position: 'asc' },
});
for (const [index, dashboard] of remaining.entries()) {
await tx.dashboard.updateMany({
where: { id: dashboard.id, userId },
data: { position: index },
});
}
return { id };
});
}
/**
* Persistiert die Reihenfolge der Reiter des Benutzers
* (quick-260923-ad9, Task 2). Wörtlich nach 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 —
* die Prüfung läuft VOR jedem `updateMany`), dann je Eintrag ein
* `updateMany` mit `id` UND `userId` in der Bedingung und einer Prüfung
* auf genau eine getroffene Zeile (T-AD9-04). Existenzorakel-Vermeidung:
* EINE `BadRequestException` mit DERSELBEN Meldung für unvollständige,
* unbekannte und fremde Kennungen — kein Fall verrät, welcher Grund
* zutraf (Muster T-GWH-05/T-JDD-06).
*/
async reorderDashboards(userId: string, tenantId: string, dto: ReorderDashboardsDto) {
if (new Set(dto.ids).size !== dto.ids.length) {
throw new BadRequestException('ids must match the dashboards of this user exactly');
}
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
const existing = await tx.dashboard.findMany({
where: { userId },
select: { id: true },
});
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
if (existing.length !== dto.ids.length || dto.ids.some((id) => !existingIds.has(id))) {
throw new BadRequestException('ids must match the dashboards of this user exactly');
}
for (const [index, id] of dto.ids.entries()) {
const { count } = await tx.dashboard.updateMany({
where: { id, userId },
data: { position: index },
});
if (count !== 1) {
throw new BadRequestException('ids must match the dashboards of this user exactly');
}
}
return tx.dashboard.findMany({
where: { userId },
orderBy: { position: 'asc' },
});
});
}
/**
* Returns the saved layout of one dashboard tab, or a default empty
* layout with all breakpoint arrays initialized.
*
* quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
* `assertOwnedDashboard` runs first, over the SAME bound client.
*/
async getLayout(userId: string, tenantId: string, dashboardId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
const record = await tenantPrisma.dashboardLayout.findUnique({
where: { dashboardId },
}); });
if (!record) { if (!record) {
@@ -105,32 +334,33 @@ export class DashboardService {
} }
/** /**
* Upserts the user's dashboard layout. * Upserts the layout of one dashboard tab.
* Creates a new record if none exists, updates if it does. * Creates a new record if none exists, updates if it does.
* *
* `userId` is platform-wide `@unique` (no tenant component) — a tenant * quick-260923-ad9 (D-02): scoped by `dto.dashboardId` instead of
* whose user id was, by hand, moved off its actually-visible row could hit * `userId` — `assertOwnedDashboard` runs first, over the SAME bound
* an `upsert` conflict on a row it cannot see under RLS. Measured * client. `dashboardId` is now the `@unique` column on `DashboardLayout`
* (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row * (was `userId` before this plan).
* throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known *
* error that the `tenders` area's translation pattern catches — this is a * A bound conflicting upsert against a row invisible under RLS throws
* `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known error
* that the `tenders` area's translation pattern catches — this is a
* different Prisma error class, `.code`/`.meta` are `undefined`, the only * different Prisma error class, `.code`/`.meta` are `undefined`, the only
* signal is the raw `.message` text). Translated below into an * signal is the raw `.message` text) — measured 260910-krx, Aufgabe 1,
* understandable German message instead of a raw 500, same intent as * translation kept unchanged from before this plan.
* `tender-notification-pref.service.ts`, different detection. Not
* reachable via any application path today (a user's tenant id never
* changes after creation) — the honest fix is a schema change and is
* deferred as a product decision to Etappe 3, same as WINDOWS #22.
*/ */
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) { async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
try { try {
return await tenantPrisma.dashboardLayout.upsert({ return await tenantPrisma.dashboardLayout.upsert({
where: { userId }, where: { dashboardId: dto.dashboardId },
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue }, update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
create: { create: {
userId, userId,
tenantId, tenantId,
dashboardId: dto.dashboardId,
layouts: dto.layouts as unknown as Prisma.InputJsonValue, layouts: dto.layouts as unknown as Prisma.InputJsonValue,
}, },
}); });
@@ -145,12 +375,13 @@ export class DashboardService {
} }
/** /**
* Returns all widget instances for a given user, gefiltert um Widgets * Returns all widget instances of one dashboard tab, gefiltert um Widgets
* eines für den Benutzer gesperrten Moduls (D-22, PERM-07). * eines für den Benutzer gesperrten Moduls (D-22, PERM-07).
* *
* Die bestehende Query bleibt unverändert die erste Aktion. Steht unter * quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
* den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — der * `assertOwnedDashboard` runs first, over the SAME bound client. Steht
* Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die * unter den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` —
* der Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die
* Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei * Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei
* mindestens einem modulgebundenen Widget wird die Zugriffsauflösung * mindestens einem modulgebundenen Widget wird die Zugriffsauflösung
* aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und * aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und
@@ -158,10 +389,12 @@ export class DashboardService {
* Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das * Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das
* betroffene Widget entfernt (Fail-Closed). * betroffene Widget entfernt (Fail-Closed).
*/ */
async getWidgets(userId: string, tenantId: string, role: Role) { async getWidgets(userId: string, tenantId: string, role: Role, dashboardId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
const widgets = await tenantPrisma.widgetInstance.findMany({ const widgets = await tenantPrisma.widgetInstance.findMany({
where: { userId }, where: { dashboardId },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}); });
@@ -207,14 +440,20 @@ export class DashboardService {
} }
/** /**
* Creates a new widget instance for the user. * Creates a new widget instance on one dashboard tab.
* quick-260923-ad9 (D-02): `assertOwnedDashboard` runs first, over the
* SAME bound client — a widget can only be created on a tab the caller
* owns.
*/ */
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) { async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
return tenantPrisma.widgetInstance.create({ return tenantPrisma.widgetInstance.create({
data: { data: {
userId, userId,
tenantId, tenantId,
dashboardId: dto.dashboardId,
widgetType: dto.widgetType, widgetType: dto.widgetType,
config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue, config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue,
}, },
@@ -2,7 +2,7 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
import { WIDGET_TYPES } from '@tessera/shared'; import { WIDGET_TYPES } from '@tessera/shared';
/** /**
* DTO for creating a new widget instance on a user's dashboard. * DTO for creating a new widget instance on one dashboard tab.
* *
* quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus * quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus
* `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine * `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine
@@ -10,9 +10,15 @@ import { WIDGET_TYPES } from '@tessera/shared';
* zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog * zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog
* angebotene Kachel mit 400 ab. * angebotene Kachel mit 400 ab.
* *
* quick-260923-ad9: `dashboardId` selects the tab — the service verifies
* ownership before writing (`assertOwnedDashboard`).
*
* config is optional and defaults to {} on the model. * config is optional and defaults to {} on the model.
*/ */
export class CreateWidgetDto { export class CreateWidgetDto {
@IsString()
dashboardId!: string;
@IsString() @IsString()
@IsIn([...WIDGET_TYPES]) @IsIn([...WIDGET_TYPES])
widgetType!: string; widgetType!: string;
@@ -0,0 +1,17 @@
import { Transform } from 'class-transformer';
import { IsString, Length } from 'class-validator';
/**
* DTO for `PATCH /dashboard/tabs/:id` (quick-260923-ad9, Task 2).
*
* `name` wird VOR der Längenprüfung beschnitten (führende/nachgestellte
* Leerräume zählen nicht mit) — ein reiner Leerraum-Name schlägt danach an
* `@Length(1, 40)` fehl. Die Obergrenze von 40 Zeichen ist ein Riegel gegen
* Massenanfragen, kein UI-Detail (T-AD9-06).
*/
export class RenameDashboardDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@Length(1, 40)
name!: string;
}
@@ -0,0 +1,26 @@
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsString,
} from 'class-validator';
/**
* DTO for `PUT /dashboard/tabs/order` (quick-260923-ad9, Task 2).
*
* `ids` ist die VOLLSTÄNDIGE Kennungsliste der Reiter des Benutzers, in
* der gewünschten Reihenfolge — der Dienst verlangt einen exakten Abgleich
* gegen die vorhandenen Reiter (kein Teil-Umsortieren, keine fremden/
* unbekannten Kennungen), Muster `ReorderFavoritesDto`/`FavoritesService.
* reorder` (260917-jdd). `ArrayMaxSize(20)` ist ein Riegel gegen
* Massenanfragen (T-AD9-06) — ein Benutzer hat höchstens 20 Reiter.
*/
export class ReorderDashboardsDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(20)
@ArrayUnique()
@IsString({ each: true })
ids!: string[];
}
@@ -1,11 +1,16 @@
import { IsObject } from 'class-validator'; import { IsObject, IsString } from 'class-validator';
/** /**
* DTO for saving/updating a user's dashboard layout. * DTO for saving/updating the layout of one dashboard tab (quick-260923-ad9).
* The layouts object contains responsive breakpoint layouts * The layouts object contains responsive breakpoint layouts
* (lg, md, sm, xs, xxs) as managed by react-grid-layout. * (lg, md, sm, xs, xxs) as managed by react-grid-layout.
* `dashboardId` selects the tab — the service verifies ownership before
* writing (`assertOwnedDashboard`).
*/ */
export class SaveLayoutDto { export class SaveLayoutDto {
@IsString()
dashboardId!: string;
@IsObject() @IsObject()
layouts!: Record<string, unknown>; layouts!: Record<string, unknown>;
} }
@@ -39,8 +39,11 @@ describe('widget-module-map (quick-260922-m1h)', () => {
* ablehnen zu lassen. * ablehnen zu lassen.
*/ */
describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => { describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => {
// quick-260923-ad9: dashboardId ist seither ein Pflichtfeld (Reiter-
// Kennung) — hier fest mitgegeben, damit dieser Test weiterhin nur die
// Whitelist von widgetType prueft.
async function validateType(widgetType: string) { async function validateType(widgetType: string) {
const dto = plainToInstance(CreateWidgetDto, { widgetType }); const dto = plainToInstance(CreateWidgetDto, { widgetType, dashboardId: 'dash-1' });
return validate(dto); return validate(dto);
} }
+11
View File
@@ -20,6 +20,12 @@ vi.mock('next-intl', () => ({
})); }));
const mockStore = { const mockStore = {
// quick-260923-ad9: Reiter — leere Liste rendert die Reiterleiste ohne
// Reiter-Knoepfe, damit diese Datei bei den bestehenden Zusammensetzungs-
// Pruefungen bleibt, statt den Reiter-Speicher selbst nachzubauen.
dashboards: [] as Array<{ id: string; name: string; position: number }>,
activeDashboardId: null as string | null,
isSwitchingDashboard: false,
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] }, layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [] as Array<{ id: string; widgetType: string; config: Record<string, unknown> }>, widgets: [] as Array<{ id: string; widgetType: string; config: Record<string, unknown> }>,
isEditMode: false, isEditMode: false,
@@ -32,6 +38,11 @@ const mockStore = {
removeWidget: vi.fn(), removeWidget: vi.fn(),
loadDashboard: vi.fn(), loadDashboard: vi.fn(),
saveLayout: vi.fn(), saveLayout: vi.fn(),
selectDashboard: vi.fn(),
createDashboard: vi.fn(),
renameDashboard: vi.fn(),
deleteDashboard: vi.fn(),
reorderDashboards: vi.fn(),
}; };
vi.mock('@/lib/stores/dashboard-store', () => ({ vi.mock('@/lib/stores/dashboard-store', () => ({
+35 -6
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import { registerWidget } from '@/components/dashboard/widget-registry'; import { registerWidget } from '@/components/dashboard/widget-registry';
@@ -48,6 +49,9 @@ export default function DashboardPage() {
const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState<string[] | null>(null); const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState<string[] | null>(null);
const { const {
dashboards,
activeDashboardId,
isSwitchingDashboard,
layouts, layouts,
widgets, widgets,
isEditMode, isEditMode,
@@ -58,6 +62,11 @@ export default function DashboardPage() {
addWidget, addWidget,
removeWidget, removeWidget,
loadDashboard, loadDashboard,
selectDashboard,
createDashboard,
renameDashboard,
deleteDashboard,
reorderDashboards,
} = useDashboardStore(); } = useDashboardStore();
// Load dashboard data on mount // Load dashboard data on mount
@@ -105,15 +114,35 @@ export default function DashboardPage() {
return ( return (
<div className="relative p-2"> <div className="relative p-2">
{/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */} {/* Reiterleiste (quick-260923-ad9) — bleibt waehrend eines
<DashboardGrid Reiterwechsels stehen, nur das Raster darunter zeigt eine kurze
layouts={layouts} Ladezeile. */}
widgets={widgets} <DashboardTabs
dashboards={dashboards}
activeDashboardId={activeDashboardId}
isEditMode={isEditMode} isEditMode={isEditMode}
onLayoutChange={updateLayouts} onSelect={selectDashboard}
onRemoveWidget={removeWidget} onCreate={createDashboard}
onRename={renameDashboard}
onDelete={deleteDashboard}
onReorder={reorderDashboards}
/> />
{isSwitchingDashboard ? (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
</div>
) : (
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={isEditMode}
onLayoutChange={updateLayouts}
onRemoveWidget={removeWidget}
/>
)}
{/* Feste Aktionsleiste unten rechts. {/* Feste Aktionsleiste unten rechts.
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px). Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
@@ -3,11 +3,20 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel'; import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel';
import { fetchWidgets } from '@/lib/dashboard-api'; import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
/** /**
* Settings > Dashboard > Widgets page (D-03). * Settings > Dashboard > Widgets page (D-03).
* Fetches user's widget instances and renders per-instance config forms. * Fetches the widget instances of the user's FIRST dashboard tab (the
* standard tab, quick-260923-ad9) and renders per-instance config forms.
*
* quick-260923-ad9 (Task 3, deviation Rule 3 — blocking compile issue):
* `fetchWidgets` now requires a `dashboardId` since widgets are scoped per
* tab. This page is not itself tab-aware (out of this plan's scope — see
* "Nicht im Umfang") and previously showed every widget the user had, back
* when there was exactly one dashboard per user; it now shows the first
* tab's widgets, which is the same set for the (overwhelmingly common)
* case of a user who has not yet created a second tab.
*/ */
export default function WidgetSettingsPage() { export default function WidgetSettingsPage() {
const t = useTranslations('settings'); const t = useTranslations('settings');
@@ -18,12 +27,17 @@ export default function WidgetSettingsPage() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetchWidgets() (async () => {
.then(setWidgets) try {
.catch(() => { const dashboards = await fetchDashboards();
const first = dashboards[0];
setWidgets(first ? await fetchWidgets(first.id) : []);
} catch {
// Silent fail — empty widget list shown // Silent fail — empty widget list shown
}) } finally {
.finally(() => setIsLoading(false)); setIsLoading(false);
}
})();
}, []); }, []);
return ( return (
@@ -0,0 +1,343 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* Mock next-intl mit einfacher `{param}`-Ersetzung — anders als das
* schlichtere Muster in `widget-catalog-modal.test.tsx` (das Parameter
* ignoriert), weil Test 8 unten die eingesetzte Reiterbezeichnung im
* Loeschdialog prueft.
*/
vi.mock('next-intl', () => ({
useTranslations: (namespace: string) => (key: string, params?: Record<string, string>) => {
const translations: Record<string, Record<string, string>> = {
widgets: {
'tabs.navLabel': 'Dashboard-Reiter',
'tabs.add': 'Dashboard hinzufügen',
'tabs.renameButtonLabel': 'Dashboard umbenennen',
'tabs.renameInputLabel': 'Name des Dashboards',
'tabs.deleteButtonLabel': 'Dashboard löschen',
'tabs.deleteDialogTitle': 'Dashboard löschen',
'tabs.deleteDialogBody': 'Möchten Sie das Dashboard „{name}“ wirklich löschen?',
'tabs.dragHint': 'Ziehen Sie einen Reiter, um ihn zum Standard zu machen.',
},
common: {
cancel: 'Abbrechen',
delete: 'Löschen',
},
};
let text = translations[namespace]?.[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(`{${k}}`, v);
}
}
return text;
},
}));
import { DashboardTabs } from './dashboard-tabs';
const DASHBOARDS = [
{ id: 'd1', name: 'Dashboard', position: 0 },
{ id: 'd2', name: 'Dashboard 2', position: 1 },
];
const THREE_DASHBOARDS = [
{ id: 'd1', name: 'Dashboard', position: 0 },
{ id: 'd2', name: 'Dashboard 2', position: 1 },
{ id: 'd3', name: 'Dashboard 3', position: 2 },
];
/**
* Stubbt `getBoundingClientRect` fuer JEDEN Reiter-Wrapper so, dass Reiter i
* (nach seiner AKTUELLEN Stelle im DOM, `data-tab-index`) die Spanne von
* i·100 bis i·100+100 belegt — echte Masse liefert jsdom hier nicht (Plan-
* Vorgabe, Task 4). Die Zeigerpositionen der Tests unten rechnen gegen
* genau diese Spannen.
*/
function stubTabRects() {
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLDivElement,
) {
const idx = Number(this.dataset.tabIndex ?? '0');
return {
x: idx * 100,
y: 0,
left: idx * 100,
top: 0,
right: idx * 100 + 100,
bottom: 32,
width: 100,
height: 32,
toJSON() {
return {};
},
} as DOMRect;
});
}
function defaultProps(overrides: Partial<Parameters<typeof DashboardTabs>[0]> = {}) {
return {
dashboards: DASHBOARDS,
activeDashboardId: 'd1',
isEditMode: false,
onSelect: vi.fn(),
onCreate: vi.fn(),
onRename: vi.fn(),
onDelete: vi.fn(),
onReorder: vi.fn(),
...overrides,
};
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('DashboardTabs (quick-260923-ad9, Task 3)', () => {
it('Test 1: rendert jeden Reiter mit seinem Namen', () => {
render(<DashboardTabs {...defaultProps()} />);
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument();
});
it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => {
const onSelect = vi.fn();
render(<DashboardTabs {...defaultProps({ onSelect })} />);
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2');
});
it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => {
render(<DashboardTabs {...defaultProps()} />);
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current');
});
it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: false })} />);
expect(screen.queryByLabelText('Dashboard umbenennen')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Dashboard hinzufügen')).not.toBeInTheDocument();
});
it('Test 5: im Bearbeitungsmodus mit mehreren Reitern sind Loeschen-Knoepfe und ein Hinzufuegen-Knopf da', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
expect(screen.getAllByLabelText('Dashboard löschen')).toHaveLength(2);
expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument();
});
it('Test 6: beim letzten verbleibenden Reiter wird Loeschen gar nicht erst angeboten', () => {
render(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]], isEditMode: true })} />);
expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument();
});
it('Test 7: der Hinzufuegen-Knopf ruft onCreate auf', () => {
const onCreate = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onCreate })} />);
fireEvent.click(screen.getByLabelText('Dashboard hinzufügen'));
expect(onCreate).toHaveBeenCalledTimes(1);
});
it('Test 8: Umbenennen-Knopf des AKTIVEN Reiters verwandelt den Namen in ein Eingabefeld; Enter uebernimmt beschnitten', () => {
const onRename = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
const input = screen.getByLabelText('Name des Dashboards');
fireEvent.change(input, { target: { value: ' Finanzen ' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onRename).toHaveBeenCalledWith('d1', 'Finanzen');
});
it('Test 9: Escape verwirft die Umbenennung, ohne onRename aufzurufen', () => {
const onRename = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onRename })} />);
fireEvent.click(screen.getByLabelText('Dashboard umbenennen'));
const input = screen.getByLabelText('Name des Dashboards');
fireEvent.change(input, { target: { value: 'Verworfen' } });
fireEvent.keyDown(input, { key: 'Escape' });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument();
});
it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => {
render(<DashboardTabs {...defaultProps({ isEditMode: true })} />);
expect(screen.getAllByLabelText('Dashboard umbenennen')).toHaveLength(1);
});
it('Test 11: Loeschen fragt mit dem Namen des Reiters zurueck; Bestaetigen ruft onDelete auf', () => {
const onDelete = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[1]);
const dialog = screen.getByRole('alertdialog');
expect(dialog).toHaveTextContent('Dashboard 2');
fireEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
expect(onDelete).toHaveBeenCalledWith('d2');
});
it('Test 12: Abbrechen im Loeschdialog ruft onDelete NICHT auf', () => {
const onDelete = vi.fn();
render(<DashboardTabs {...defaultProps({ isEditMode: true, onDelete })} />);
fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]);
const dialog = screen.getByRole('alertdialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Abbrechen' }));
expect(onDelete).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
});
describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', () => {
it('Test 13: Druecken und Loslassen OHNE nennenswerte Bewegung sendet KEINE neue Reihenfolge (der nachfolgende Klick waehlt weiterhin)', () => {
stubTabRects();
const onReorder = vi.fn();
const onSelect = vi.fn();
render(<DashboardTabs {...defaultProps({ onReorder, onSelect })} />);
const tab = screen.getByTestId('dashboard-tab-d2');
fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 });
fireEvent.pointerUp(tab, { clientX: 150, pointerId: 1 });
expect(onReorder).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' }));
expect(onSelect).toHaveBeenCalledWith('d2');
});
it('Test 14: Druecken, mehr als die Schwelle nach RECHTS bewegen und loslassen verschiebt den Reiter hinter seinen rechten Nachbarn', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d1'); // Spanne 0..100
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
// ueber die Schwelle hinaus, weit rechts von d2s Mittelpunkt (150)
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 });
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
});
it('Test 15: dasselbe nach LINKS verschiebt vor den linken Nachbarn', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d2'); // Spanne 100..200
fireEvent.pointerDown(tab, { clientX: 150, pointerId: 1, button: 0 });
// weit links von d1s Mittelpunkt (50)
fireEvent.pointerMove(tab, { clientX: 10, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 10, pointerId: 1 });
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
});
it('Test 16: waehrend des Ziehens zeigt die Leiste die Vorschau-Reihenfolge; Abbruch des Zeigers verwirft sie, ohne zu senden', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
// Vorschau: d2 steht jetzt vor d1 im DOM.
const buttons = screen.getAllByRole('button').filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
expect(buttons.map((b) => b.textContent)).toEqual(['Dashboard 2', 'Dashboard']);
fireEvent.pointerCancel(tab, { pointerId: 1 });
expect(onReorder).not.toHaveBeenCalled();
const buttonsAfterCancel = screen
.getAllByRole('button')
.filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2');
expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', 'Dashboard 2']);
});
it('Test 17: ein Ziehen, das den ersten Reiter verdraengt, macht den vorgezogenen Reiter zum ersten in der gesendeten Liste', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: THREE_DASHBOARDS, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d3'); // Spanne 200..300
fireEvent.pointerDown(tab, { clientX: 250, pointerId: 1, button: 0 });
// weit links von d1s Mittelpunkt (50) -> wird der neue Erste
fireEvent.pointerMove(tab, { clientX: 5, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 5, pointerId: 1 });
expect(onReorder).toHaveBeenCalledWith(['d3', 'd1', 'd2']);
});
it('Test 18: der aktive Reiter bleibt beim Ziehen aktiv, auch wenn er seine Position wechselt', () => {
stubTabRects();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, activeDashboardId: 'd1', onReorder: vi.fn() })} />);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true');
});
it('Test 19: nach einem echten Ziehen unterdrueckt der naechste Klick die Auswahl EINMAL, danach funktioniert Klicken wieder normal', () => {
stubTabRects();
const onSelect = vi.fn();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, onSelect, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 });
expect(onReorder).toHaveBeenCalledTimes(1);
// Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen.
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
expect(onSelect).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }));
expect(onSelect).toHaveBeenCalledWith('d1');
});
it('Test 20: der Hinweistext zum Ziehen steht bei mehr als einem Reiter, aber nicht bei genau einem', () => {
const { rerender } = render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS })} />);
expect(screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.')).toBeInTheDocument();
rerender(<DashboardTabs {...defaultProps({ dashboards: [DASHBOARDS[0]] })} />);
expect(
screen.queryByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'),
).not.toBeInTheDocument();
});
it('Test 21: Ziehen ist auch AUSSERHALB des Bearbeitungsmodus moeglich (D-09)', () => {
stubTabRects();
const onReorder = vi.fn();
render(<DashboardTabs {...defaultProps({ dashboards: DASHBOARDS, isEditMode: false, onReorder })} />);
const tab = screen.getByTestId('dashboard-tab-d1');
fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 });
fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 });
fireEvent.pointerUp(tab, { clientX: 180, pointerId: 1 });
expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']);
});
});
@@ -0,0 +1,393 @@
'use client';
import { useTranslations } from 'next-intl';
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import type { DashboardTab } from '@/lib/dashboard-api';
interface DashboardTabsProps {
dashboards: DashboardTab[];
activeDashboardId: string | null;
isEditMode: boolean;
onSelect: (id: string) => void;
onCreate: () => void;
onRename: (id: string, name: string) => void;
onDelete: (id: string) => void;
onReorder: (ids: string[]) => void;
}
/** Ab dieser waagerechten Auslenkung (Bildschirmpixel) wird aus einem Klick ein Ziehen (Task 4). */
const DRAG_THRESHOLD_PX = 4;
// jsdom kennt kein setPointerCapture — im Browser wird gefangen, im Test
// feuern Move/Up auf demselben Element (Muster xframe-config-form.tsx).
function capturePointer(el: HTMLElement, id: number) {
if (typeof el.setPointerCapture === 'function') el.setPointerCapture(id);
}
function releasePointer(el: HTMLElement, id: number) {
if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(id);
}
interface DragState {
id: string;
pointerId: number;
startX: number;
}
/**
* Berechnet die neue Reihenfolge, wenn `draggedId` an die Zeigerposition
* `clientX` verschoben wird: der gezogene Reiter wird aus der Liste
* entfernt, dann vor dem ERSTEN verbleibenden Reiter eingefuegt, dessen
* Mittelpunkt rechts vom Zeiger liegt (ansonsten ans Ende).
*/
function computeReorderedIds(
order: string[],
draggedId: string,
rects: Map<string, { left: number; width: number }>,
clientX: number,
): string[] {
const others = order.filter((id) => id !== draggedId);
let insertAt = others.length;
for (let i = 0; i < others.length; i++) {
const rect = rects.get(others[i]);
if (!rect) continue;
const midpoint = rect.left + rect.width / 2;
if (clientX < midpoint) {
insertAt = i;
break;
}
}
const next = others.slice();
next.splice(insertAt, 0, draggedId);
return next;
}
/**
* Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3/4).
*
* Klick wechselt IMMER den Reiter, unabhängig vom Bearbeitungsmodus.
* Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft)
* und Löschen (mit Rückfrage) sind nur im Bearbeitungsmodus sichtbar; der
* Löschen-Knopf fehlt zusätzlich beim letzten verbleibenden Reiter (D-10 —
* der Server weist das ohnehin ab, die Oberfläche bietet es gar nicht erst
* an).
*
* Ziehen (Task 4, D-05 — keine neue Abhängigkeit, Pointer-Ereignisse wie in
* `xframe-config-form.tsx`) ist IMMER möglich, nicht nur im Bearbeitungs-
* modus: nach vorn ziehen IST das Festlegen des Standards (D-09). Unter der
* Schwelle von {@link DRAG_THRESHOLD_PX} bleibt es ein Klick (`onClick`
* wechselt den Reiter, kein `onReorder`); darüber wird der Zeiger
* eingefangen, die Leiste zeigt die Vorschau-Reihenfolge, und beim
* Loslassen geht die VOLLSTÄNDIGE Kennungsliste an `onReorder`. Ein
* `hasDraggedRef`-Merker unterdrückt den `onClick`, der nach einem echten
* Ziehen im echten Browser folgt (jsdom feuert ihn in Tests nicht von
* selbst nach). Abbruch des Zeigers verwirft die Vorschau, ohne zu senden.
*/
export function DashboardTabs({
dashboards,
activeDashboardId,
isEditMode,
onSelect,
onCreate,
onRename,
onDelete,
onReorder,
}: DashboardTabsProps) {
const t = useTranslations('widgets');
const tCommon = useTranslations('common');
const [renamingId, setRenamingId] = useState<string | null>(null);
const [draftName, setDraftName] = useState('');
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const dragRef = useRef<DragState | null>(null);
const hasDraggedRef = useRef(false);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber
// einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster
// `widget-catalog-modal.tsx` (dialogRef.current?.focus()).
useEffect(() => {
if (renamingId) {
renameInputRef.current?.focus();
renameInputRef.current?.select();
}
}, [renamingId]);
const order = previewOrder ?? dashboards.map((d) => d.id);
const orderedTabs = order
.map((id) => dashboards.find((d) => d.id === id))
.filter((d): d is DashboardTab => d !== undefined);
function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) {
if (e.button !== 0 || renamingId) return;
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX };
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
if (!drag) return;
const wasNotDragging = previewOrder === null;
if (wasNotDragging) {
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return;
hasDraggedRef.current = true;
capturePointer(e.currentTarget, drag.pointerId);
}
// Auf dem SELBEN Ereignis, das die Schwelle ueberschreitet, wird sofort
// die Zielposition berechnet — nicht erst beim naechsten Move. Ohne das
// wuerde ein einzelner grosser Sprung (Druecken, weit bewegen, Loslassen
// — die Form, in der ein Ziehen unter jsdom typischerweise ausgeloest
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
// Ziehzustand wechselt, ohne die Position auszuwerten.
const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
const rects = new Map<string, { left: number; width: number }>();
for (const id of baseOrder) {
const el = tabRefs.current.get(id);
if (el) {
const rect = el.getBoundingClientRect();
rects.set(id, { left: rect.left, width: rect.width });
}
}
const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX);
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
setPreviewOrder(next);
}
}
function handlePointerUp(e: PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
dragRef.current = null;
if (!drag) return;
if (previewOrder !== null) {
releasePointer(e.currentTarget, drag.pointerId);
const finalOrder = previewOrder;
setPreviewOrder(null);
onReorder(finalOrder);
}
}
function handlePointerCancel(e: PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
dragRef.current = null;
if (drag && previewOrder !== null) {
releasePointer(e.currentTarget, drag.pointerId);
}
setPreviewOrder(null);
}
function handleTabClick(tabId: string) {
if (hasDraggedRef.current) {
hasDraggedRef.current = false;
return;
}
onSelect(tabId);
}
function startRename(tab: DashboardTab) {
setRenamingId(tab.id);
setDraftName(tab.name);
}
function commitRename() {
const id = renamingId;
const name = draftName.trim();
setRenamingId(null);
if (id && name) {
onRename(id, name);
}
}
function cancelRename() {
setRenamingId(null);
}
const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null;
return (
<div>
<nav aria-label={t('tabs.navLabel')} className="mb-1 flex items-center gap-1 overflow-x-auto">
{orderedTabs.map((tab, index) => {
const isActive = tab.id === activeDashboardId;
const isRenaming = renamingId === tab.id;
return (
<div
key={tab.id}
ref={(el) => {
if (el) tabRefs.current.set(tab.id, el);
else tabRefs.current.delete(tab.id);
}}
data-tab-index={index}
data-testid={`dashboard-tab-${tab.id}`}
className="flex touch-none items-center"
onPointerDown={(e) => handlePointerDown(e, tab.id)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
{isRenaming ? (
<input
ref={renameInputRef}
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitRename();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelRename();
}
}}
maxLength={40}
aria-label={t('tabs.renameInputLabel')}
className="h-8 w-32 rounded border border-border bg-background px-2 text-sm text-foreground"
/>
) : (
<button
type="button"
onClick={() => handleTabClick(tab.id)}
aria-current={isActive ? 'true' : undefined}
className={`h-8 cursor-grab rounded-t px-3 text-sm font-medium transition-colors ${
isActive
? 'border border-b-transparent border-border bg-card text-foreground'
: 'text-muted-foreground hover:bg-muted'
}`}
>
{tab.name}
</button>
)}
{isEditMode && isActive && !isRenaming && (
<button
type="button"
onClick={() => startRename(tab)}
aria-label={t('tabs.renameButtonLabel')}
title={t('tabs.renameButtonLabel')}
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
</button>
)}
{isEditMode && dashboards.length > 1 && (
<button
type="button"
onClick={() => setPendingDeleteId(tab.id)}
aria-label={t('tabs.deleteButtonLabel')}
title={t('tabs.deleteButtonLabel')}
className="ml-0.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
);
})}
{isEditMode && (
<button
type="button"
onClick={onCreate}
aria-label={t('tabs.add')}
title={t('tabs.add')}
className="ml-1 flex h-8 w-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
)}
{pendingDeleteTab && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
onClick={() => setPendingDeleteId(null)}
aria-label={tCommon('cancel')}
className="fixed inset-0 bg-black/50"
/>
<div
role="alertdialog"
aria-modal="true"
aria-label={t('tabs.deleteDialogTitle')}
className="relative z-50 mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
>
<h3 className="mb-2 text-lg font-semibold text-foreground">
{t('tabs.deleteDialogTitle')}
</h3>
<p className="mb-4 text-sm text-muted-foreground">
{t('tabs.deleteDialogBody', { name: pendingDeleteTab.name })}
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setPendingDeleteId(null)}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
{tCommon('cancel')}
</button>
<button
type="button"
onClick={() => {
onDelete(pendingDeleteTab.id);
setPendingDeleteId(null);
}}
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
{tCommon('delete')}
</button>
</div>
</div>
</div>
)}
</nav>
{dashboards.length > 1 && (
<p className="mb-2 text-xs text-muted-foreground">{t('tabs.dragHint')}</p>
)}
</div>
);
}
+71 -10
View File
@@ -5,44 +5,105 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export async function fetchLayout(): Promise<Record<string, unknown>> { // --- Reiter (quick-260923-ad9) ---------------------------------------------
const res = await fetch(`${API_URL}/dashboard/layout`, {
export interface DashboardTab {
id: string;
name: string;
position: number;
}
export async function fetchDashboards(): Promise<DashboardTab[]> {
const res = await fetch(`${API_URL}/dashboard/tabs`, {
credentials: 'include', credentials: 'include',
}); });
if (!res.ok) throw new Error('Failed to fetch dashboards');
return res.json();
}
export async function createDashboardTab(): Promise<DashboardTab> {
const res = await fetch(`${API_URL}/dashboard/tabs`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to create dashboard');
return res.json();
}
export async function renameDashboardTab(id: string, name: string): Promise<DashboardTab> {
const res = await fetch(`${API_URL}/dashboard/tabs/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error('Failed to rename dashboard');
return res.json();
}
export async function deleteDashboardTab(id: string): Promise<void> {
const res = await fetch(`${API_URL}/dashboard/tabs/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to delete dashboard');
}
export async function reorderDashboardTabs(ids: string[]): Promise<DashboardTab[]> {
const res = await fetch(`${API_URL}/dashboard/tabs/order`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ids }),
});
if (!res.ok) throw new Error('Failed to reorder dashboards');
return res.json();
}
// --- Anordnung und Kacheln — je Reiter (quick-260923-ad9) ------------------
export async function fetchLayout(dashboardId: string): Promise<Record<string, unknown>> {
const res = await fetch(
`${API_URL}/dashboard/layout?dashboardId=${encodeURIComponent(dashboardId)}`,
{ credentials: 'include' },
);
if (!res.ok) throw new Error('Failed to fetch layout'); if (!res.ok) throw new Error('Failed to fetch layout');
return res.json(); return res.json();
} }
export async function saveLayout( export async function saveLayout(
dashboardId: string,
layouts: Record<string, unknown>, layouts: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const res = await fetch(`${API_URL}/dashboard/layout`, { const res = await fetch(`${API_URL}/dashboard/layout`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ layouts }), body: JSON.stringify({ dashboardId, layouts }),
}); });
if (!res.ok) throw new Error('Failed to save layout'); if (!res.ok) throw new Error('Failed to save layout');
} }
export async function fetchWidgets(): Promise< export async function fetchWidgets(
Array<{ id: string; widgetType: string; config: Record<string, unknown> }> dashboardId: string,
> { ): Promise<Array<{ id: string; widgetType: string; config: Record<string, unknown> }>> {
const res = await fetch(`${API_URL}/dashboard/widgets`, { const res = await fetch(
credentials: 'include', `${API_URL}/dashboard/widgets?dashboardId=${encodeURIComponent(dashboardId)}`,
}); { credentials: 'include' },
);
if (!res.ok) throw new Error('Failed to fetch widgets'); if (!res.ok) throw new Error('Failed to fetch widgets');
return res.json(); return res.json();
} }
export async function addWidget( export async function addWidget(
dashboardId: string,
widgetType: string, widgetType: string,
): Promise<{ id: string; widgetType: string; config: Record<string, unknown> }> { ): Promise<{ id: string; widgetType: string; config: Record<string, unknown> }> {
const res = await fetch(`${API_URL}/dashboard/widgets`, { const res = await fetch(`${API_URL}/dashboard/widgets`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ widgetType }), body: JSON.stringify({ dashboardId, widgetType }),
}); });
if (!res.ok) throw new Error('Failed to add widget'); if (!res.ok) throw new Error('Failed to add widget');
return res.json(); return res.json();
+207 -12
View File
@@ -1,17 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/** /**
* dashboard-store.test — NEU (quick-260916-bwo, feineres Dashboard-Raster). * dashboard-store.test — quick-260916-bwo (Marker-Umrechnung) + erweitert um
* quick-260923-ad9 (Task 3, Dashboard-Reiter).
* *
* Sechs Tests fuer die Anbindung der einmaligen Umrechnung im Store: * Sechs Tests (1-6) pinnen weiterhin die Anbindung der einmaligen
* `loadDashboard` rechnet alte Anordnungen um und speichert SOFORT mit * Umrechnung im Store — `loadDashboard`/`selectDashboard` rechnen alte
* Marker; `saveLayout` traegt den Marker bei JEDEM Speichern (T-BWO-02 — * Anordnungen um und speichern SOFORT mit Marker; `saveLayout` traegt den
* fehlt er, wuerde das naechste Laden erneut verdoppeln); der Zustand * Marker bei JEDEM Speichern (T-BWO-02); der Zustand selbst bleibt
* selbst bleibt markerfrei (der Store iteriert mit Object.keys ueber die * markerfrei; `addWidget` legt neue Eintraege in den verdoppelten
* Breakpoints); `addWidget` legt neue Eintraege in den verdoppelten * Vorgabegroessen an. Signaturen sind an die Reiter-Kennung angepasst
* Vorgabegroessen an. Der Store hatte bisher keine Testdatei. * (`fetchLayout(dashboardId)`, `saveLayout(dashboardId, layouts)`,
* `addWidget(dashboardId, widgetType)`), das gepruefte Verhalten ist
* dasselbe.
*
* Ab Test 7: Reiter-Verwaltung — Laden holt zuerst die Reiterliste, macht
* den ersten aktiv und laedt erst danach dessen Inhalt; ein Reiterwechsel
* schreibt eine ungespeicherte Anordnung zuerst fuer den ALTEN Reiter;
* Anlegen/Umbenennen/Loeschen pflegen die Reiterliste und den aktiven
* Reiter.
*/ */
vi.mock('@/lib/dashboard-api', () => ({ vi.mock('@/lib/dashboard-api', () => ({
fetchDashboards: vi.fn(),
createDashboardTab: vi.fn(),
renameDashboardTab: vi.fn(),
deleteDashboardTab: vi.fn(),
reorderDashboardTabs: vi.fn(),
fetchLayout: vi.fn(), fetchLayout: vi.fn(),
fetchWidgets: vi.fn(), fetchWidgets: vi.fn(),
saveLayout: vi.fn(), saveLayout: vi.fn(),
@@ -25,9 +39,14 @@ import * as api from '@/lib/dashboard-api';
const { useDashboardStore } = await import('./dashboard-store'); const { useDashboardStore } = await import('./dashboard-store');
const EMPTY = { lg: [], md: [], sm: [], xs: [], xxs: [] }; const EMPTY = { lg: [], md: [], sm: [], xs: [], xxs: [] };
const DASH_1 = { id: 'dash-1', name: 'Dashboard', position: 0 };
const DASH_2 = { id: 'dash-2', name: 'Dashboard 2', position: 1 };
beforeEach(() => { beforeEach(() => {
useDashboardStore.setState({ useDashboardStore.setState({
dashboards: [],
activeDashboardId: null,
isSwitchingDashboard: false,
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] }, layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [], widgets: [],
isEditMode: false, isEditMode: false,
@@ -36,7 +55,9 @@ beforeEach(() => {
error: null, error: null,
}); });
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
vi.mocked(api.fetchWidgets).mockResolvedValue([]); vi.mocked(api.fetchWidgets).mockResolvedValue([]);
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.saveLayout).mockResolvedValue(undefined); vi.mocked(api.saveLayout).mockResolvedValue(undefined);
}); });
@@ -45,7 +66,7 @@ afterEach(() => {
}); });
describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => { describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => {
it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT mit Marker gespeichert', async () => { it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
}); });
@@ -57,6 +78,7 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
expect(Object.keys(state.layouts)).not.toContain('__gridVersion'); expect(Object.keys(state.layouts)).not.toContain('__gridVersion');
expect(api.saveLayout).toHaveBeenCalledTimes(1); expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith( expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1',
expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }), expect.objectContaining({ __gridVersion: 2, lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }] }),
); );
expect(state.isDirty).toBe(false); expect(state.isDirty).toBe(false);
@@ -86,7 +108,10 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
expect(useDashboardStore.getState().layouts).toEqual(EMPTY); expect(useDashboardStore.getState().layouts).toEqual(EMPTY);
}); });
it('Test 4: jedes Speichern traegt den Marker (T-BWO-02), der Zustand bleibt markerfrei', async () => { it('Test 4: jedes Speichern traegt den Marker (T-BWO-02) unter der aktiven Reiter-Kennung, der Zustand bleibt markerfrei', async () => {
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.saveLayout).mockClear();
const layouts = { lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] }; const layouts = { lg: [{ i: 'a', x: 2, y: 2, w: 4, h: 4 }], md: [], sm: [], xs: [], xxs: [] };
useDashboardStore.getState().updateLayouts(layouts); useDashboardStore.getState().updateLayouts(layouts);
expect(useDashboardStore.getState().isDirty).toBe(true); expect(useDashboardStore.getState().isDirty).toBe(true);
@@ -94,7 +119,10 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
await useDashboardStore.getState().saveLayout(); await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).toHaveBeenCalledTimes(1); expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith(expect.objectContaining({ __gridVersion: 2, ...layouts })); expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1',
expect.objectContaining({ __gridVersion: 2, ...layouts }),
);
expect(useDashboardStore.getState().isDirty).toBe(false); expect(useDashboardStore.getState().isDirty).toBe(false);
expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion'); expect(Object.keys(useDashboardStore.getState().layouts)).not.toContain('__gridVersion');
}); });
@@ -115,13 +143,180 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
expect(errorSpy).toHaveBeenCalledTimes(1); expect(errorSpy).toHaveBeenCalledTimes(1);
}); });
it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen angelegt', async () => { it('Test 6: neues Widget wird in den neuen (verdoppelten) Vorgabegroessen fuer den aktiven Reiter angelegt', async () => {
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} }); vi.mocked(api.addWidget).mockResolvedValue({ id: 'n1', widgetType: 'clock', config: {} });
await useDashboardStore.getState().addWidget('clock'); await useDashboardStore.getState().addWidget('clock');
const state = useDashboardStore.getState(); const state = useDashboardStore.getState();
expect(api.addWidget).toHaveBeenCalledWith('dash-1', 'clock');
expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 4, h: 4 }); expect(state.layouts.lg).toContainEqual({ i: 'n1', x: 0, y: 0, w: 4, h: 4 });
expect(state.isDirty).toBe(true); expect(state.isDirty).toBe(true);
}); });
}); });
describe('dashboard-store — Reiter laden (quick-260923-ad9, Task 3)', () => {
it('Test 7: holt zuerst die Reiterliste, macht den ERSTEN aktiv und laedt erst danach dessen Kacheln und Anordnung', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
const callOrder: string[] = [];
vi.mocked(api.fetchDashboards).mockImplementation(async () => {
callOrder.push('tabs');
return [DASH_1, DASH_2];
});
vi.mocked(api.fetchLayout).mockImplementation(async () => {
callOrder.push('layout');
return { ...EMPTY };
});
vi.mocked(api.fetchWidgets).mockImplementation(async () => {
callOrder.push('widgets');
return [];
});
await useDashboardStore.getState().loadDashboard();
expect(callOrder[0]).toBe('tabs');
expect(callOrder.slice(1).sort()).toEqual(['layout', 'widgets']);
expect(api.fetchLayout).toHaveBeenCalledWith('dash-1');
expect(api.fetchWidgets).toHaveBeenCalledWith('dash-1');
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1');
expect(useDashboardStore.getState().dashboards).toEqual([DASH_1, DASH_2]);
});
it('Test 8: zweimaliges Aufrufen von loadDashboard hintereinander loest nur EINEN Abruf der Reiterliste aus', async () => {
const first = useDashboardStore.getState().loadDashboard();
const second = useDashboardStore.getState().loadDashboard();
await Promise.all([first, second]);
expect(api.fetchDashboards).toHaveBeenCalledTimes(1);
});
});
describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
await useDashboardStore.getState().loadDashboard();
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] });
vi.mocked(api.saveLayout).mockClear();
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
await useDashboardStore.getState().selectDashboard('dash-2');
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', expect.anything());
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-2');
});
it('Test 10: nach dem Wechsel stehen im Zustand AUSSCHLIESSLICH Kacheln und Anordnung des neuen Reiters', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 2,
});
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
await useDashboardStore.getState().selectDashboard('dash-2');
const state = useDashboardStore.getState();
expect(state.widgets).toEqual([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
expect(state.layouts.lg).toEqual([{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }]);
});
it('Test 11: ein Wechsel auf den bereits aktiven Reiter tut nichts', async () => {
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.fetchLayout).mockClear();
vi.mocked(api.fetchWidgets).mockClear();
await useDashboardStore.getState().selectDashboard('dash-1');
expect(api.fetchLayout).not.toHaveBeenCalled();
expect(api.fetchWidgets).not.toHaveBeenCalled();
});
});
describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-ad9, Task 3)', () => {
it('Test 12: createDashboard haengt den neuen Reiter hinten an, macht ihn aktiv, die Kachelflaeche ist leer', async () => {
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.createDashboardTab).mockResolvedValue(DASH_2);
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
await useDashboardStore.getState().createDashboard();
const state = useDashboardStore.getState();
expect(state.dashboards.map((d) => d.id)).toEqual(['dash-1', 'dash-2']);
expect(state.activeDashboardId).toBe('dash-2');
expect(state.widgets).toEqual([]);
});
it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => {
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.renameDashboardTab).mockResolvedValue({ id: 'dash-1', name: 'Finanzen', position: 0 });
await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen');
expect(useDashboardStore.getState().dashboards[0].name).toBe('Finanzen');
});
it('Test 14: deleteDashboard des AKTIVEN Reiters macht den dann ersten verbleibenden Reiter aktiv und laedt dessen Inhalt', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined);
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
await useDashboardStore.getState().deleteDashboard('dash-1');
const state = useDashboardStore.getState();
expect(state.dashboards.map((d) => d.id)).toEqual(['dash-2']);
expect(state.activeDashboardId).toBe('dash-2');
expect(state.widgets).toEqual([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
});
it('Test 15: deleteDashboard eines INAKTIVEN Reiters aendert den aktiven Reiter nicht', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined);
vi.mocked(api.fetchLayout).mockClear();
await useDashboardStore.getState().deleteDashboard('dash-2');
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1');
expect(useDashboardStore.getState().dashboards.map((d) => d.id)).toEqual(['dash-1']);
expect(api.fetchLayout).not.toHaveBeenCalled();
});
});
describe('dashboard-store — Reiter per Ziehen umsortieren (quick-260923-ad9, Task 4)', () => {
it('Test 16: setzt die neue Reihenfolge SOFORT optimistisch, sendet die vollstaendige Kennungsliste, der aktive Reiter bleibt aktiv', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.reorderDashboardTabs).mockResolvedValue([DASH_2, DASH_1]);
const promise = useDashboardStore.getState().reorderDashboards(['dash-2', 'dash-1']);
// Optimistisch VOR der Antwort des Servers gesetzt.
expect(useDashboardStore.getState().dashboards.map((d) => d.id)).toEqual(['dash-2', 'dash-1']);
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1');
await promise;
expect(api.reorderDashboardTabs).toHaveBeenCalledWith(['dash-2', 'dash-1']);
expect(useDashboardStore.getState().dashboards).toEqual([DASH_2, DASH_1]);
expect(useDashboardStore.getState().activeDashboardId).toBe('dash-1');
});
it('Test 17: schlaegt das Speichern fehl, steht die VORHERIGE Reihenfolge wieder im Zustand', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.reorderDashboardTabs).mockRejectedValue(new Error('PUT failed'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await useDashboardStore.getState().reorderDashboards(['dash-2', 'dash-1']);
expect(useDashboardStore.getState().dashboards).toEqual([DASH_1, DASH_2]);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});
+177 -5
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import * as api from '@/lib/dashboard-api'; import * as api from '@/lib/dashboard-api';
import type { DashboardTab } from '@/lib/dashboard-api';
import type { WidgetType } from '@/components/dashboard/widget-registry'; import type { WidgetType } from '@/components/dashboard/widget-registry';
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry'; import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration'; import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
@@ -11,6 +12,9 @@ export interface WidgetInstance {
} }
interface DashboardState { interface DashboardState {
dashboards: DashboardTab[];
activeDashboardId: string | null;
isSwitchingDashboard: boolean;
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>; layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
widgets: WidgetInstance[]; widgets: WidgetInstance[];
isEditMode: boolean; isEditMode: boolean;
@@ -24,6 +28,34 @@ interface DashboardState {
removeWidget: (id: string) => Promise<void>; removeWidget: (id: string) => Promise<void>;
loadDashboard: () => Promise<void>; loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>; saveLayout: () => Promise<void>;
selectDashboard: (id: string) => Promise<void>;
createDashboard: () => Promise<void>;
renameDashboard: (id: string, name: string) => Promise<void>;
deleteDashboard: (id: string) => Promise<void>;
reorderDashboards: (ids: string[]) => Promise<void>;
}
/**
* Schutz gegen doppeltes Laden der Reiterliste (quick-260923-ad9, Task 3,
* T-AD9-07 — Gegenstück zur Transaktionssperre in `listDashboards` auf der
* API): ein modul-globales Versprechen statt eines Zustandsfeldes, damit
* ZWEI synchron hintereinander ausgeloeste `loadDashboard()`-Aufrufe (z. B.
* React StrictMode, doppeltes Einhaengen) sich dasselbe, noch nicht
* aufgeloeste Versprechen teilen — `api.fetchDashboards()` laeuft dann nur
* EINMAL. Nach Abschluss (Erfolg ODER Fehler) wird die Sperre zurueckgesetzt,
* damit ein spaeteres, echtes Neuladen (Seite erneut besucht, langer
* Zeitabstand) wieder ein frisches Netzwerkergebnis holt statt fuer immer
* auf dem ersten Ergebnis zu bleiben.
*/
let dashboardsLoadPromise: Promise<DashboardTab[]> | null = null;
function loadDashboardsOnce(): Promise<DashboardTab[]> {
if (!dashboardsLoadPromise) {
dashboardsLoadPromise = api.fetchDashboards().finally(() => {
dashboardsLoadPromise = null;
});
}
return dashboardsLoadPromise;
} }
/** /**
@@ -37,8 +69,19 @@ interface DashboardState {
* ueber die Breakpoints). Fehlt der Marker beim Speichern, wird beim naechsten * ueber die Breakpoints). Fehlt der Marker beim Speichern, wird beim naechsten
* Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen * Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen
* (Sofort-Speichern nach der Umrechnung und `saveLayout`), T-BWO-02. * (Sofort-Speichern nach der Umrechnung und `saveLayout`), T-BWO-02.
*
* quick-260923-ad9 (Task 3): Reiter — mehrere Dashboards je Benutzer. Der
* erste (`position` aufsteigend) ist der Standard und wird beim Laden aktiv.
* `layouts`/`widgets` gehoeren IMMER zum `activeDashboardId` — ein
* Reiterwechsel ersetzt beide vollstaendig, nie ein Zusammenfuehren. Die
* Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard`
* durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie
* `loadDashboard`.
*/ */
export const useDashboardStore = create<DashboardState>()((set, get) => ({ export const useDashboardStore = create<DashboardState>()((set, get) => ({
dashboards: [],
activeDashboardId: null,
isSwitchingDashboard: false,
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] }, layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [], widgets: [],
isEditMode: false, isEditMode: false,
@@ -63,8 +106,10 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
}, },
addWidget: async (widgetType: WidgetType) => { addWidget: async (widgetType: WidgetType) => {
const dashboardId = get().activeDashboardId;
if (!dashboardId) return;
try { try {
const newWidget = await api.addWidget(widgetType); const newWidget = await api.addWidget(dashboardId, widgetType);
const constraints = WIDGET_CONSTRAINTS[widgetType]; const constraints = WIDGET_CONSTRAINTS[widgetType];
set((state) => { set((state) => {
@@ -117,12 +162,21 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
loadDashboard: async () => { loadDashboard: async () => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const dashboards = await loadDashboardsOnce();
const first = dashboards[0];
if (!first) {
set({ error: 'Failed to load dashboard', isLoading: false });
return;
}
const [rawLayouts, widgets] = await Promise.all([ const [rawLayouts, widgets] = await Promise.all([
api.fetchLayout(), api.fetchLayout(first.id),
api.fetchWidgets(), api.fetchWidgets(first.id),
]); ]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts); const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
set({ set({
dashboards,
activeDashboardId: first.id,
layouts: migratedLayouts, layouts: migratedLayouts,
widgets, widgets,
isLoading: false, isLoading: false,
@@ -132,7 +186,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint. // try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
if (migrated) { if (migrated) {
try { try {
await api.saveLayout(withGridVersion(migratedLayouts)); await api.saveLayout(first.id, withGridVersion(migratedLayouts));
} catch (err) { } catch (err) {
console.error('Failed to persist migrated layout:', err); console.error('Failed to persist migrated layout:', err);
} }
@@ -146,11 +200,129 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
}, },
saveLayout: async () => { saveLayout: async () => {
const dashboardId = get().activeDashboardId;
if (!dashboardId) return;
try { try {
await api.saveLayout(withGridVersion(get().layouts)); await api.saveLayout(dashboardId, withGridVersion(get().layouts));
set({ isDirty: false }); set({ isDirty: false });
} catch (err) { } catch (err) {
console.error('Failed to save layout:', err); console.error('Failed to save layout:', err);
} }
}, },
/**
* Wechselt den aktiven Reiter (quick-260923-ad9, Task 3). Eine
* ungespeicherte Anordnung wird ZUERST fuer den ALTEN Reiter geschrieben
* (`get().saveLayout()` liest `activeDashboardId` VOR dem `set` unten,
* schreibt also unter der Kennung des alten Reiters) — erst danach werden
* Kacheln und Anordnung des neuen Reiters geladen und ersetzen den
* Zustand VOLLSTAENDIG (kein Zusammenfuehren).
*/
selectDashboard: async (id: string) => {
const state = get();
if (id === state.activeDashboardId) return;
if (state.isDirty) {
await get().saveLayout();
}
set({ isSwitchingDashboard: true, error: null });
try {
const [rawLayouts, widgets] = await Promise.all([
api.fetchLayout(id),
api.fetchWidgets(id),
]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
set({
activeDashboardId: id,
layouts: migratedLayouts,
widgets,
isDirty: false,
isSwitchingDashboard: false,
});
if (migrated) {
try {
await api.saveLayout(id, withGridVersion(migratedLayouts));
} catch (err) {
console.error('Failed to persist migrated layout:', err);
}
}
} catch (err) {
console.error('Failed to switch dashboard:', err);
set({ isSwitchingDashboard: false, error: 'Failed to switch dashboard' });
}
},
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
createDashboard: async () => {
try {
const created = await api.createDashboardTab();
set((state) => ({ dashboards: [...state.dashboards, created] }));
await get().selectDashboard(created.id);
} catch (err) {
console.error('Failed to create dashboard:', err);
}
},
renameDashboard: async (id: string, name: string) => {
try {
const updated = await api.renameDashboardTab(id, name);
set((state) => ({
dashboards: state.dashboards.map((d) => (d.id === id ? updated : d)),
}));
} catch (err) {
console.error('Failed to rename dashboard:', err);
}
},
/**
* Loescht einen Reiter. Beim aktiven Reiter wird danach der dann erste
* verbleibende Reiter aktiv und sein Inhalt geladen (`selectDashboard`).
* Die verbleibende Liste wird lokal gefiltert statt neu geladen — die vom
* Server neu vergebenen `position`-Werte werden dabei nicht nachgezogen;
* das ist fuer die Reihenfolge in der Leiste unschaedlich (sie filtert nur,
* sortiert nicht neu), ein spaeteres Umsortieren sendet ohnehin die
* VOLLSTAENDIGE Kennungsliste, nicht die Positionszahlen.
*/
deleteDashboard: async (id: string) => {
try {
await api.deleteDashboardTab(id);
const remaining = get().dashboards.filter((d) => d.id !== id);
set({ dashboards: remaining });
if (get().activeDashboardId === id) {
const next = remaining[0];
if (next) {
set({ activeDashboardId: null });
await get().selectDashboard(next.id);
}
}
} catch (err) {
console.error('Failed to delete dashboard:', err);
}
},
/**
* Persistiert eine per Ziehen bestimmte Reihenfolge (quick-260923-ad9,
* Task 4). Setzt die neue Reihenfolge SOFORT im Zustand (optimistisch —
* die Leiste in `dashboard-tabs.tsx` zeigt schon ihre eigene Vorschau
* waehrend des Ziehens, dieses `set` uebernimmt sie beim Loslassen als
* Tatsache) und stellt bei einem fehlgeschlagenen Speichern die VORHERIGE
* Reihenfolge wieder her. Der aktive Reiter bleibt aktiv, auch wenn er
* seine Position wechselt — `activeDashboardId` bleibt unberuehrt.
*/
reorderDashboards: async (ids: string[]) => {
const previous = get().dashboards;
const byId = new Map(previous.map((d) => [d.id, d]));
const optimistic = ids.map((id) => byId.get(id)).filter((d): d is DashboardTab => d !== undefined);
set({ dashboards: optimistic });
try {
const updated = await api.reorderDashboardTabs(ids);
set({ dashboards: updated });
} catch (err) {
console.error('Failed to reorder dashboards:', err);
set({ dashboards: previous });
}
},
})); }));
+11
View File
@@ -214,6 +214,17 @@
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.", "layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
"widgetSaveError": "Änderungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.", "widgetSaveError": "Änderungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"unavailable": "Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.", "unavailable": "Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.",
"tabs": {
"navLabel": "Dashboard-Reiter",
"add": "Dashboard hinzufügen",
"switching": "Dashboard wird gewechselt …",
"renameButtonLabel": "Dashboard umbenennen",
"renameInputLabel": "Name des Dashboards",
"deleteButtonLabel": "Dashboard löschen",
"deleteDialogTitle": "Dashboard löschen",
"deleteDialogBody": "Möchten Sie das Dashboard „{name}“ wirklich löschen? Seine Kacheln und seine Anordnung werden mit gelöscht.",
"dragHint": "Ziehen Sie einen Reiter mit der Maus an eine andere Stelle. Der erste Reiter wird beim Öffnen geladen."
},
"clock": { "clock": {
"name": "Uhr", "name": "Uhr",
"description": "Zeigt die aktuelle Uhrzeit an", "description": "Zeigt die aktuelle Uhrzeit an",
+11
View File
@@ -214,6 +214,17 @@
"layoutLoadError": "Could not load dashboard. Please reload the page.", "layoutLoadError": "Could not load dashboard. Please reload the page.",
"widgetSaveError": "Could not save changes. Please try again.", "widgetSaveError": "Could not save changes. Please try again.",
"unavailable": "This tile is not available — the module it belongs to is not enabled for you.", "unavailable": "This tile is not available — the module it belongs to is not enabled for you.",
"tabs": {
"navLabel": "Dashboard tabs",
"add": "Add dashboard",
"switching": "Switching dashboard …",
"renameButtonLabel": "Rename dashboard",
"renameInputLabel": "Dashboard name",
"deleteButtonLabel": "Delete dashboard",
"deleteDialogTitle": "Delete dashboard",
"deleteDialogBody": "Do you really want to delete the dashboard \"{name}\"? Its tiles and layout will be deleted as well.",
"dragHint": "Drag a tab to a different position with your mouse. The first tab loads when you open the dashboard."
},
"clock": { "clock": {
"name": "Clock", "name": "Clock",
"description": "Shows the current time", "description": "Shows the current time",
+5
View File
@@ -60,6 +60,11 @@ Unten in der Seitenleiste finden Sie die Sprachumschaltung (Deutsch/English) sow
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets**. Ist noch kein Widget platziert, sehen Sie nur das Tessera-Symbol mit dem Hinweis „Keine Widgets aktiv". Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets**. Ist noch kein Widget platziert, sehen Sie nur das Tessera-Symbol mit dem Hinweis „Keine Widgets aktiv".
**Mehrere Dashboards (Reiter):** Über dem Raster steht eine Reiterleiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).
- Einen Reiter löschen: Klicken Sie auf das Kreuz daneben und bestätigen Sie die Rückfrage — seine Kacheln und seine Anordnung werden dabei mit gelöscht. Der letzte verbleibende Reiter lässt sich nicht löschen, der Knopf dafür erscheint dort gar nicht erst.
**Widgets hinzufügen und anordnen:** Unten rechts auf dem Dashboard schwebt der Stift-Schalter **„Dashboard bearbeiten"**; im Bearbeitungsmodus wird daraus ein Häkchen **„Änderungen speichern"**, und daneben erscheint **„Widget hinzufügen"**. Sobald der Bearbeitungsmodus aktiv ist: **Widgets hinzufügen und anordnen:** Unten rechts auf dem Dashboard schwebt der Stift-Schalter **„Dashboard bearbeiten"**; im Bearbeitungsmodus wird daraus ein Häkchen **„Änderungen speichern"**, und daneben erscheint **„Widget hinzufügen"**. Sobald der Bearbeitungsmodus aktiv ist:
- Erscheint der Button **„Widget hinzufügen"**, der eine Auswahl aller verfügbaren Widget-Typen als Kachel-Katalog öffnet. Ein Klick auf einen Eintrag fügt das Widget sofort dem Dashboard hinzu. - Erscheint der Button **„Widget hinzufügen"**, der eine Auswahl aller verfügbaren Widget-Typen als Kachel-Katalog öffnet. Ein Klick auf einen Eintrag fügt das Widget sofort dem Dashboard hinzu.
- Können Sie bestehende Widgets per Ziehen an eine neue Position verschieben. Fassen Sie die Kachel dazu an einer beliebigen Stelle an — Eingabefelder, Knöpfe und Links ausgenommen; ein grauer Griff am oberen Kachelrand zeigt, dass die Kachel beweglich ist. Abgelegt wird nur dort, wo Platz ist: über einer anderen Kachel springt sie an ihren Ausgangspunkt zurück. - Können Sie bestehende Widgets per Ziehen an eine neue Position verschieben. Fassen Sie die Kachel dazu an einer beliebigen Stelle an — Eingabefelder, Knöpfe und Links ausgenommen; ein grauer Griff am oberen Kachelrand zeigt, dass die Kachel beweglich ist. Abgelegt wird nur dort, wo Platz ist: über einer anderen Kachel springt sie an ihren Ausgangspunkt zurück.
@@ -168,16 +168,16 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen | | dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) | | user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) | | module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
| dashboard | 1 | 21 | 1 | **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) | | dashboard | 1 | 28 | 1 | **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) | | auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg | | calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet | | tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile | | favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff | | bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | | settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
| **Summe** | **61** | **190** | **6** | **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | | **Summe** | **61** | **197** | **6** | **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare) ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 75 Paare)
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar (260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
@@ -201,6 +201,19 @@ muss-mandantengebunden) war in der Tabelle eingetragen, in dieser
Verteilung aber nie mitgezaehlt. Beide Korrekturen (72→74, 35→37) sind Verteilung aber nie mitgezaehlt. Beide Korrekturen (72→74, 35→37) sind
gemessen, nicht geschaetzt. gemessen, nicht geschaetzt.
**Nachtrag 260923-ad9 (Task 1, Dashboard-Reiter):** 75 Paare — ein neues
Paar `dashboard/dashboard.service.ts`/`dashboard` (muss-mandantengebunden,
gebunden) fuer die neue Reitertabelle (`listDashboards`,
`assertOwnedDashboard`). Nachgezaehlt mit `grep -cE "^\| apps/api/src/"
docs/mandantentrennung-zugriffsklassifikation.md` ueber die
Fundstellentabelle: vor diesem Eintrag standen dort 74 Zeilen. Die
Uebersichtszeile `dashboard` und die Summenzeile unten sind mit derselben
Gate-Schleife nachgerechnet (Rohtreffer, nicht Paare): `dashboard.service.ts`
traegt jetzt drei zusaetzliche gebundene `tenantPrisma.dashboard.`-Rohtreffer
(zwei `findMany` in `listDashboards`, ein `findUnique` in
`assertOwnedDashboard`) — Bereich `dashboard` 21→24 gebunden, Summe
190→193 gebunden, ungebunden und System unveraendert.
**Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle **Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle
hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben: hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben:
`tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von `tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von
@@ -329,11 +342,11 @@ entnommen (30 Zusicherungen, darunter der Wachhund
| Klasse | Anzahl Paare | | Klasse | Anzahl Paare |
|---|---| |---|---|
| muss-mandantengebunden | 37 | | muss-mandantengebunden | 38 |
| keine-mandantengebundene-tabelle | 21 | | keine-mandantengebundene-tabelle | 21 |
| beides | 14 | | beides | 14 |
| bewusst-uebergreifend | 2 | | bewusst-uebergreifend | 2 |
| **Summe** | **74** | | **Summe** | **75** |
## Der Hintergrunddienst als Falle — sechs Fälle ## Der Hintergrunddienst als Falle — sechs Fälle
@@ -674,10 +687,11 @@ werden.
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. | | apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). | | apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. | | apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. |
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. | | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). | | apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Kacheln eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). quick-260923-ad9 (Task 1): `getWidgets`/`addWidget` filtern/schreiben ueber `dashboardId` statt `userId` (D-02) — `assertOwnedDashboard` prueft vorher, dass der Reiter dem Aufrufer gehoert. |
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. | | apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | system-gebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Seit 260914-eym liest der Planer-Startpfad `loadActiveConfigsForScheduler()` ueber `forSystem()` (alle aktiven Konfigurationen, nur lesend, `system_read_policy`) — kein ungebundener Zugriff mehr, WINDOWS #21 geschlossen; alle uebrigen Zugriffe bleiben mandantengebunden (Stand-Vorrang: system ohne ungebunden = `system-gebunden`). | | apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | system-gebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Seit 260914-eym liest der Planer-Startpfad `loadActiveConfigsForScheduler()` ueber `forSystem()` (alle aktiven Konfigurationen, nur lesend, `system_read_policy`) — kein ungebundener Zugriff mehr, WINDOWS #21 geschlossen; alle uebrigen Zugriffe bleiben mandantengebunden (Stand-Vorrang: system ohne ungebunden = `system-gebunden`). |
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). | | apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |