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:
+18
@@ -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";
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user