feat(quick-260923-ad9): Datenmodell, Migration und Reiter-Grundlage - Task 1

Neues Modell Dashboard (D-01/D-02/D-09): position statt Standard-Feld,
kein Unique auf (userId, position) - Umsortieren schreibt spaeter alle
Positionen einer Transaktion neu. WidgetInstance/DashboardLayout haengen
jetzt am Reiter statt am Benutzer (DashboardLayout.dashboardId @unique
ersetzt userId @unique).

Migration 20260923120000_dashboard_tabs: Zeilenschutz mit Mandant- UND
Benutzerdimension (Form 20260911120000/20260921120000), Bestands-
uebernahme fuer jeden Benutzer mit Kacheln oder Anordnung VOR den
Fremdschluesseln (D-03) - gemessen: 0 Kacheln/Anordnungen ohne Reiter,
genau 2 Reiter auf Position 0.

dashboard.service.ts: listDashboards() (Transaktionssperre gegen
doppelte Erstanlage, T-AD9-07), Riegel assertOwnedDashboard() (fail-
closed gegen fremde Reiter, T-AD9-01/02/03) - getLayout/saveLayout/
getWidgets/addWidget laufen jetzt ueber dashboardId statt userId.
GET /dashboard/tabs neu; die vier bestehenden Wege reichen die Reiter-
Kennung durch. Verhalten fuer den Benutzer unveraendert (ein Reiter,
wie bisher) - Task 2 ergaenzt Anlegen/Umbenennen/Loeschen/Umsortieren.

