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>
This commit is contained in:
2026-09-23 07:56:38 +02:00
parent 84fe73e16a
commit 9c518238f5
9 changed files with 627 additions and 94 deletions
@@ -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])
}
model DashboardLayout {
id String @id @default(uuid())
userId String @unique
// Dashboard-Reiter (quick-260923-ad9, D-01/D-02/D-09): mehrere Dashboards je
// Benutzer, ueber `position` (Integer) aufsteigend sortiert. KEIN Unique auf
// (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
layouts Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
position Int
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])
}
@@ -202,6 +233,10 @@ model WidgetInstance {
id String @id @default(uuid())
userId 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
config Json @default("{}")
createdAt DateTime @default(now())
@@ -210,6 +245,7 @@ model WidgetInstance {
@@index([userId])
@@index([tenantId])
@@index([dashboardId])
}
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.