feat(260924-m4n): Bilderrahmen Stufe 2 - alte Bildspalte data entfernt, storagePath Pflicht

- Migration 20260924120000_dashboard_image_drop_data: Schutzpruefung
  (bricht ab, solange eine Zeile ohne storagePath existiert; row_security
  aus, damit ein Eigentuemer ohne BYPASSRLS nicht still 0 Zeilen sieht),
  dann NOT NULL, DROP COLUMN data, DROP POLICY system_read_policy
- Dienst: Bootstrap-Umzug samt forSystem() und Selbstheilung aus data
  entfernt; Upload vergibt die UUID selbst, Zeile gleich mit Pfad
- FORSYSTEM_ALLOWED_CALL_SITES, Tests, Zugriffsklassifikation (per
  Gate-Schleife gemessen: 61/213/6) nachgezogen
- Betriebshandbuch Kap. 4: Hinweis und Wiederherstellungsweg bei Abbruch
- Todo 2026-09-22 nach completed/

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 16:09:36 +02:00
parent b10734f382
commit dd54ec5d42
9 changed files with 230 additions and 288 deletions
@@ -80,3 +80,21 @@ nichts. Der Gewinn der Umstellung (kleiner `pg_dump`) ist bereits da, weil
neue Uploads keine Bytes mehr in die Zeile schreiben. Nur die Bytes der neue Uploads keine Bytes mehr in die Zeile schreiben. Nur die Bytes der
ALTEN Bilder bleiben bis dahin doppelt vorhanden — einmal in der Datei, ALTEN Bilder bleiben bis dahin doppelt vorhanden — einmal in der Datei,
einmal in der Spalte. einmal in der Spalte.
## Erledigt in quick-260924-m4n (24.09.2026)
- Migration heißt `20260924120000_dashboard_image_drop_data` (nicht
`20260922120100`: sie muss hinter allen vorhandenen Migrationen liegen).
Sie prüft zuerst, dass keine Zeile ohne `storagePath` existiert, und bricht
sonst mit Meldung ab, bevor sie etwas ändert; `row_security` ist für die
Prüfung aus, damit ein Eigentümer ohne BYPASSRLS nicht still 0 Zeilen sieht
(lokal nachgewiesen). Danach `storagePath` NOT NULL, `DROP COLUMN "data"`,
`DROP POLICY IF EXISTS system_read_policy ON "DashboardImage"`.
- Dienst: Bootstrap-Umzug, `forSystem()` und die Selbstheilung aus `data`
entfernt; der Upload vergibt die UUID selbst und legt die Zeile gleich mit
Pfad an. Erlaubnisliste, Tests (10b, 10c, 18, 21–23 entfallen) und
Zugriffsklassifikation nachgezogen (Zahlen mit der Gate-Schleife gemessen).
- Wiederherstellungsweg nach einem Abbruch (fehlgeschlagene Migration als
zurückgenommen vermerken, 1.3.1 laufen lassen, erneut einspielen) in
`docs/anleitung-betrieb.md` Kapitel 4, in einer Wegwerf-Datenbank
durchgespielt.
@@ -0,0 +1,53 @@
-- quick-260924-m4n — Stufe 2 der Umstellung aus quick-260922-hk4: die alte
-- Bildspalte "data" faellt, "storagePath" wird Pflicht.
--
-- Stufe 1 (20260922120000_dashboard_image_to_disk) hat "storagePath"
-- angelegt und "data" nur NULLbar gemacht, weil `prisma migrate deploy` VOR
-- dem Anwendungsstart laeuft: ein sofortiges DROP haette die Bytes
-- vernichtet, bevor der Bootstrap-Umzug (DashboardImagesService,
-- Version 1.3.1) sie auf die Platte schreiben konnte (T-HK4-03). Dieser
-- Umzug ist mit dieser Version aus dem Code entfernt.
--
-- SCHUTZ VOR DATENVERLUST: gibt es noch eine Zeile ohne "storagePath", hat
-- der Umzug auf diesem Server nie gearbeitet (der Server hat eine Version
-- < 1.3.1 uebersprungen). Dann bricht die Migration mit einer Meldung ab,
-- BEVOR irgendetwas geaendert wird (die Pruefung steht vor jeder Aenderung),
-- `migrate deploy` stoppt, die API startet nicht. Abhilfe
-- (docs/anleitung-betrieb.md, Kapitel 4, gemessen in einer Wegwerf-DB):
-- den fehlgeschlagenen Eintrag in "_prisma_migrations" als zurueckgenommen
-- vermerken (sonst verweigert auch 1.3.1 den Start mit P3009), dann eine
-- Version >= 1.3.1 einmal starten lassen (der Umzug laeuft beim Start von
-- selbst), danach erneut auf diese Version gehen.
--
-- ZEILENSCHUTZ (RLS) UND DIE PRUEFUNG: "DashboardImage" hat FORCE ROW LEVEL
-- SECURITY (20260921120000). FORCE wirkt auch auf den Tabelleneigentuemer —
-- ohne Sitzungsvariablen wuerde die Mandantenregel dem EXISTS jede Zeile
-- wegfiltern, die Pruefung saehe 0 Zeilen und der Schutz waere stumm
-- wirkungslos. Heute laeuft die Migration als `tessera` (Superuser mit
-- BYPASSRLS, gemessen 24.09.2026 lokal) und sieht alles. Fuer den Fall, dass
-- sie spaeter ueber TESSERA_MIGRATE_DATABASE_URL als Eigentuemer OHNE
-- BYPASSRLS laeuft (docs/mandantentrennung-datenbankrolle.md), schaltet die
-- Pruefung `row_security` fuer diese Transaktion ab: PostgreSQL filtert dann
-- NICHT still, sondern bricht mit "query would be affected by row-level
-- security policy" ab. Die Pruefung sieht also entweder alle Zeilen oder
-- scheitert laut — nie "0 gesehen, weiter".
DO $$
BEGIN
PERFORM set_config('row_security', 'off', true);
IF EXISTS (SELECT 1 FROM "DashboardImage" WHERE "storagePath" IS NULL) THEN
RAISE EXCEPTION 'DashboardImage: es gibt noch Zeilen ohne storagePath — Umzug (quick-260922-hk4) zuerst mit einer Version >= 1.3.1 laufen lassen, dann erneut deployen';
END IF;
PERFORM set_config('row_security', 'on', true);
END $$;
ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;
ALTER TABLE "DashboardImage" DROP COLUMN "data";
-- Die Systemkontext-Leseregel aus 20260922120000 hatte genau einen Zweck:
-- den Bootstrap-Umzug, der ueber ALLE Mandanten las (`forSystem()`). Der
-- Umzug ist entfernt, niemand liest "DashboardImage" mehr systemgebunden —
-- eine offene Leseregel ohne Leser waere nur Angriffsflaeche. Es bleibt
-- allein "tenant_isolation_policy" (Mandant UND Benutzer). IF EXISTS, damit
-- die Migration auch auf einer Datenbank durchlaeuft, auf der die Regel von
-- Hand entfernt wurde.
DROP POLICY IF EXISTS system_read_policy ON "DashboardImage";
+5 -8
View File
@@ -265,14 +265,11 @@ model DashboardImage {
originalName String originalName String
mimeType String mimeType String
size Int size Int
// Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die // Relativ zur Monorepo-Wurzel, z. B.
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern // "user-files/dashboard-images/<userId>/<id>.png". Die Bytes liegen seit
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit // quick-260922-hk4 im Dateibereich; die alte Spalte `data` ist mit Stufe 2
// 20260922120100 (vorgemerkt in .planning/todos/pending/). // (Migration 20260924120000_dashboard_image_drop_data) entfernt.
data Bytes? storagePath String
// 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])
@@ -4,21 +4,21 @@ import * as path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
/** /**
* Bindung an forTenant()/forSystem() — dasselbe Muster wie * Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein * (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma`
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher, * unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert,
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener * welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne * damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag).
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten * `forSystem` steht als Spion daneben: seit Stufe 2 (quick-260924-m4n,
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`, * Bootstrap-Umzug entfernt) darf der Dienst ihn nie mehr rufen.
* 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()), forSystem: vi.fn(() => {
throw new Error('forSystem darf der Bilderdienst seit Stufe 2 nicht mehr rufen');
}),
})); }));
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
@@ -39,15 +39,18 @@ import { DashboardImagesService } from './dashboard-images.service';
* `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 * Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-20): KEIN
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster * `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
* desktop.service.spec.ts) ueber den Testschalter * desktop.service.spec.ts) ueber den Testschalter
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich. * `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus * Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das * die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404 * Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
* bei fehlender Datei, das Mitloeschen der Datei und der automatische * bei fehlender Datei und das Mitloeschen der Datei.
* Umzug beim Start (T-HK4-03). *
* Stufe 2 (quick-260924-m4n): die Spalte `data` ist weg, `storagePath` ist
* Pflicht. Mit ihr entfallen die Faelle 10b/10c (Selbstheilung aus `data`),
* 18 (Zeile ohne `storagePath`) und 21-23 (Bootstrap-Umzug).
*/ */
interface ImageRow { interface ImageRow {
@@ -57,13 +60,11 @@ interface ImageRow {
originalName: string; originalName: string;
mimeType: string; mimeType: string;
size: number; size: number;
data: Uint8Array | null; storagePath: string;
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;
@@ -77,7 +78,6 @@ 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]);
@@ -102,16 +102,21 @@ afterAll(() => {
} }
}); });
/**
* Eine Zeile OHNE Datei auf der Platte; der Pfad folgt der Form, die der
* Dienst selbst vergibt (`storagePath` ist seit Stufe 2 Pflicht).
*/
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow { function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
const id = overrides.id ?? 'img-1';
const userId = overrides.userId ?? 'user-1';
return { return {
id: overrides.id ?? 'img-1', id,
userId: overrides.userId ?? 'user-1', userId,
tenantId: overrides.tenantId ?? 'tenant-1', tenantId: overrides.tenantId ?? 'tenant-1',
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 === undefined ? null : overrides.data, storagePath: overrides.storagePath ?? `user-files/dashboard-images/${userId}/${id}.png`,
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
createdAt: overrides.createdAt ?? new Date('2026-01-01'), createdAt: overrides.createdAt ?? new Date('2026-01-01'),
}; };
} }
@@ -123,11 +128,10 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
*/ */
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow { function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
const row = makeRow(overrides); 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`); const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
fs.mkdirSync(path.dirname(absolute), { recursive: true }); fs.mkdirSync(path.dirname(absolute), { recursive: true });
fs.writeFileSync(absolute, bytes); fs.writeFileSync(absolute, bytes);
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath }; return row;
} }
function storedFile(userId: string, id: string, ext = 'png'): string { function storedFile(userId: string, id: string, ext = 'png'): string {
@@ -148,7 +152,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
const dashboardImage: ModelMethods = { const dashboardImage: ModelMethods = {
findMany: vi.fn(async (raw: unknown) => { findMany: vi.fn(async (raw: unknown) => {
const args = raw as { const args = raw as {
where: { tenantId?: string; userId?: string; storagePath?: string | null }; where: { tenantId?: string; userId?: string };
select?: Record<string, boolean>; select?: Record<string, boolean>;
}; };
const where = args.where ?? {}; const where = args.where ?? {};
@@ -156,9 +160,6 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
.filter((r) => { .filter((r) => {
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false; if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
if (where.userId !== undefined && r.userId !== where.userId) 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; return true;
}) })
.slice() .slice()
@@ -195,11 +196,11 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}), }),
}; };
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) { function wrap(tenantId: string, userId?: string) {
const wrapped: ModelMethods = {}; const wrapped: ModelMethods = {};
for (const method of Object.keys(dashboardImage)) { for (const method of Object.keys(dashboardImage)) {
wrapped[method] = async (...args: unknown[]) => { wrapped[method] = async (...args: unknown[]) => {
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method }); boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
return dashboardImage[method](...args); return dashboardImage[method](...args);
}; };
} }
@@ -211,10 +212,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
__rows: rows, __rows: rows,
__boundCallLog: boundCallLog, __boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string, userId?: string) { __makeBoundClient(tenantId: string, userId?: string) {
return wrap('tenant', tenantId, userId); return wrap(tenantId, userId);
},
__makeSystemClient() {
return wrap('system', '', undefined);
}, },
}; };
return fake; return fake;
@@ -340,37 +338,6 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
expect(Buffer.from(result.data).equals(PNG)).toBe(true); expect(Buffer.from(result.data).equals(PNG)).toBe(true);
}); });
it('Test 10b: getBytes — Datei fehlt, aber die alte Spalte `data` traegt die Bytes noch: wiederherstellen statt 404', async () => {
// Fall aus dem Browser-Rundgang 22.09.2026: ein `pg_dump` von vor dem Umzug
// traegt die Bytes noch, das Volume `user-files` wird getrennt gesichert —
// wer nur den Abzug zurueckspielt, haette sonst Zeilen ohne Datei.
const row = makeRow({
id: 'img-alt',
data: PNG,
storagePath: 'user-files/dashboard-images/user-1/img-alt.png',
});
const prisma = makeFakePrisma([row]);
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(false);
const result = await makeService(prisma).getBytes('img-alt', 'user-1', 'tenant-1');
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(true);
expect(fs.readFileSync(storedFile('user-1', 'img-alt')).equals(PNG)).toBe(true);
});
it('Test 10c: getBytes — Datei fehlt UND `data` ist leer -> 404', async () => {
const row = makeRow({
id: 'img-weg',
data: null,
storagePath: 'user-files/dashboard-images/user-1/img-weg.png',
});
const prisma = makeFakePrisma([row]);
await expect(makeService(prisma).getBytes('img-weg', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
});
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([
makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'eigen' }),
@@ -399,13 +366,13 @@ 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; der // Jeder Modellaufruf steht im Protokoll des gebundenen Klienten. Seit
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die // Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
// UUID der Zeile erst nach `create` feststeht (hk4). // MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
const methods = prisma.__boundCallLog.map((c) => c.method); const methods = prisma.__boundCallLog.map((c) => c.method);
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']); expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
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');
} }
@@ -421,10 +388,15 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
expect(fs.existsSync(onDisk)).toBe(true); expect(fs.existsSync(onDisk)).toBe(true);
expect(fs.readFileSync(onDisk).equals(PNG)).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`); 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). // Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
expect(prisma.__rows[0].data).toBeNull(); // die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
// die Zeile.
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> }; const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
expect(createArgs.data.data).toBeUndefined(); expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
expect(createArgs.data.id).toBe(result.id);
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(createArgs.data).not.toHaveProperty('data');
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
}); });
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 () => { 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 () => {
@@ -435,7 +407,7 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen. // Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
expect(result.mimeType).toBe('image/jpeg'); expect(result.mimeType).toBe('image/jpeg');
const stored = prisma.__rows[0].storagePath ?? ''; const stored = prisma.__rows[0].storagePath;
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`); expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
expect(stored).not.toContain('passwd'); expect(stored).not.toContain('passwd');
expect(stored).not.toContain('..'); expect(stored).not.toContain('..');
@@ -462,10 +434,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
} }
}); });
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => { it('Test 16: getBytes liest den Dateiinhalt unter dem Pfad aus der Zeile', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]); const prisma = makeFakePrisma([makeStoredRow({ id: 'img-16' }, JPEG)]);
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1'); const result = await makeService(prisma).getBytes('img-16', 'user-1', 'tenant-1');
expect(Buffer.from(result.data).equals(PNG)).toBe(true); expect(Buffer.from(result.data).equals(JPEG)).toBe(true);
}); });
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => { it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
@@ -479,11 +451,6 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
); );
}); });
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 () => { it('Test 19: remove loescht Zeile UND Datei', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]); const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
const onDisk = storedFile('user-1', 'weg'); const onDisk = storedFile('user-1', 'weg');
@@ -504,69 +471,3 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
expect(prisma.__rows).toHaveLength(0); 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');
});
});
@@ -4,12 +4,12 @@ import {
InternalServerErrorException, InternalServerErrorException,
Logger, Logger,
NotFoundException, NotFoundException,
type OnApplicationBootstrap,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as path from 'node:path'; 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 { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { 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,
@@ -46,13 +46,22 @@ import {
* (T-HK4-02); das Volume haengt in keinem Webserver. * (T-HK4-02); das Volume haengt in keinem Webserver.
* *
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST * HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
* die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert * die Zeile (mit der vom Dienst vergebenen UUID und dem daraus gebildeten
* das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim * Pfad), 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 * Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als * wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort * eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
* 404 und die Kachel zeigt „Bild nicht verfügbar". * 404 und die Kachel zeigt „Bild nicht verfügbar".
* *
* STUFE 2 DER UMSTELLUNG (quick-260924-m4n, Migration
* 20260924120000_dashboard_image_drop_data): die alte Spalte `data` ist
* weg, `storagePath` ist Pflicht. Mit ihr sind der Bootstrap-Umzug
* (`onApplicationBootstrap()` mit `forSystem()`) und die Selbstheilung aus
* `data` in `getBytes` entfallen — der Umzug hatte auf allen Servern seine
* Arbeit getan, die Migration bricht ab, falls doch noch eine Zeile ohne
* Pfad existiert.
*
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND * Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile * gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen, * holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
@@ -145,6 +154,33 @@ function relativeStoragePath(userId: string, id: string, extension: string): str
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`; return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
} }
/**
* Servergenerierter relativer Pfad fuer ein neues Bild: UUID der Zeile plus
* Endung aus dem ERKANNTEN Typ (T-HK4-01).
*/
function storagePathFor(userId: string, id: string, mimeType: string): string {
const extension = extensionFor(mimeType);
if (extension === null) {
// detectImageMime liefert nur die vier bekannten Typen; ein anderer
// Wert hier waere ein Programmierfehler, kein Benutzerfehler.
throw new InternalServerErrorException(`Unbekannter Bildtyp '${mimeType}'`);
}
return relativeStoragePath(userId, id, extension);
}
/**
* Schreibt die Bytes an den servergenerierten Ort. Der Ordner je Benutzer
* entsteht dabei (`recursive: true`).
*/
async function writeImageFile(storagePath: string, bytes: Uint8Array): Promise<void> {
const absolute = absoluteImagePath(storagePath);
if (absolute === null) {
throw new Error(`Ungueltiger Ablageort '${storagePath}'`);
}
await fs.mkdir(path.dirname(absolute), { recursive: true });
await fs.writeFile(absolute, bytes);
}
/** /**
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im * Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht * Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
@@ -162,69 +198,11 @@ function absoluteImagePath(storagePath: string): string | null {
} }
@Injectable() @Injectable()
export class DashboardImagesService implements OnApplicationBootstrap { export class DashboardImagesService {
private readonly logger = new Logger(DashboardImagesService.name); 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);
@@ -240,10 +218,12 @@ export class DashboardImagesService implements OnApplicationBootstrap {
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte, * begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
* die Zeile haelt den Pfad. * die Zeile haelt den Pfad.
* *
* Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der * Reihenfolge (T-HK4-04): die UUID vergibt der Dienst selbst
* Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des * (`randomUUID()`, dieselbe Form wie Prismas `@default(uuid())`), damit
* Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine * die Zeile ihren Pfad gleich beim Anlegen traegt — `storagePath` ist seit
* Zeile ohne Datei. * Stufe 2 Pflicht. Zeile zuerst, dann die Datei; scheitert das Schreiben,
* 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) {
@@ -265,8 +245,12 @@ export class DashboardImagesService implements OnApplicationBootstrap {
); );
} }
const id = randomUUID();
const storagePath = storagePathFor(user.id, id, mimeType);
const created = await tenantPrisma.dashboardImage.create({ const created = await tenantPrisma.dashboardImage.create({
data: { data: {
id,
storagePath,
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),
@@ -277,11 +261,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
}); });
try { try {
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer); await writeImageFile(storagePath, file.buffer);
await tenantPrisma.dashboardImage.update({
where: { id: created.id },
data: { storagePath },
});
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${ `Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
@@ -297,8 +277,8 @@ export class DashboardImagesService implements OnApplicationBootstrap {
/** /**
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> * Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
* 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad * 404. Gelesen wird die Datei; ein ungueltiger Pfad und eine fehlende
* (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404. * Datei ergeben denselben 404.
*/ */
async getBytes( async getBytes(
id: string, id: string,
@@ -311,7 +291,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
throw new NotFoundException(`Image with id '${id}' not found`); throw new NotFoundException(`Image with id '${id}' not found`);
} }
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath); const absolute = absoluteImagePath(row.storagePath);
if (absolute === null) { if (absolute === null) {
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`); this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
throw new NotFoundException(`Image with id '${id}' not found`); throw new NotFoundException(`Image with id '${id}' not found`);
@@ -321,26 +301,6 @@ export class DashboardImagesService implements OnApplicationBootstrap {
const data = await fs.readFile(absolute); const data = await fs.readFile(absolute);
return { mimeType: row.mimeType, data }; return { mimeType: row.mimeType, data };
} catch (error) { } catch (error) {
// Selbstheilung waehrend der Umstellung (T-HK4-03): fehlt die Datei,
// steckt aber noch die alte Spalte `data` in der Zeile, wird die Datei
// daraus neu geschrieben und ausgeliefert. Der Fall ist real: ein
// `pg_dump` aus der Zeit vor dem Umzug traegt die Bytes noch, das
// Volume `user-files` wird getrennt gesichert — wer nur den Abzug
// zurueckspielt, haette sonst Zeilen ohne Datei. Nach dem Entfernen der
// Spalte (eigenes Todo) faellt dieser Zweig ersatzlos weg.
if (row.data !== null) {
try {
await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
this.logger.log(`Bilderrahmen-Bild ${id} aus der Datenbank wiederhergestellt`);
return { mimeType: row.mimeType, data: row.data };
} catch (writeError) {
this.logger.error(
`Bilderrahmen-Bild ${id} konnte nicht wiederhergestellt werden: ${
writeError instanceof Error ? writeError.message : String(writeError)
}`,
);
}
}
this.logger.warn( this.logger.warn(
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${ `Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
@@ -363,7 +323,7 @@ export class DashboardImagesService implements OnApplicationBootstrap {
} }
await tenantPrisma.dashboardImage.delete({ where: { id } }); await tenantPrisma.dashboardImage.delete({ where: { id } });
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath); const absolute = absoluteImagePath(row.storagePath);
if (absolute !== null) { if (absolute !== null) {
try { try {
await fs.unlink(absolute); await fs.unlink(absolute);
@@ -378,31 +338,4 @@ export class DashboardImagesService implements OnApplicationBootstrap {
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;
}
} }
@@ -173,9 +173,16 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
* `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen * `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen
* Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber * Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber
* `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe. * `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe.
*
* quick-260924-m4n: der SIEBTE FALL ist wieder ENTFERNT. Stufe 2 der
* Bilderrahmen-Umstellung (Migration 20260924120000_dashboard_image_drop_data)
* loescht die Spalte `data`; der Bootstrap-Umzug in
* `dashboard-images.service.ts` hat damit nichts mehr zu lesen und ist samt
* seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. 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/proxmox/proxmox.service.ts', 1], ['apps/api/src/proxmox/proxmox.service.ts', 1],
+1 -1
View File
@@ -623,7 +623,7 @@ export class ProxmoxService {
* `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE * `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE
* `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst * `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst
* keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden * keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden
* (Muster `DkvSchedulerService`/`DashboardImagesService`, einmal lesen, * (Muster `DkvSchedulerService`, einmal lesen,
* viele bedienen). * viele bedienen).
*/ */
async loadActiveServersForScheduler(): Promise< async loadActiveServersForScheduler(): Promise<
+29
View File
@@ -221,6 +221,35 @@ der `.env` als `IMAGE_TAG` steht, siehe Kapitel 9.)
sie auf dem Server abweichen.) Liegt `StartedAt` **vor** `Created` des Images, läuft sie auf dem Server abweichen.) Liegt `StartedAt` **vor** `Created` des Images, läuft
noch die alte Version – dann `--force-recreate` nachholen. noch die alte Version – dann `--force-recreate` nachholen.
**Hinweis für die erste Version nach 1.3.1 – alte Bildspalte fällt weg:** Diese
Version entfernt die alte Spalte, in der die Bilder des Bilderrahmen-Widgets
früher in der Datenbank lagen (Migration `20260924120000_dashboard_image_drop_data`).
Die Bilder selbst liegen seit 1.3.1 im Volume `user-files`; 1.3.1 hat sie beim
ersten Start von selbst dorthin umgezogen. Hat ein Server 1.3.1 übersprungen, wäre
der Umzug dort nie gelaufen – dann bricht die Migration ab, **bevor** sie etwas
ändert, und der `api`-Container startet nicht. In `docker compose logs api` steht
dann die Meldung „DashboardImage: es gibt noch Zeilen ohne storagePath — Umzug
(quick-260922-hk4) zuerst mit einer Version >= 1.3.1 laufen lassen, dann erneut
deployen“. Es gehen dabei keine Bilder verloren. Abhilfe in drei Schritten:
1. Den abgebrochenen Versuch als zurückgenommen vermerken – sonst verweigert
auch 1.3.1 jeden Start, weil Prisma eine fehlgeschlagene Migration in der
Datenbank sieht (Fehler `P3009`):
```bash
docker compose -f docker-compose.prod.yml exec db \
psql -U tessera -d tessera -c \
"UPDATE _prisma_migrations SET rolled_back_at = now() WHERE migration_name = '20260924120000_dashboard_image_drop_data' AND finished_at IS NULL;"
```
2. In der `.env` `IMAGE_TAG=v1.3.1` setzen, `pull` und `--force-recreate` wie
oben, den Start abwarten – der Umzug läuft dabei von selbst.
3. `IMAGE_TAG` zurück auf den Kanal (`live` bzw. `beta`) und erneut einspielen;
jetzt läuft die Migration durch.
(Dieser Ablauf ist am 24.09.2026 in einer Wegwerf-Datenbank vollständig
durchgespielt.)
## 5. Datenbank-Migrationen ## 5. Datenbank-Migrationen
Ein separater Migrationsschritt ist **nicht** nötig. Der `api`-Container führt Ein separater Migrationsschritt ist **nicht** nötig. Der `api`-Container führt
@@ -168,15 +168,15 @@ 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 | 28 | 1 | **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **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) | | dashboard | 1 | 29 | 0 | **quick-260924-m4n (Stufe 2 der Bilderrahmen-Umstellung):** nachgemessen mit der Gate-Schleife 1/29/0 — die Zeile nannte zuletzt 1/28/1, gemessen waren vor dieser Änderung aber schon 1/31/1: quick-260923-lrr hatte in `dashboard.service.ts` zwei gebundene `tenantPrisma.favoriteLink.`-Rohtreffer (Aufräumen hochgeladener Favoriten-Symbole) hinzugefügt, ohne diese Zeile nachzuziehen, und die ad9-Zählung lag um eins zu niedrig. Diese Änderung selbst: −2 gebunden und −1 System in `dashboard-images.service.ts` — der Bootstrap-Umzug ist entfernt (sein `systemPrisma.dashboardImage.findMany` und sein je Zeile gebundenes `update`), und der Upload legt die Zeile gleich MIT `storagePath` an (UUID vom Dienst), das nachträgliche `update` entfällt. Übrig in `dashboard-images.service.ts`: 7 gebundene Rohtreffer (`findMany`, `count`, `create`, `delete` beim Zurücknehmen, zweimal `findUnique`, `delete`). Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **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 | 12 | 0 | **Nachgemessen quick-260924-m4n: 12 gebundene Rohtreffer** — die Zeile nannte 8; die vier weiteren `tenantPrisma.favoriteLink.`-Rohtreffer kamen mit quick-260923-lrr (Favoriten-Symbol hochladen/ausliefern/entfernen) in `favorites.service.ts` hinzu, ohne dass die Zeile nachgezogen wurde. Vorher: **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 |
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) | | proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
| **Summe** | **61** | **208** | **7** | **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **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 | | **Summe** | **61** | **213** | **6** | **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **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, 77 Paare) ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 77 Paare)
@@ -691,7 +691,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 | 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 | gebunden | **quick-260924-m4n:** Stand zurück von `system-gebunden` auf `gebunden`. Stufe 2 der Umstellung (Migration 20260924120000_dashboard_image_drop_data) löscht die Spalte `data` und macht `storagePath` zur Pflicht; der Bootstrap-Umzug hatte auf allen Servern gearbeitet und ist samt seinem einzigen `forSystem()`-Aufruf entfernt (Eintrag aus `FORSYSTEM_ALLOWED_CALL_SITES` gestrichen). Dieselbe Migration entfernt die `system_read_policy` auf "DashboardImage" — auf der Tabelle bleibt allein `tenant_isolation_policy` (Mandant UND Benutzer). Alle vier Anfragewege laufen wie bisher ausschließlich über `forTenant(this.prisma, tenantId, userId)`; der Upload vergibt die UUID jetzt selbst und legt die Zeile gleich mit Pfad an. Vorher: **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 | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `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). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. | | apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `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). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. |
| 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 | 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 | favoriteLink | muss-mandantengebunden | gebunden | quick-260923-lrr — nur LESEND, zum Aufraeumen hochgeladener Favoriten-Symbole: `removeWidget` und `deleteDashboard` lesen VOR dem Loeschen die Favoriten mit hochgeladenem Symbol (`findMany`, Bedingung traegt `userId` UND `widgetId`) ueber DENSELBEN, bereits gebundenen Klienten `tenantPrisma` der Methode (kein zweiter `forTenant()`-Aufruf). Die Zeilen selbst verschwinden ueber den Fremdschluessel-Kaskadenweg; danach werden die Dateien best effort entfernt, ein Dateifehler bricht das Loeschen nie ab. | | apps/api/src/dashboard/dashboard.service.ts | favoriteLink | muss-mandantengebunden | gebunden | quick-260923-lrr — nur LESEND, zum Aufraeumen hochgeladener Favoriten-Symbole: `removeWidget` und `deleteDashboard` lesen VOR dem Loeschen die Favoriten mit hochgeladenem Symbol (`findMany`, Bedingung traegt `userId` UND `widgetId`) ueber DENSELBEN, bereits gebundenen Klienten `tenantPrisma` der Methode (kein zweiter `forTenant()`-Aufruf). Die Zeilen selbst verschwinden ueber den Fremdschluessel-Kaskadenweg; danach werden die Dateien best effort entfernt, ein Dateifehler bricht das Loeschen nie ab. |
@@ -830,9 +830,13 @@ 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; seit 260922-hk4 `system_read_policy … FOR SELECT` auf fünf Tabellen; seit 260923-dhh
kommt "DashboardImage" als sechste dazu, angelegt in der Migration kommt "ProxmoxServer" als sechste dazu, Migration 20260923140000.
20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder), "DashboardImage" trug die Regel nur vorübergehend — angelegt in
20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder, mit
Stufe 2 der Umstellung wieder entfernt, Migration
20260924120000_dashboard_image_drop_data, quick-260924-m4n; gemessen
24.09.2026 lokal: sechs Tabellen mit der Regel),
Schwesterhelfer 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