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