dashboard.service.spec.ts: 43 Tests (31 alte unveraendert + 12 neue fuer
Reiter-Anlage, -Reihenfolge und den Fremdreiter-Riegel bei allen vier
Wegen). Zugriffsklassifikation nachgerechnet: 75 Paare (+1), Bereich
dashboard 21->24 gebunden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 07:56:38 +02:00
parent 84fe73e16a
commit 9c518238f5
9 changed files with 627 additions and 94 deletions
@@ -0,0 +1,113 @@
-- 260923-ad9 — Dashboard-Reiter: mehrere Dashboards je Benutzer.
--
-- Zweck: das Dashboard traegt heute genau eine Kachelflaeche je Benutzer.
-- Diese Migration gibt jedem Benutzer mehrere Dashboards ("Reiter"), die
-- oben nebeneinander stehen: jeder Reiter mit eigenen Kacheln und eigener
-- Anordnung, per Ziehen umsortierbar.
--
-- D-01: `position` (Integer) traegt die Reihenfolge, aufsteigend sortiert.
-- KEIN Unique auf (userId, position) — beim Umsortieren werden alle
-- Positionen eines Benutzers in EINER Transaktion neu geschrieben
-- (dashboard.service.ts, reorderDashboards, Muster FavoritesService.reorder);
-- ein Unique waere dabei nur im Weg.
--
-- D-03: niemand verliert etwas. Fuer jeden Benutzer, der heute Kacheln ODER
-- eine gespeicherte Anordnung hat, entsteht genau EIN Dashboard mit
-- position = 0 und dem Namen "Dashboard"; vorhandene Kacheln und die
-- vorhandene Anordnung werden darauf umgehaengt. Diese Bestandsuebernahme
-- MUSS vor den Fremdschluesseln laufen, sonst scheitert sie an genau diesen
-- — deshalb steht sie unten vor den ALTER-TABLE-Schritten fuer
-- WidgetInstance/DashboardLayout.
--
-- D-04: Zeilenschutz ist Pflicht. Die neue Tabelle traegt `tenantId` und
-- dieselbe Regel wie ihre Nachbarn — Mandant UND Benutzerdimension von
-- Anfang an (Form aus 20260911120000_rls_user_dimension_personal_tables,
-- uebernommen aus 20260921120000_dashboard_image).
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen ueber ALTER DEFAULT
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirkt die Regel erst, wenn
-- die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS,
-- siehe docs/mandantentrennung-datenbankrolle.md).
-- 1) Tabelle Dashboard anlegen, Indizes auf userId und tenantId.
CREATE TABLE "Dashboard" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Dashboard_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Dashboard_userId_idx" ON "Dashboard"("userId");
CREATE INDEX "Dashboard_tenantId_idx" ON "Dashboard"("tenantId");
-- 2) Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000/20260921120000).
ALTER TABLE "Dashboard" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Dashboard" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "Dashboard"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
-- 3) Bestandsuebernahme (D-03): je Benutzer aus der Vereinigung der
-- Benutzer mit Kacheln und der Benutzer mit gespeicherter Anordnung genau
-- EINE Zeile einfuegen. DISTINCT ON sichert "je Benutzer genau eine Zeile"
-- auch fuer den theoretischen Fall "derselbe Benutzer mit zwei
-- Mandantenkennungen" ab (deterministische Wahl ueber die Sortierung nach
-- tenantId als zweitem Kriterium).
INSERT INTO "Dashboard" ("id", "userId", "tenantId", "name", "position", "createdAt", "updatedAt")
SELECT gen_random_uuid(), bestand."userId", bestand."tenantId", 'Dashboard', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (
SELECT DISTINCT ON ("userId") "userId", "tenantId"
FROM (
SELECT "userId", "tenantId" FROM "WidgetInstance"
UNION ALL
SELECT "userId", "tenantId" FROM "DashboardLayout"
) AS vereinigung
ORDER BY "userId", "tenantId"
) AS bestand;
-- 4) WidgetInstance.dashboardId: zunaechst NULLbar ergaenzen, aus der neuen
-- Tabelle ueber die Benutzerkennung befuellen (fuer jeden Benutzer mit
-- Kacheln existiert nach Schritt 3 GENAU ein Dashboard), dann NOT NULL,
-- Index, Fremdschluessel mit Loeschweitergabe.
ALTER TABLE "WidgetInstance" ADD COLUMN "dashboardId" TEXT;
UPDATE "WidgetInstance" wi
SET "dashboardId" = d."id"
FROM "Dashboard" d
WHERE d."userId" = wi."userId";
ALTER TABLE "WidgetInstance" ALTER COLUMN "dashboardId" SET NOT NULL;
CREATE INDEX "WidgetInstance_dashboardId_idx" ON "WidgetInstance"("dashboardId");
ALTER TABLE "WidgetInstance" ADD CONSTRAINT "WidgetInstance_dashboardId_fkey"
FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 5) DashboardLayout.dashboardId: dieselbe Uebernahme; zusaetzlich die
-- Eindeutigkeit auf userId entfernen (mehrere Reiter je Benutzer sind jetzt
-- erlaubt), dort einen gewoehnlichen Index anlegen, und die Eindeutigkeit
-- auf dashboardId anlegen (ein Reiter hat hoechstens eine gespeicherte
-- Anordnung).
ALTER TABLE "DashboardLayout" ADD COLUMN "dashboardId" TEXT;
UPDATE "DashboardLayout" dl
SET "dashboardId" = d."id"
FROM "Dashboard" d
WHERE d."userId" = dl."userId";
ALTER TABLE "DashboardLayout" ALTER COLUMN "dashboardId" SET NOT NULL;
DROP INDEX "DashboardLayout_userId_key";
CREATE INDEX "DashboardLayout_userId_idx" ON "DashboardLayout"("userId");
CREATE UNIQUE INDEX "DashboardLayout_dashboardId_key" ON "DashboardLayout"("dashboardId");
ALTER TABLE "DashboardLayout" ADD CONSTRAINT "DashboardLayout_dashboardId_fkey"
FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+37 -1
View File
@@ -187,14 +187,45 @@ model ModuleGrant {
@@index([moduleId]) @@index([moduleId])
} }
// Dashboard-Reiter (quick-260923-ad9, D-01/D-02/D-09): mehrere Dashboards je
// Benutzer, ueber `position` (Integer) aufsteigend sortiert. KEIN Unique auf
// (userId, position) — `reorderDashboards` (Muster FavoritesService.reorder)
// schreibt beim Umsortieren ALLE Positionen eines Benutzers in EINER
// Transaktion neu; ein Unique waere dabei nur im Weg (kollidiert waehrend
// des Umschreibens mit sich selbst). KEIN eigenes Standard-Feld: "als
// Favorit festlegen" IST das Nach-vorn-Ziehen (D-09) — Position 0 ist der
// Standard, es gibt keine zweite Wahrheit daneben. Keine Relation zu
// User/Tenant — Form der Nachbarmodelle WidgetInstance/DashboardImage (eine
// Relation zu User wuerde an Bestandszeilen verwaister Benutzer scheitern).
model Dashboard {
id String @id @default(uuid())
userId String
tenantId String
name String
position Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
widgets WidgetInstance[]
layout DashboardLayout?
@@index([userId])
@@index([tenantId])
}
model DashboardLayout { model DashboardLayout {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @unique userId String
tenantId String tenantId String
// quick-260923-ad9 (D-02): haengt jetzt am Dashboard statt am Benutzer —
// die Eindeutigkeit wandert von userId auf dashboardId, userId/tenantId
// bleiben fuer Besitz- und Mandantenpruefung erhalten.
dashboardId String @unique
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
layouts Json @default("{}") layouts Json @default("{}")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([userId])
@@index([tenantId]) @@index([tenantId])
} }
@@ -202,6 +233,10 @@ model WidgetInstance {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
tenantId String tenantId String
// quick-260923-ad9 (D-02): Kacheln haengen ab jetzt am Reiter, nicht mehr
// nur am Benutzer.
dashboardId String
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
widgetType String widgetType String
config Json @default("{}") config Json @default("{}")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -210,6 +245,7 @@ model WidgetInstance {
@@index([userId]) @@index([userId])
@@index([tenantId]) @@index([tenantId])
@@index([dashboardId])
} }
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers. // Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
+27 -9
View File
@@ -8,6 +8,7 @@ import {
Patch, Patch,
Post, Post,
Put, Put,
Query,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user'; 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). * and scopes all operations to the calling user (T-05-01, T-05-02).
* *
* Routes: * Routes:
* - GET /dashboard/layout — get user's saved layout * - GET /dashboard/tabs — list the user's dashboard tabs (quick-260923-ad9)
* - PUT /dashboard/layout — upsert user's layout * - GET /dashboard/layout — get the saved layout of one tab
* - GET /dashboard/widgets — list user's widget instances * - PUT /dashboard/layout — upsert the layout of one tab
* - POST /dashboard/widgets — create a new widget instance * - 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 * - PATCH /dashboard/widgets/:id/config — update widget config
* - DELETE /dashboard/widgets/:id — remove a widget instance * - DELETE /dashboard/widgets/:id — remove a widget instance
* - GET /dashboard/search-providers — list default + user's custom providers * - 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 }; 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); 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') @Put('layout')
@@ -79,9 +94,12 @@ export class DashboardController {
} }
@Get('widgets') @Get('widgets')
async getWidgets(@Req() req: AuthenticatedRequest) { async getWidgets(
@Req() req: AuthenticatedRequest,
@Query('dashboardId') dashboardId: string,
) {
const { userId, tenantId, role } = this.extractContext(req); const { userId, tenantId, role } = this.extractContext(req);
return this.dashboardService.getWidgets(userId, tenantId, role); return this.dashboardService.getWidgets(userId, tenantId, role, dashboardId);
} }
@Post('widgets') @Post('widgets')
+295 -42
View File
@@ -30,12 +30,20 @@ vi.mock('./widget-module-map', () => ({
* ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus * ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus
* `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine * `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine
* Bindung wäre heute wirkungslos. * 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', () => ({ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), 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 { Prisma } from '@prisma/client';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
@@ -43,14 +51,42 @@ import { DashboardService } from './dashboard.service';
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das /** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst * Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider` * NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
* ergaenzt seit Aufgabe 3 (260910-krx). */ * ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance', 'searchProvider']; * 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( function makeWidget(
overrides: Partial<{ overrides: Partial<{
id: string; id: string;
userId: string; userId: string;
tenantId: string; tenantId: string;
dashboardId: string;
widgetType: string; widgetType: string;
config: Record<string, unknown>; config: Record<string, unknown>;
createdAt: Date; createdAt: Date;
@@ -60,6 +96,7 @@ function makeWidget(
id: overrides.id ?? 'w1', id: overrides.id ?? 'w1',
userId: overrides.userId ?? 'user-1', userId: overrides.userId ?? 'user-1',
tenantId: overrides.tenantId ?? 'tenant-1', tenantId: overrides.tenantId ?? 'tenant-1',
dashboardId: overrides.dashboardId ?? DASH_1,
widgetType: overrides.widgetType ?? 'clock', widgetType: overrides.widgetType ?? 'clock',
config: overrides.config ?? {}, config: overrides.config ?? {},
createdAt: overrides.createdAt ?? new Date('2026-01-01'), createdAt: overrides.createdAt ?? new Date('2026-01-01'),
@@ -92,35 +129,67 @@ function makeFakePrisma(
opts: { opts: {
widgets?: ReturnType<typeof makeWidget>[]; widgets?: ReturnType<typeof makeWidget>[];
modules?: { id: string; slug: string }[]; 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>[]; searchProviders?: ReturnType<typeof makeSearchProvider>[];
dashboards?: ReturnType<typeof makeDashboard>[];
} = {}, } = {},
) { ) {
const widgets = opts.widgets ?? []; const widgets = opts.widgets ?? [];
const modules = opts.modules ?? []; const modules = opts.modules ?? [];
let layoutRow = opts.layout ?? null; let layoutRow = opts.layout ?? null;
const searchProviders = opts.searchProviders ?? []; 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 boundCallLog: { tenantId: string; model: string; method: string }[] = [];
const fake: any = { 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: { dashboardLayout: {
findUnique: vi.fn(async ({ where }: any) => { findUnique: vi.fn(async ({ where }: any) => {
if (layoutRow && layoutRow.userId === where.userId) return layoutRow; if (layoutRow && layoutRow.dashboardId === where.dashboardId) return layoutRow;
return null; return null;
}), }),
upsert: vi.fn(async ({ where, update, create }: any) => { 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 }; layoutRow = { ...layoutRow, layouts: update.layouts };
return layoutRow; 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; return layoutRow;
}), }),
}, },
widgetInstance: { widgetInstance: {
findMany: vi.fn(async ({ where }: any) => { findMany: vi.fn(async ({ where }: any) => {
return widgets return widgets
.filter((w) => w.userId === where.userId) .filter((w) => w.dashboardId === where.dashboardId)
.slice() .slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}), }),
@@ -188,8 +257,16 @@ function makeFakePrisma(
} }
bound[modelName] = wrapped; 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; return bound;
}, },
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
return fn(fake.__makeBoundClient(tenantId));
},
}; };
return fake; return fake;
@@ -245,7 +322,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); 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(result).toEqual(widgets);
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); 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 moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
const service = new DashboardService(prisma as any, moduleAccessService as any); 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]); expect(result).toEqual([widget]);
}); });
@@ -276,7 +353,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set()); const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); 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([]); expect(result).toEqual([]);
}); });
@@ -287,12 +364,13 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const prisma = makeFakePrisma({ const prisma = makeFakePrisma({
widgets: [widget], widgets: [widget],
modules: [{ id: 'mod-1', slug: 'tender-radar' }], 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. // 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 moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
const service = new DashboardService(prisma as any, moduleAccessService as any); 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(result).toEqual([widget]);
expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith( 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 moduleAccessService = makeFakeModuleAccessService(new Set()); // kein Zugriff auf tender-radar
const service = new DashboardService(prisma as any, moduleAccessService as any); 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]); expect(result).toEqual([platformWidget]);
}); });
@@ -334,7 +412,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
const service = new DashboardService(prisma as any, moduleAccessService as any); 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']); 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 moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); 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, DASH_1);
await service.getWidgets('user-1', 'tenant-1', 'USER' as any); await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
expect(prisma.widgetInstance.delete).not.toHaveBeenCalled(); expect(prisma.widgetInstance.delete).not.toHaveBeenCalled();
expect(prisma.widgetInstance.findMany).toHaveBeenCalledTimes(2); 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 moduleAccessService = makeFakeModuleAccessService(new Set(['irgendeine-id']));
const service = new DashboardService(prisma as any, moduleAccessService as any); 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([]); 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 () => { it('getLayout: der Lesezugriff läuft über den gebundenen Klienten, mit der übergebenen Mandantenkennung im Protokoll', async () => {
const prisma = makeFakePrisma({ 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 moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); 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' }] }); expect(result).toEqual({ lg: [{ i: 'w1' }] });
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1'); 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 moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); 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: [] }); 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 moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); 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', 'dashboardLayout', 'upsert');
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
}); });
/** /**
* Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.userId` ist * Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.dashboardId`
* plattformweit eindeutig, ohne Mandantenanteil. Ist die vorhandene Zeile * (bis quick-260923-ad9: `userId`) ist die eindeutige Spalte. Ist die
* unter dem gebundenen Kontext unsichtbar, laeuft das `upsert` in einen * vorhandene Zeile unter dem gebundenen Kontext unsichtbar, laeuft das
* Konflikt — und der aeussert sich hier NICHT als der bekannte P2002-Fehler * `upsert` in einen Konflikt — und der aeussert sich hier NICHT als der
* (`PrismaClientKnownRequestError`), sondern als * bekannte P2002-Fehler (`PrismaClientKnownRequestError`), sondern als
* `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den * `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den
* Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit * Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit
* ueberhaupt geprueft wird. Gemessen in `rls-scratch-check.mjs` * 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); const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect( 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); ).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); const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect( 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); ).rejects.toBe(known);
}); });
}); });
it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => { it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => {
const prisma = makeFakePrisma({ 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 moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.getLayout('user-1', 'tenant-1'); await service.getLayout('user-1', 'tenant-1', DASH_1);
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', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); 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 moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); 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([]); expect(result).toEqual([]);
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany'); 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 moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); 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', '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 () => { 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 widget = makeWidget({ id: 'w1', userId: 'user-1' });
const prisma = makeFakePrisma({ const prisma = makeFakePrisma({
widgets: [widget], 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 moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); const service = new DashboardService(prisma as any, moduleAccessService as any);
for (const call of [ for (const call of [
() => service.getLayout('user-1', 'tenant-1'), () => service.getLayout('user-1', 'tenant-1', DASH_1),
() => service.saveLayout('user-1', 'tenant-1', { layouts: {} } as any), () => service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any),
() => service.getWidgets('user-1', 'tenant-1', 'USER' as any), () => service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1),
() => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any), () => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any),
() => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any), () => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any),
() => service.removeWidget('w1', 'user-1', 'tenant-1'), () => 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 moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any); 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([]); 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 service = new DashboardService(prisma as any, moduleAccessService as any);
const stored = { lg: [{ i: 'w1', x: 0, y: 0, w: 4, h: 4 }], __gridVersion: 2 }; 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).toEqual(stored);
expect((result as Record<string, unknown>).__gridVersion).toBe(2); 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) --------- // --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) ---------
describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => { 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', name: 'Intranet',
urlTemplate: 'https://intranet.test/?q={query}', urlTemplate: 'https://intranet.test/?q={query}',
} as any); } 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 // Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die
// Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren // Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren
+111 -26
View File
@@ -5,7 +5,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma, Role } from '@prisma/client'; import { Prisma, Role } from '@prisma/client';
import { ModuleAccessService } from '../module-registry/module-access.service'; 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 { PrismaService } from '../prisma/prisma.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.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 * Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
* with all breakpoint arrays initialized. * 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 tenantPrisma = forTenant(this.prisma, tenantId, userId);
const record = await tenantPrisma.dashboardLayout.findUnique({ let dashboards = await tenantPrisma.dashboard.findMany({
where: { userId }, 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) { 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. * Creates a new record if none exists, updates if it does.
* *
* `userId` is platform-wide `@unique` (no tenant component) — a tenant * quick-260923-ad9 (D-02): scoped by `dto.dashboardId` instead of
* whose user id was, by hand, moved off its actually-visible row could hit * `userId` — `assertOwnedDashboard` runs first, over the SAME bound
* an `upsert` conflict on a row it cannot see under RLS. Measured * client. `dashboardId` is now the `@unique` column on `DashboardLayout`
* (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row * (was `userId` before this plan).
* throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known *
* error that the `tenders` area's translation pattern catches — this is a * 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 * different Prisma error class, `.code`/`.meta` are `undefined`, the only
* signal is the raw `.message` text). Translated below into an * signal is the raw `.message` text) — measured 260910-krx, Aufgabe 1,
* understandable German message instead of a raw 500, same intent as * translation kept unchanged from before this plan.
* `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.
*/ */
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) { async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
try { try {
return await tenantPrisma.dashboardLayout.upsert({ return await tenantPrisma.dashboardLayout.upsert({
where: { userId }, where: { dashboardId: dto.dashboardId },
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue }, update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
create: { create: {
userId, userId,
tenantId, tenantId,
dashboardId: dto.dashboardId,
layouts: dto.layouts as unknown as Prisma.InputJsonValue, 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). * eines für den Benutzer gesperrten Moduls (D-22, PERM-07).
* *
* Die bestehende Query bleibt unverändert die erste Aktion. Steht unter * quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
* den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — der * `assertOwnedDashboard` runs first, over the SAME bound client. Steht
* Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die * 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 * Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei
* mindestens einem modulgebundenen Widget wird die Zugriffsauflösung * mindestens einem modulgebundenen Widget wird die Zugriffsauflösung
* aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und * 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 * Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das
* betroffene Widget entfernt (Fail-Closed). * 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); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
const widgets = await tenantPrisma.widgetInstance.findMany({ const widgets = await tenantPrisma.widgetInstance.findMany({
where: { userId }, where: { dashboardId },
orderBy: { createdAt: 'asc' }, 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) { async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
return tenantPrisma.widgetInstance.create({ return tenantPrisma.widgetInstance.create({
data: { data: {
userId, userId,
tenantId, tenantId,
dashboardId: dto.dashboardId,
widgetType: dto.widgetType, widgetType: dto.widgetType,
config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue, 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'; 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 * quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus
* `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine * `@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 * zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog
* angebotene Kachel mit 400 ab. * 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. * config is optional and defaults to {} on the model.
*/ */
export class CreateWidgetDto { export class CreateWidgetDto {
@IsString()
dashboardId!: string;
@IsString() @IsString()
@IsIn([...WIDGET_TYPES]) @IsIn([...WIDGET_TYPES])
widgetType!: string; 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 * The layouts object contains responsive breakpoint layouts
* (lg, md, sm, xs, xxs) as managed by react-grid-layout. * (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 { export class SaveLayoutDto {
@IsString()
dashboardId!: string;
@IsObject() @IsObject()
layouts!: Record<string, unknown>; layouts!: Record<string, unknown>;
} }
@@ -39,8 +39,11 @@ describe('widget-module-map (quick-260922-m1h)', () => {
* ablehnen zu lassen. * ablehnen zu lassen.
*/ */
describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => { 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) { async function validateType(widgetType: string) {
const dto = plainToInstance(CreateWidgetDto, { widgetType }); const dto = plainToInstance(CreateWidgetDto, { widgetType, dashboardId: 'dash-1' });
return validate(dto); return validate(dto);
} }
@@ -168,16 +168,16 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen | | dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) | | user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) | | module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
| dashboard | 1 | 21 | 1 | **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) | | dashboard | 1 | 24 | 1 | **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) | | auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg | | calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet | | tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile | | favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff | | bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | | settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
| **Summe** | **61** | **190** | **6** | **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | | **Summe** | **61** | **193** | **6** | **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare) ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 75 Paare)
Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf
(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar (260909-ipc) plus ein bisher vollstaendig unsichtbares Paar
@@ -201,6 +201,19 @@ muss-mandantengebunden) war in der Tabelle eingetragen, in dieser
Verteilung aber nie mitgezaehlt. Beide Korrekturen (72→74, 35→37) sind Verteilung aber nie mitgezaehlt. Beide Korrekturen (72→74, 35→37) sind
gemessen, nicht geschaetzt. gemessen, nicht geschaetzt.
**Nachtrag 260923-ad9 (Task 1, Dashboard-Reiter):** 75 Paare — ein neues
Paar `dashboard/dashboard.service.ts`/`dashboard` (muss-mandantengebunden,
gebunden) fuer die neue Reitertabelle (`listDashboards`,
`assertOwnedDashboard`). Nachgezaehlt mit `grep -cE "^\| apps/api/src/"
docs/mandantentrennung-zugriffsklassifikation.md` ueber die
Fundstellentabelle: vor diesem Eintrag standen dort 74 Zeilen. Die
Uebersichtszeile `dashboard` und die Summenzeile unten sind mit derselben
Gate-Schleife nachgerechnet (Rohtreffer, nicht Paare): `dashboard.service.ts`
traegt jetzt drei zusaetzliche gebundene `tenantPrisma.dashboard.`-Rohtreffer
(zwei `findMany` in `listDashboards`, ein `findUnique` in
`assertOwnedDashboard`) — Bereich `dashboard` 21→24 gebunden, Summe
190→193 gebunden, ungebunden und System unveraendert.
**Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle **Stand 260909-laa (Aufgabe 2):** dieselben 62 Paare, keine neue Fundstelle
hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben: hinzugekommen oder verschwunden — nur EINE Klasse hat sich verschoben:
`tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von `tender-rss-feed.service.ts`/`tenderRssFeedSource` wechselt von
@@ -329,11 +342,11 @@ entnommen (30 Zusicherungen, darunter der Wachhund
| Klasse | Anzahl Paare | | Klasse | Anzahl Paare |
|---|---| |---|---|
| muss-mandantengebunden | 37 | | muss-mandantengebunden | 38 |
| keine-mandantengebundene-tabelle | 21 | | keine-mandantengebundene-tabelle | 21 |
| beides | 14 | | beides | 14 |
| bewusst-uebergreifend | 2 | | bewusst-uebergreifend | 2 |
| **Summe** | **74** | | **Summe** | **75** |
## Der Hintergrunddienst als Falle — sechs Fälle ## Der Hintergrunddienst als Falle — sechs Fälle
@@ -674,10 +687,11 @@ werden.
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. | | apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). | | apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. | | apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 (Task 1) — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. |
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. | | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). | | apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Kacheln eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). quick-260923-ad9 (Task 1): `getWidgets`/`addWidget` filtern/schreiben ueber `dashboardId` statt `userId` (D-02) — `assertOwnedDashboard` prueft vorher, dass der Reiter dem Aufrufer gehoert. |
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. | | apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | system-gebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Seit 260914-eym liest der Planer-Startpfad `loadActiveConfigsForScheduler()` ueber `forSystem()` (alle aktiven Konfigurationen, nur lesend, `system_read_policy`) — kein ungebundener Zugriff mehr, WINDOWS #21 geschlossen; alle uebrigen Zugriffe bleiben mandantengebunden (Stand-Vorrang: system ohne ungebunden = `system-gebunden`). | | apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | system-gebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Seit 260914-eym liest der Planer-Startpfad `loadActiveConfigsForScheduler()` ueber `forSystem()` (alle aktiven Konfigurationen, nur lesend, `system_read_policy`) — kein ungebundener Zugriff mehr, WINDOWS #21 geschlossen; alle uebrigen Zugriffe bleiben mandantengebunden (Stand-Vorrang: system ohne ungebunden = `system-gebunden`). |
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). | | apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |