refactor(quick-260922-hk4): Bilderrahmen-Bilder in user-files statt in der Datenbank

- Bytes liegen unter user-files/dashboard-images/<userId>/<id>.<ext>, die
  Zeile haelt nur noch storagePath (Muster User.avatarPath)
- Dateiname immer servergeneriert: UUID der Zeile + Endung aus dem
  ERKANNTEN Mime-Typ, originalName kommt in keinem Pfad vor (T-HK4-01)
- Migration 20260922120000: storagePath dazu, data wird NULLbar, kein DROP
  (zweistufig, T-HK4-03); system_read_policy fuer den Umzug
- onApplicationBootstrap zieht Altbestand automatisch um: systemgebunden
  lesen, je Zeile mandantengebunden schreiben (Muster DKV-Planer)
- Upload nimmt die Zeile bei fehlgeschlagenem Schreiben zurueck, Loeschen
  entfernt die Datei mit, fehlende Datei -> 404 (T-HK4-04)
- 11 neue Dienst-Tests gegen ein echtes Temp-Verzeichnis (kein fs-Mock)
- Zugriffsklassifikation: Stand system-gebunden, Zahlen nachgemessen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 15:22:47 +02:00
parent 441854af72
commit 9039cea686
6 changed files with 658 additions and 79 deletions
@@ -0,0 +1,59 @@
-- quick-260922-hk4 — Bilderrahmen-Bilder wandern aus der Datenbank in den
-- Dateibereich (Volume `user-files`).
--
-- Warum: gesichert wird von Hand per `pg_dump` (docs/anleitung-betrieb.md
-- Kap. 6). Jedes Bild waechst in diesen Abzug hinein — 30 Bilder à 5 MiB je
-- Benutzer sind im Extremfall 150 MB PRO BENUTZER, gegen eine heute 18 MB
-- grosse Datenbank (gemessen 22.09.2026 auf alpha). Die Bytes liegen ab
-- dieser Version unter
-- `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`; die Zeile
-- haelt nur noch den relativen Pfad in "storagePath" — dasselbe Muster wie
-- `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
-- (dkv-export.service.ts). Der Dateiname ist IMMER servergeneriert (die
-- UUID der Zeile plus die Endung aus dem an den Magic Bytes ERKANNTEN
-- Mime-Typ); kein Byte aus der Anfrage, insbesondere nicht
-- "originalName", geht je in einen Pfad (T-HK4-01, Muster T-07-09).
--
-- ZWEISTUFIG, UND WARUM DIESE MIGRATION "data" NICHT LOESCHT (T-HK4-03):
-- Vorhandene Zeilen tragen ihre Bytes noch in "data". Der Umzug auf die
-- Platte passiert beim ersten Start dieser Version automatisch
-- (DashboardImagesService.onApplicationBootstrap, liest systemgebunden ueber
-- alle Mandanten, schreibt je Zeile mandantengebunden zurueck) — der Nutzer
-- muss nichts ausfuehren. Wuerde diese Migration die Spalte sofort
-- loeschen, laufen Migration und Umzug im selben Start in der falschen
-- Reihenfolge ("migrate deploy" laeuft VOR dem Anwendungsstart) und die
-- Bytes waeren weg, bevor sie jemand gelesen hat. Deshalb:
-- Stufe 1 (diese Migration): "storagePath" dazu (NULLbar), "data" bleibt
-- stehen und wird NULLbar, damit neue Uploads sie leer lassen.
-- Stufe 2 (spaetere Freigabe, Migration
-- 20260922120100_dashboard_image_drop_data, vorgemerkt in
-- .planning/todos/pending/): "storagePath" SET NOT NULL und
-- DROP COLUMN "data" — erst, wenn alpha UND live einmal mit
-- einer Version >= dieser gelaufen sind.
--
-- Das Prisma-Modell behaelt in Stufe 1 bewusst `data Bytes?` (optional).
-- Damit bleibt der Bootstrap-Umzug typisiert und braucht kein rohes SQL;
-- die Spalte verschwindet aus Modell und Tabelle gemeinsam in Stufe 2.
--
-- Rechte/Regeln: "tenant_isolation_policy" aus 20260921120000 bleibt
-- unveraendert. Kein DROP POLICY.
-- Relativer Pfad zur Monorepo-Wurzel, z. B.
-- "user-files/dashboard-images/<userId>/<id>.png". Stufe 2 macht die Spalte
-- NOT NULL.
ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;
-- Neue Uploads schreiben keine Bytes mehr in die Zeile; die Spalte bleibt
-- fuer die Dauer von Stufe 1 als Sicherheitsnetz erhalten.
ALTER TABLE "DashboardImage" ALTER COLUMN "data" DROP NOT NULL;
-- Systemkontext-Leserecht (Muster 20260914120000_rls_system_context_read):
-- der Bootstrap-Umzug liest die noch nicht umgezogenen Zeilen ueber ALLE
-- Mandanten (`forSystem()`), bevor er je Zeile mandantengebunden
-- zurueckschreibt. Ohne diese Regel saehe er nach dem Scharfschalten der
-- Datenbankrolle (Etappe 4, Schalter heute AUS) NULL Zeilen und stellte die
-- Arbeit stumm ein — genau die Falle, die 20260914120000 fuer die fuenf
-- Hintergrunddienst-Tabellen geschlossen hat. Permissiv und NUR FOR SELECT:
-- Schreiben bleibt allein der Mandantenregel unterstellt.
CREATE POLICY system_read_policy ON "DashboardImage"
FOR SELECT USING (is_system_context());
+15 -4
View File
@@ -212,12 +212,16 @@ model WidgetInstance {
@@index([tenantId]) @@index([tenantId])
} }
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers, // Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
// als bytea in der Datenbank (kein Docker-Volume, die Sicherung deckt es mit // Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
// ab). Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
// Benutzer) und die Magic-Byte-Erkennung leben in // Benutzer) und die Magic-Byte-Erkennung leben in
// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND // src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension). // gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
//
// quick-260922-hk4: die Bytes liegen jetzt im Dateibereich
// (user-files/dashboard-images/<userId>/<id>.<ext>), die Zeile haelt nur
// noch den relativen Pfad — Muster User.avatarPath. Die Datenbanksicherung
// (pg_dump) bleibt dadurch klein.
model DashboardImage { model DashboardImage {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
@@ -225,7 +229,14 @@ model DashboardImage {
originalName String originalName String
mimeType String mimeType String
size Int size Int
data Bytes // Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit
// 20260922120100 (vorgemerkt in .planning/todos/pending/).
data Bytes?
// Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der
// Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL.
storagePath String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([userId]) @@index([userId])
@@ -1,33 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
/** /**
* Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts * Bindung an forTenant()/forSystem() — dasselbe Muster wie
* (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma` * dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
* unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert, * ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
* welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt * das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
* damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag). * Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
* unterscheidet beide ueber `via`.
*/ */
vi.mock('../prisma/prisma-tenant.extension', () => ({ vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) => forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
prisma.__makeBoundClient(tenantId, userId), prisma.__makeBoundClient(tenantId, userId),
), ),
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
})); }));
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardImagesService } from './dashboard-images.service'; import { DashboardImagesService } from './dashboard-images.service';
/** /**
* dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen). * dashboard-images.service.spec — quick-260921-pi9 (Bilderrahmen),
* erweitert in quick-260922-hk4 (Bilder auf der Festplatte statt in der
* Datenbank).
* *
* Elf Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload * Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload ohne
* ohne Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE * Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE Richtungen
* Richtungen (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND * (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND fremder Mandant
* fremder Mandant -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, * -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, Loeschen
* Loeschen eigen/fremd, und der Nachweis, dass jede Methode * eigen/fremd, und der Nachweis, dass jede Methode
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem * `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
* Argument aufruft. * Argument aufruft.
*
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
* desktop.service.spec.ts) ueber den Testschalter
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
* Umzug beim Start (T-HK4-03).
*/ */
interface ImageRow { interface ImageRow {
@@ -37,11 +57,13 @@ interface ImageRow {
originalName: string; originalName: string;
mimeType: string; mimeType: string;
size: number; size: number;
data: Uint8Array; data: Uint8Array | null;
storagePath: string | null;
createdAt: Date; createdAt: Date;
} }
interface BoundCall { interface BoundCall {
via: 'tenant' | 'system';
tenantId: string; tenantId: string;
userId: string | undefined; userId: string | undefined;
model: string; model: string;
@@ -55,11 +77,31 @@ interface FakePrisma {
__rows: ImageRow[]; __rows: ImageRow[];
__boundCallLog: BoundCall[]; __boundCallLog: BoundCall[];
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods }; __makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
__makeSystemClient(): { dashboardImage: ModelMethods };
} }
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]);
const TEXT = Buffer.from('nur Text, kein Bild'); const TEXT = Buffer.from('nur Text, kein Bild');
/** Ablageort der Testdateien — echtes Verzeichnis, kein fs-Mock. */
let imagesDir: string;
const ORIGINAL_DIR_ENV = process.env.DASHBOARD_IMAGES_DIR;
beforeAll(() => {
imagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-dashboard-images-'));
process.env.DASHBOARD_IMAGES_DIR = imagesDir;
});
afterAll(() => {
fs.rmSync(imagesDir, { recursive: true, force: true });
if (ORIGINAL_DIR_ENV === undefined) {
delete process.env.DASHBOARD_IMAGES_DIR;
} else {
process.env.DASHBOARD_IMAGES_DIR = ORIGINAL_DIR_ENV;
}
});
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow { function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
return { return {
id: overrides.id ?? 'img-1', id: overrides.id ?? 'img-1',
@@ -68,11 +110,30 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
originalName: overrides.originalName ?? 'foto.png', originalName: overrides.originalName ?? 'foto.png',
mimeType: overrides.mimeType ?? 'image/png', mimeType: overrides.mimeType ?? 'image/png',
size: overrides.size ?? PNG.length, size: overrides.size ?? PNG.length,
data: overrides.data ?? Uint8Array.from(PNG), data: overrides.data === undefined ? null : overrides.data,
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
createdAt: overrides.createdAt ?? new Date('2026-01-01'), createdAt: overrides.createdAt ?? new Date('2026-01-01'),
}; };
} }
/**
* Legt eine Zeile MIT passender Datei auf der Platte an — der Normalfall
* nach dem Upload (die Tests 8-12 pruefen Besitz und Bindung, nicht die
* Ablage).
*/
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
const row = makeRow(overrides);
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
fs.mkdirSync(path.dirname(absolute), { recursive: true });
fs.writeFileSync(absolute, bytes);
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
}
function storedFile(userId: string, id: string, ext = 'png'): string {
return path.join(imagesDir, userId, `${id}.${ext}`);
}
function pick(row: ImageRow, select: Record<string, boolean> | undefined) { function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
if (!select) return row; if (!select) return row;
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
@@ -86,9 +147,20 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
const boundCallLog: BoundCall[] = []; const boundCallLog: BoundCall[] = [];
const dashboardImage: ModelMethods = { const dashboardImage: ModelMethods = {
findMany: vi.fn(async (raw: unknown) => { findMany: vi.fn(async (raw: unknown) => {
const args = raw as { where: { tenantId: string; userId: string }; select?: Record<string, boolean> }; const args = raw as {
where: { tenantId?: string; userId?: string; storagePath?: string | null };
select?: Record<string, boolean>;
};
const where = args.where ?? {};
return rows return rows
.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId) .filter((r) => {
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
if (where.userId !== undefined && r.userId !== where.userId) return false;
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
return false;
}
return true;
})
.slice() .slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((r) => pick(r, args.select)); .map((r) => pick(r, args.select));
@@ -98,11 +170,18 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length; return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
}), }),
create: vi.fn(async (raw: unknown) => { create: vi.fn(async (raw: unknown) => {
const args = raw as { data: Omit<ImageRow, 'id' | 'createdAt'>; select?: Record<string, boolean> }; const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') }); const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
rows.push(created); rows.push(created);
return pick(created, args.select); return pick(created, args.select);
}), }),
update: vi.fn(async (raw: unknown) => {
const args = raw as { where: { id: string }; data: Partial<ImageRow> };
const row = rows.find((r) => r.id === args.where.id);
if (!row) throw new Error(`update: Zeile '${args.where.id}' gibt es nicht`);
Object.assign(row, args.data);
return row;
}),
findUnique: vi.fn(async (raw: unknown) => { findUnique: vi.fn(async (raw: unknown) => {
const args = raw as { where: { id: string } }; const args = raw as { where: { id: string } };
return rows.find((r) => r.id === args.where.id) ?? null; return rows.find((r) => r.id === args.where.id) ?? null;
@@ -116,19 +195,26 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}), }),
}; };
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
const wrapped: ModelMethods = {};
for (const method of Object.keys(dashboardImage)) {
wrapped[method] = async (...args: unknown[]) => {
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
return dashboardImage[method](...args);
};
}
return { dashboardImage: wrapped };
}
const fake: FakePrisma = { const fake: FakePrisma = {
dashboardImage, dashboardImage,
__rows: rows, __rows: rows,
__boundCallLog: boundCallLog, __boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string, userId?: string) { __makeBoundClient(tenantId: string, userId?: string) {
const wrapped: ModelMethods = {}; return wrap('tenant', tenantId, userId);
for (const method of Object.keys(dashboardImage)) { },
wrapped[method] = async (...args: unknown[]) => { __makeSystemClient() {
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method }); return wrap('system', '', undefined);
return dashboardImage[method](...args);
};
}
return { dashboardImage: wrapped };
}, },
}; };
return fake; return fake;
@@ -156,6 +242,7 @@ function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): Uplo
beforeEach(() => { beforeEach(() => {
vi.mocked(forTenant).mockClear(); vi.mocked(forTenant).mockClear();
vi.mocked(forSystem).mockClear();
}); });
describe('DashboardImagesService (quick-260921-pi9)', () => { describe('DashboardImagesService (quick-260921-pi9)', () => {
@@ -172,6 +259,7 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
} }
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> }; const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
expect(call.select.data).toBeUndefined(); expect(call.select.data).toBeUndefined();
expect(call.select.storagePath).toBeUndefined();
}); });
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => { it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
@@ -234,19 +322,19 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
}); });
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => { it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
const prisma = makeFakePrisma([makeRow({ id: 'img-1', userId: 'user-2' })]); const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
}); });
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => { it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
const prisma = makeFakePrisma([makeRow({ id: 'img-1', tenantId: 'tenant-2' })]); const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
const service = makeService(prisma); const service = makeService(prisma);
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
}); });
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => { it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]); const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1' })]);
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1'); const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
expect(result.mimeType).toBe('image/png'); expect(result.mimeType).toBe('image/png');
expect(Buffer.from(result.data).equals(PNG)).toBe(true); expect(Buffer.from(result.data).equals(PNG)).toBe(true);
@@ -254,9 +342,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => { it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
const prisma = makeFakePrisma([ const prisma = makeFakePrisma([
makeRow({ id: 'eigen' }), makeStoredRow({ id: 'eigen' }),
makeRow({ id: 'fremd-user', userId: 'user-2' }), makeStoredRow({ id: 'fremd-user', userId: 'user-2' }),
makeRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }), makeStoredRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
]); ]);
const service = makeService(prisma); const service = makeService(prisma);
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' }); await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
@@ -267,12 +355,12 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
}); });
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => { it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]); const prisma = makeFakePrisma([]);
const service = makeService(prisma); const service = makeService(prisma);
await service.list('user-1', 'tenant-1'); await service.list('user-1', 'tenant-1');
await service.upload(user, file(PNG, 'image/png')); const created = await service.upload(user, file(PNG, 'image/png'));
await service.getBytes('img-1', 'user-1', 'tenant-1'); await service.getBytes(created.id, 'user-1', 'tenant-1');
await service.remove('img-1', 'user-1', 'tenant-1'); await service.remove(created.id, 'user-1', 'tenant-1');
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4); expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
for (const call of vi.mocked(forTenant).mock.calls) { for (const call of vi.mocked(forTenant).mock.calls) {
@@ -280,12 +368,174 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
expect(call[1]).toBe('tenant-1'); expect(call[1]).toBe('tenant-1');
expect(call[2]).toBe('user-1'); expect(call[2]).toBe('user-1');
} }
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten. // Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
// UUID der Zeile erst nach `create` feststeht (hk4).
const methods = prisma.__boundCallLog.map((c) => c.method); const methods = prisma.__boundCallLog.map((c) => c.method);
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']); expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
for (const c of prisma.__boundCallLog) { for (const c of prisma.__boundCallLog) {
expect(c.via).toBe('tenant');
expect(c.tenantId).toBe('tenant-1'); expect(c.tenantId).toBe('tenant-1');
expect(c.userId).toBe('user-1'); expect(c.userId).toBe('user-1');
} }
}); });
}); });
describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)', () => {
it('Test 13: upload schreibt die Datei unter <dir>/<userId>/<id>.png und speichert den relativen Pfad in der Zeile', async () => {
const prisma = makeFakePrisma();
const result = await makeService(prisma).upload(user, file(PNG, 'image/png'));
const onDisk = storedFile('user-1', result.id);
expect(fs.existsSync(onDisk)).toBe(true);
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
expect(prisma.__rows[0].data).toBeNull();
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
expect(createArgs.data.data).toBeUndefined();
});
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
const prisma = makeFakePrisma();
const service = makeService(prisma);
const boeserName = '../../../etc/passwd.png';
const result = await service.upload(user, file(JPEG, 'image/png', boeserName));
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
expect(result.mimeType).toBe('image/jpeg');
const stored = prisma.__rows[0].storagePath ?? '';
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
expect(stored).not.toContain('passwd');
expect(stored).not.toContain('..');
expect(fs.existsSync(storedFile('user-1', result.id, 'jpg'))).toBe(true);
// Der Anzeigename bleibt in der Zeile erhalten, nur eben als Text.
expect(result.originalName).toBe(boeserName);
});
it('Test 15: scheitert das Schreiben, wird die eben angelegte Zeile wieder geloescht und 500 geworfen (T-HK4-04)', async () => {
const blocker = path.join(imagesDir, 'blockade');
fs.writeFileSync(blocker, 'ich bin eine Datei, kein Verzeichnis');
const vorher = process.env.DASHBOARD_IMAGES_DIR;
process.env.DASHBOARD_IMAGES_DIR = path.join(blocker, 'unmoeglich');
try {
const prisma = makeFakePrisma();
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
InternalServerErrorException,
);
expect(prisma.dashboardImage.create).toHaveBeenCalledTimes(1);
expect(prisma.dashboardImage.delete).toHaveBeenCalledTimes(1);
expect(prisma.__rows).toHaveLength(0);
} finally {
process.env.DASHBOARD_IMAGES_DIR = vorher;
}
});
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
});
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
const prisma = makeFakePrisma([
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
]);
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
});
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
});
it('Test 19: remove loescht Zeile UND Datei', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
const onDisk = storedFile('user-1', 'weg');
expect(fs.existsSync(onDisk)).toBe(true);
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
expect(prisma.__rows).toHaveLength(0);
expect(fs.existsSync(onDisk)).toBe(false);
});
it('Test 20: fehlt die Datei beim Loeschen, gelingt das Loeschen trotzdem (eine Dateileiche ist harmloser als eine haengende Loeschung)', async () => {
const prisma = makeFakePrisma([
makeRow({ id: 'nur-zeile', storagePath: 'user-files/dashboard-images/user-1/nur-zeile.png' }),
]);
await expect(makeService(prisma).remove('nur-zeile', 'user-1', 'tenant-1')).resolves.toEqual({
id: 'nur-zeile',
});
expect(prisma.__rows).toHaveLength(0);
});
});
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
const fremderMandant = makeRow({
id: 'alt-2',
userId: 'user-9',
tenantId: 'tenant-2',
mimeType: 'image/jpeg',
data: Uint8Array.from(JPEG),
});
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
await makeService(prisma).onApplicationBootstrap();
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
// gebunden ist.
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
where: Record<string, unknown>;
};
expect(findManyArgs.where.storagePath).toBeNull();
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
// Die bereits umgezogene Zeile wird nicht angefasst.
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
});
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
await makeService(prisma).onApplicationBootstrap();
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
});
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
const service = makeService(prisma);
await service.onApplicationBootstrap();
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
vi.mocked(forTenant).mockClear();
await service.onApplicationBootstrap();
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
});
});
@@ -1,6 +1,15 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
type OnApplicationBootstrap,
} from '@nestjs/common';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
DASHBOARD_IMAGE_MAX_COUNT, DASHBOARD_IMAGE_MAX_COUNT,
@@ -10,20 +19,53 @@ import {
/** /**
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets * DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
* (quick-260921-pi9). * (quick-260921-pi9), seit quick-260922-hk4 im Dateibereich statt in der
* Datenbank.
* *
* Ein Bild gehoert dem hochladenden Benutzer: Besitz = gleicher Mandant UND * WO DIE BYTES LIEGEN (hk4): unter
* gleicher Benutzer. Die Besitzpruefung in `getBytes`/`remove` (Zeile holen, * `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`, die Zeile
* `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen, sonst 404) * haelt nur noch den relativen Pfad in `storagePath` — dasselbe Muster wie
* ist NICHT dekorativ: die RLS-Regel auf `DashboardImage` (Migration * `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
* 20260921120000, mit Benutzerdimension) wirkt erst, wenn die Anwendung als * (dkv-export.service.ts). Grund ist die Sicherung: gesichert wird von Hand
* Rolle ohne Umgehungsrecht verbindet — der Schalter ist heute AUS * per `pg_dump` (docs/anleitung-betrieb.md Kap. 6), und 30 Bilder à 5 MiB je
* (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der Vergleich * Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug. Das
* hier der einzige wirksame Schutz gegen Quer-Lesen und Quer-Loeschen; die * Volume `user-files` wird daneben gesichert. Geschwindigkeit war NICHT das
* `forTenant()`-Bindung je Methode LEGT eine Mandantengrenze obendrauf, sie * Argument (ein Bild wird je Browser einmal taeglich geladen).
* ersetzt den Vergleich nicht (Muster dashboard.service.ts). Nach dem *
* Scharfschalten liefert `findUnique` fuer eine fremde Zeile bereits `null` * DER DATEINAME KOMMT IMMER VOM SERVER (T-HK4-01, Muster T-07-09 aus
* — die Antwort bleibt 404, nur der Weg dorthin aendert sich. * `dkv-export.service.ts`): er ist die UUID der Zeile plus die Endung aus
* dem an den Magic Bytes ERKANNTEN Mime-Typ. `originalName` ist reiner
* Anzeigetext und erscheint weder im Pfad noch in einem Header (T-PI9-06).
* `absoluteImagePath()` prueft zusaetzlich, dass der aus der Zeile
* gelesene Pfad im Bilderverzeichnis liegt — ein Wert aus der Datenbank
* wird nie ungeprueft an `path.join` gereicht.
*
* EIN EIGENER ORDNER JE BENUTZER IST KEIN SCHUTZ: wer welches Bild sehen
* darf, entscheidet weiterhin dieser Dienst. Die Datei wird nie direkt
* ausgeliefert, nur ueber `GET /dashboard/images/:id` mit Besitzpruefung
* (T-HK4-02); das Volume haengt in keinem Webserver.
*
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
* die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert
* das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim
* Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
* 404 und die Kachel zeigt „Bild nicht verfügbar".
*
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
* sonst 404) ist NICHT dekorativ: die RLS-Regel auf `DashboardImage`
* (Migration 20260921120000, mit Benutzerdimension) wirkt erst, wenn die
* Anwendung als Rolle ohne Umgehungsrecht verbindet — der Schalter ist
* heute AUS (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der
* Vergleich hier der einzige wirksame Schutz gegen Quer-Lesen und
* Quer-Loeschen; die `forTenant()`-Bindung je Methode LEGT eine
* Mandantengrenze obendrauf, sie ersetzt den Vergleich nicht (Muster
* dashboard.service.ts). Nach dem Scharfschalten liefert `findUnique` fuer
* eine fremde Zeile bereits `null` — die Antwort bleibt 404, nur der Weg
* dorthin aendert sich.
* *
* Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die * Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
* Kennung existiert. Kennungen sind `uuid()`, nicht erratbar. * Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
@@ -31,9 +73,7 @@ import {
* Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08): * Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08):
* `file.mimetype` und `originalname` behauptet der Browser; gespeichert und * `file.mimetype` und `originalname` behauptet der Browser; gespeichert und
* spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was * spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was
* `detectImageMime` an den Bytes erkannt hat. Der Dateiname wird nur als * `detectImageMime` an den Bytes erkannt hat.
* Anzeigetext gefuehrt (auf 255 Zeichen gekuerzt) und erscheint nie in
* einem HTTP-Header (T-PI9-06).
* *
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben * Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze * Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
@@ -47,7 +87,10 @@ import {
const ORIGINAL_NAME_MAX = 255; const ORIGINAL_NAME_MAX = 255;
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */ /** Ablageort unterhalb der Monorepo-Wurzel, so wie er in der Zeile steht. */
const STORAGE_PREFIX = 'user-files/dashboard-images/';
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — nie Bytes, nie Pfad. */
const META_SELECT = { const META_SELECT = {
id: true, id: true,
originalName: true, originalName: true,
@@ -64,10 +107,124 @@ export interface DashboardImageMeta {
createdAt: Date; createdAt: Date;
} }
/**
* Loest das Bilderverzeichnis relativ zur Monorepo-Wurzel auf — Muster
* `resolveAvatarsDir()` (user.controller.ts): zur Laufzeit ist
* `__dirname` = apps/api/dist/dashboard/, also vier Ebenen hoch.
*
* `DASHBOARD_IMAGES_DIR` ist ein Testschalter (Muster `DESKTOP_DIST_DIR`,
* desktop.service.ts) und im Betrieb nie gesetzt; die Tests zeigen damit
* auf ein Wegwerfverzeichnis unter `os.tmpdir()`, statt `fs` nachzubauen.
*/
export function resolveDashboardImagesDir(): string {
const override = process.env.DASHBOARD_IMAGES_DIR;
if (override !== undefined && override !== '') {
return path.resolve(override);
}
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'dashboard-images');
}
/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */
function extensionFor(mimeType: string): string | null {
switch (mimeType) {
case 'image/png':
return 'png';
case 'image/jpeg':
return 'jpg';
case 'image/gif':
return 'gif';
case 'image/webp':
return 'webp';
default:
return null;
}
}
/** Relativer Pfad, wie er in der Zeile steht (`storagePath`). */
function relativeStoragePath(userId: string, id: string, extension: string): string {
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
}
/**
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
* die erwartete Form hat oder aus dem Verzeichnis herausfuehren wuerde
* (T-HK4-01). Der Aufrufer behandelt `null` wie eine fehlende Datei.
*/
function absoluteImagePath(storagePath: string): string | null {
const normalized = storagePath.split('\\').join('/');
if (!normalized.startsWith(STORAGE_PREFIX)) return null;
const base = resolveDashboardImagesDir();
const absolute = path.resolve(base, normalized.slice(STORAGE_PREFIX.length));
if (absolute !== base && !absolute.startsWith(base + path.sep)) return null;
return absolute;
}
@Injectable() @Injectable()
export class DashboardImagesService { export class DashboardImagesService implements OnApplicationBootstrap {
private readonly logger = new Logger(DashboardImagesService.name);
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/**
* Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der
* Betreiber nichts von Hand ausfuehren muss.
*
* ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die
* SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data`
* NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges
* DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte.
* Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live
* einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in
* `.planning/todos/pending/`).
*
* GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster
* `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle
* Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben
* wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer
* dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet
* (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in
* 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim
* DKV-Planer.
*
* Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein
* zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile
* wird protokolliert und haelt den Start nicht auf.
*/
async onApplicationBootstrap(): Promise<void> {
const systemPrisma = forSystem(this.prisma);
const pending = await systemPrisma.dashboardImage.findMany({
where: { storagePath: null },
select: { id: true, userId: true, tenantId: true, mimeType: true, data: true },
orderBy: { createdAt: 'asc' },
});
let moved = 0;
for (const row of pending) {
if (row.data === null) continue;
try {
const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId);
await tenantPrisma.dashboardImage.update({
where: { id: row.id },
data: { storagePath },
});
moved += 1;
} catch (error) {
this.logger.error(
`Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
if (moved > 0) {
this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`);
}
}
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */ /** Eigene Bilder, aelteste zuerst, nur Metadaten. */
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> { async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -80,7 +237,13 @@ export class DashboardImagesService {
/** /**
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler * Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
* begrenzt, gespeichert wird der erkannte Typ. * begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
* die Zeile haelt den Pfad.
*
* Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der
* Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des
* Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine
* Zeile ohne Datei.
*/ */
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> { async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
if (!file) { if (!file) {
@@ -102,26 +265,41 @@ export class DashboardImagesService {
); );
} }
return tenantPrisma.dashboardImage.create({ const created = await tenantPrisma.dashboardImage.create({
data: { data: {
userId: user.id, userId: user.id,
tenantId: user.tenantId, tenantId: user.tenantId,
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX), originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
mimeType, mimeType,
size: file.buffer.length, size: file.buffer.length,
// Befund am Typsystem (TS 5.9 + Prisma 6): `Bytes` verlangt
// `Uint8Array<ArrayBuffer>`, multers `Buffer` ist aber ueber
// `ArrayBufferLike` getypt (koennte ein SharedArrayBuffer sein) und
// wird ohne Zusicherung abgelehnt. `new Uint8Array(buffer)` kopiert in
// einen frischen ArrayBuffer — hoechstens 5 MiB, einmal je Upload —
// und ist damit ehrlich getypt statt zugesichert.
data: new Uint8Array(file.buffer),
}, },
select: META_SELECT, select: META_SELECT,
}); });
try {
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer);
await tenantPrisma.dashboardImage.update({
where: { id: created.id },
data: { storagePath },
});
} catch (error) {
this.logger.error(
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
error instanceof Error ? error.message : String(error)
}`,
);
await tenantPrisma.dashboardImage.delete({ where: { id: created.id } });
throw new InternalServerErrorException('Das Bild konnte nicht gespeichert werden.');
} }
/** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */ return created;
}
/**
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
* 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad
* (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404.
*/
async getBytes( async getBytes(
id: string, id: string,
userId: string, userId: string,
@@ -132,10 +310,31 @@ export class DashboardImagesService {
if (!row || row.userId !== userId || row.tenantId !== tenantId) { if (!row || row.userId !== userId || row.tenantId !== tenantId) {
throw new NotFoundException(`Image with id '${id}' not found`); throw new NotFoundException(`Image with id '${id}' not found`);
} }
return { mimeType: row.mimeType, data: row.data };
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
if (absolute === null) {
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
throw new NotFoundException(`Image with id '${id}' not found`);
} }
/** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */ try {
const data = await fs.readFile(absolute);
return { mimeType: row.mimeType, data };
} catch (error) {
this.logger.warn(
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw new NotFoundException(`Image with id '${id}' not found`);
}
}
/**
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
* protokolliert und geschluckt (T-HK4-04).
*/
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> { async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } }); const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
@@ -143,6 +342,47 @@ export class DashboardImagesService {
throw new NotFoundException(`Image with id '${id}' not found`); throw new NotFoundException(`Image with id '${id}' not found`);
} }
await tenantPrisma.dashboardImage.delete({ where: { id } }); await tenantPrisma.dashboardImage.delete({ where: { id } });
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
if (absolute !== null) {
try {
await fs.unlink(absolute);
} catch (error) {
this.logger.warn(
`Datei des geloeschten Bilderrahmen-Bildes ${id} konnte nicht entfernt werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
return { id }; return { id };
} }
/**
* Schreibt die Bytes an den servergenerierten Ort und liefert den
* relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht
* dabei (`recursive: true`).
*/
private async writeImageFile(
userId: string,
id: string,
mimeType: string,
bytes: Uint8Array,
): Promise<string> {
const extension = extensionFor(mimeType);
if (extension === null) {
throw new Error(`Unbekannter Bildtyp '${mimeType}'`);
}
const storagePath = relativeStoragePath(userId, id, extension);
const absolute = absoluteImagePath(storagePath);
if (absolute === null) {
throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`);
}
await fs.mkdir(path.dirname(absolute), { recursive: true });
await fs.writeFile(absolute, bytes);
return storagePath;
}
} }
@@ -150,8 +150,24 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration * der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert. * eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
* Summe: 4 Dateien, 5 Aufrufe. * Summe: 4 Dateien, 5 Aufrufe.
*
* SIEBTER FALL (quick-260922-hk4): `dashboard-images.service.ts`, EIN
* Aufruf, ausschliesslich in `onApplicationBootstrap()` — der einmalige
* Umzug der Bilderrahmen-Bilder aus der Spalte `data` in den Dateibereich.
* Ein Startpfad hat keinen Mandanten im Ruecken und muss die noch nicht
* umgezogenen Zeilen ALLER Mandanten sehen; die passende Regel
* `system_read_policy ... FOR SELECT` auf "DashboardImage" legt die
* Migration 20260922120000 an. Dieselbe Datei bedient daneben Anfragewege
* (`list`/`upload`/`getBytes`/`remove`) — die bleiben ausnahmslos
* mandantengebunden, und auch der Umzug SCHREIBT je Zeile ueber
* `forTenant(prisma, row.tenantId, row.userId)`, nie ueber den
* Systemklienten. Praezedenz fuer "ein Dienst mit Anfrageweg UND
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
* Summe neu: 5 Dateien, 6 Aufrufe.
*/ */
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([ const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
['apps/api/src/dkv/dkv.service.ts', 1], ['apps/api/src/dkv/dkv.service.ts', 1],
['apps/api/src/ldap/ldap-config.service.ts', 2], ['apps/api/src/ldap/ldap-config.service.ts', 2],
['apps/api/src/tenders/tender-digest.scheduler.ts', 1], ['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
@@ -168,14 +168,14 @@ 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 | 18 | 0 | **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 | 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) |
| 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** | **187** | **5** | **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** | **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 |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare) ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare)
@@ -673,7 +673,7 @@ werden.
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). | | apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
| 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 | gebunden | 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 | 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 | 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). |
@@ -808,7 +808,10 @@ werden.
Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext
(Etappe 3c) — erledigt (260914-eym):** Migration (Etappe 3c) — erledigt (260914-eym):** Migration
`20260914120000_rls_system_context_read` (`is_system_context()`, `20260914120000_rls_system_context_read` (`is_system_context()`,
`system_read_policy … FOR SELECT` auf fünf Tabellen), Schwesterhelfer `system_read_policy … FOR SELECT` auf fünf Tabellen; seit 260922-hk4
kommt "DashboardImage" als sechste dazu, angelegt in der Migration
20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder),
Schwesterhelfer
`forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste; `forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste;
siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
"## Systemkontext (Etappe 3c, 260914-eym)" und den Regelschluss je Fall "## Systemkontext (Etappe 3c, 260914-eym)" und den Regelschluss je Fall