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
+111 -26
View File
@@ -5,7 +5,7 @@ import {
} from '@nestjs/common';
import { Prisma, Role } from '@prisma/client';
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 { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto';
@@ -88,13 +88,88 @@ export class DashboardService {
) {}
/**
* Returns the user's saved layout, or a default empty layout
* with all breakpoint arrays initialized.
* Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
* 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 record = await tenantPrisma.dashboardLayout.findUnique({
let dashboards = await tenantPrisma.dashboard.findMany({
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`);
}
}
/**
* 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) {
@@ -105,32 +180,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.
*
* `userId` is platform-wide `@unique` (no tenant component) — a tenant
* whose user id was, by hand, moved off its actually-visible row could hit
* an `upsert` conflict on a row it cannot see under RLS. Measured
* (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row
* throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known
* error that the `tenders` area's translation pattern catches — this is a
* quick-260923-ad9 (D-02): scoped by `dto.dashboardId` instead of
* `userId` — `assertOwnedDashboard` runs first, over the SAME bound
* client. `dashboardId` is now the `@unique` column on `DashboardLayout`
* (was `userId` before this plan).
*
* 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
* signal is the raw `.message` text). Translated below into an
* understandable German message instead of a raw 500, same intent as
* `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.
* signal is the raw `.message` text) — measured 260910-krx, Aufgabe 1,
* translation kept unchanged from before this plan.
*/
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
try {
return await tenantPrisma.dashboardLayout.upsert({
where: { userId },
where: { dashboardId: dto.dashboardId },
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
create: {
userId,
tenantId,
dashboardId: dto.dashboardId,
layouts: dto.layouts as unknown as Prisma.InputJsonValue,
},
});
@@ -145,12 +221,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).
*
* Die bestehende Query bleibt unverändert die erste Aktion. Steht unter
* den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — der
* Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die
* quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
* `assertOwnedDashboard` runs first, over the SAME bound client. Steht
* 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
* mindestens einem modulgebundenen Widget wird die Zugriffsauflösung
* aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und
@@ -158,10 +235,12 @@ export class DashboardService {
* Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das
* 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);
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
const widgets = await tenantPrisma.widgetInstance.findMany({
where: { userId },
where: { dashboardId },
orderBy: { createdAt: 'asc' },
});
@@ -207,14 +286,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) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
return tenantPrisma.widgetInstance.create({
data: {
userId,
tenantId,
dashboardId: dto.dashboardId,
widgetType: dto.widgetType,
config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue,
},