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:
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user