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