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
+27 -9
View File
@@ -8,6 +8,7 @@ import {
Patch,
Post,
Put,
Query,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
@@ -25,10 +26,11 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
* and scopes all operations to the calling user (T-05-01, T-05-02).
*
* Routes:
* - GET /dashboard/layout — get user's saved layout
* - PUT /dashboard/layout — upsert user's layout
* - GET /dashboard/widgets — list user's widget instances
* - POST /dashboard/widgets — create a new widget instance
* - GET /dashboard/tabs — list the user's dashboard tabs (quick-260923-ad9)
* - 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
* - DELETE /dashboard/widgets/:id — remove a widget instance
* - GET /dashboard/search-providers — list default + user's custom providers
@@ -66,10 +68,23 @@ export class DashboardController {
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);
return this.dashboardService.getLayout(userId, tenantId);
return this.dashboardService.listDashboards(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')
@@ -79,9 +94,12 @@ export class DashboardController {
}
@Get('widgets')
async getWidgets(@Req() req: AuthenticatedRequest) {
async getWidgets(
@Req() req: AuthenticatedRequest,
@Query('dashboardId') dashboardId: string,
) {
const { userId, tenantId, role } = this.extractContext(req);
return this.dashboardService.getWidgets(userId, tenantId, role);
return this.dashboardService.getWidgets(userId, tenantId, role, dashboardId);
}
@Post('widgets')
+295 -42
View File
@@ -30,12 +30,20 @@ vi.mock('./widget-module-map', () => ({
* ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus
* `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine
* 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', () => ({
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 { ConflictException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardService } from './dashboard.service';
@@ -43,14 +51,42 @@ import { DashboardService } from './dashboard.service';
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
* ergaenzt seit Aufgabe 3 (260910-krx). */
const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance', 'searchProvider'];
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
* 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(
overrides: Partial<{
id: string;
userId: string;
tenantId: string;
dashboardId: string;
widgetType: string;
config: Record<string, unknown>;
createdAt: Date;
@@ -60,6 +96,7 @@ function makeWidget(
id: overrides.id ?? 'w1',
userId: overrides.userId ?? 'user-1',
tenantId: overrides.tenantId ?? 'tenant-1',
dashboardId: overrides.dashboardId ?? DASH_1,
widgetType: overrides.widgetType ?? 'clock',
config: overrides.config ?? {},
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
@@ -92,35 +129,67 @@ function makeFakePrisma(
opts: {
widgets?: ReturnType<typeof makeWidget>[];
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>[];
dashboards?: ReturnType<typeof makeDashboard>[];
} = {},
) {
const widgets = opts.widgets ?? [];
const modules = opts.modules ?? [];
let layoutRow = opts.layout ?? null;
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 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;
}),
},
dashboardLayout: {
findUnique: vi.fn(async ({ where }: any) => {
if (layoutRow && layoutRow.userId === where.userId) return layoutRow;
if (layoutRow && layoutRow.dashboardId === where.dashboardId) return layoutRow;
return null;
}),
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 };
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;
}),
},
widgetInstance: {
findMany: vi.fn(async ({ where }: any) => {
return widgets
.filter((w) => w.userId === where.userId)
.filter((w) => w.dashboardId === where.dashboardId)
.slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}),
@@ -188,8 +257,16 @@ function makeFakePrisma(
}
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;
},
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
return fn(fake.__makeBoundClient(tenantId));
},
};
return fake;
@@ -245,7 +322,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
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);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual(widgets);
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
@@ -261,7 +338,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
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]);
});
@@ -276,7 +353,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
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);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual([]);
});
@@ -287,12 +364,13 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const prisma = makeFakePrisma({
widgets: [widget],
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.
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
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(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith(
@@ -313,7 +391,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set()); // kein Zugriff auf tender-radar
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]);
});
@@ -334,7 +412,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
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']);
});
@@ -349,8 +427,8 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set());
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);
await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(prisma.widgetInstance.delete).not.toHaveBeenCalled();
expect(prisma.widgetInstance.findMany).toHaveBeenCalledTimes(2);
@@ -363,7 +441,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set(['irgendeine-id']));
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([]);
});
@@ -379,15 +457,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 () => {
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 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' }] });
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1');
});
@@ -397,7 +476,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const moduleAccessService = makeFakeModuleAccessService(new Set());
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: [] });
});
@@ -407,17 +486,18 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const moduleAccessService = makeFakeModuleAccessService(new Set());
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', 'dashboard', 'findUnique');
});
/**
* Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.userId` ist
* plattformweit eindeutig, ohne Mandantenanteil. Ist die vorhandene Zeile
* unter dem gebundenen Kontext unsichtbar, laeuft das `upsert` in einen
* Konflikt — und der aeussert sich hier NICHT als der bekannte P2002-Fehler
* (`PrismaClientKnownRequestError`), sondern als
* Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.dashboardId`
* (bis quick-260923-ad9: `userId`) ist die eindeutige Spalte. Ist die
* vorhandene Zeile unter dem gebundenen Kontext unsichtbar, laeuft das
* `upsert` in einen Konflikt — und der aeussert sich hier NICHT als der
* bekannte P2002-Fehler (`PrismaClientKnownRequestError`), sondern als
* `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den
* Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit
* ueberhaupt geprueft wird. Gemessen in `rls-scratch-check.mjs`
@@ -440,7 +520,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const service = new DashboardService(prisma as any, moduleAccessService as any);
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);
});
@@ -457,20 +537,20 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const service = new DashboardService(prisma as any, moduleAccessService as any);
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);
});
});
it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => {
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 service = new DashboardService(prisma as any, moduleAccessService as any);
await service.getLayout('user-1', 'tenant-1');
await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any);
await service.getLayout('user-1', 'tenant-1', DASH_1);
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', 'upsert');
@@ -481,7 +561,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
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);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual([]);
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany');
@@ -493,9 +573,10 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const moduleAccessService = makeFakeModuleAccessService(new Set());
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', 'dashboard', 'findUnique');
});
it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => {
@@ -548,16 +629,16 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
const prisma = makeFakePrisma({
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 service = new DashboardService(prisma as any, moduleAccessService as any);
for (const call of [
() => service.getLayout('user-1', 'tenant-1'),
() => service.saveLayout('user-1', 'tenant-1', { layouts: {} } as any),
() => service.getWidgets('user-1', 'tenant-1', 'USER' as any),
() => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any),
() => service.getLayout('user-1', 'tenant-1', DASH_1),
() => service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any),
() => service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1),
() => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any),
() => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any),
() => service.removeWidget('w1', 'user-1', 'tenant-1'),
]) {
@@ -575,7 +656,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
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);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(result).toEqual([]);
});
@@ -593,9 +674,9 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
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 };
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 as Record<string, unknown>).__gridVersion).toBe(2);
@@ -623,6 +704,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) ---------
describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => {
@@ -710,7 +963,7 @@ describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog be
name: 'Intranet',
urlTemplate: 'https://intranet.test/?q={query}',
} 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
// Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren
+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,
},
@@ -2,7 +2,7 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
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
* `@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
* 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.
*/
export class CreateWidgetDto {
@IsString()
dashboardId!: string;
@IsString()
@IsIn([...WIDGET_TYPES])
widgetType!: 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
* (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 {
@IsString()
dashboardId!: string;
@IsObject()
layouts!: Record<string, unknown>;
}
@@ -39,8 +39,11 @@ describe('widget-module-map (quick-260922-m1h)', () => {
* ablehnen zu lassen.
*/
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) {
const dto = plainToInstance(CreateWidgetDto, { widgetType });
const dto = plainToInstance(CreateWidgetDto, { widgetType, dashboardId: 'dash-1' });
return validate(dto);
}