Compare commits
4 Commits
441854af72
...
ee2b0256b5
| Author | SHA1 | Date | |
|---|---|---|---|
| ee2b0256b5 | |||
| 82472ee665 | |||
| 8cbfb8b69d | |||
| 9039cea686 |
+8
-7
@@ -4,10 +4,10 @@ milestone: v1.3
|
|||||||
current_phase: 18
|
current_phase: 18
|
||||||
current_phase_name: desktop-client-fertigstellen
|
current_phase_name: desktop-client-fertigstellen
|
||||||
status: verified
|
status: verified
|
||||||
stopped_at: "22.09.2026: VERSION 1.3.0 FREIGEGEBEN (Tag v1.3.0, Commit d146234; Abbilder live + v1.3.0 fuer web und api, Gitea-Release Tessera 1.3.0 mit Tessera-Setup-1.3.0.exe und Tessera-1.3.0.AppImage; CI 408/409/410 alle gruen). Inhalt: Bilderrahmen- und XFrame-Widget (inkl. Ausschnitt/Zoom/Nur-anzeigen), Favoriten-Sortierung, Desktop-App mit Server-Anzeige/-Wechsel und Update in der App, Bildmarke in Akzentfarbe, Herkunft in Fehlermeldungen, drei Korrekturen vom 22.09. OFFEN BEIM NUTZER: auf dem Live-Server pullen (docker compose -f docker-compose.prod.yml pull && up -d --force-recreate api web). Der Basic-Auth vor alpha bleibt (Nutzerentscheidung, intern Ausnahme) — nicht ansprechen. Kein weiterer Auftrag benannt."
|
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||||
last_updated: "2026-09-22T12:15:00.000Z"
|
last_updated: "2026-09-22T13:40:00.000Z"
|
||||||
last_activity: 2026-09-21
|
last_activity: 2026-09-21
|
||||||
last_activity_desc: Freigabe 1.3.0 — CHANGELOG abgeschlossen, live auf main vorgezogen, Tag v1.3.0 gepusht; CI 408/409/410 gruen, Abbilder live/v1.3.0 und Gitea-Release mit beiden Desktop-Paketen
|
last_activity_desc: Quick 260922-hk4 — Bilderrahmen-Bilder in den Dateibereich umgezogen (automatisch beim Start, Selbstheilung aus der alten Spalte), im Browser nachgewiesen; davor Freigabe 1.3.0
|
||||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||||
progress:
|
progress:
|
||||||
total_phases: 18
|
total_phases: 18
|
||||||
@@ -461,6 +461,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
|||||||
| 260922-frg | **Desktop-Client: Update-Eintrag im Tray nie mehr stumm ausgegraut.** Befund des Nutzers: „Update installieren“ bleibt grau, obwohl alpha `1.2.0-beta.gc001a08` anbietet und der Client auf `a6d1a64` steht — auch nach App-Neustart. Nachgemessen: Tessera-seitig antwortet `/desktop/update` auf dem alpha-Server selbst (am Proxy vorbei) mit 200 und gueltigem Manifest; DAVOR antwortet der Nginx Proxy Manager auf jede Anfrage an alpha mit `401 Basic` (vom Dev-Host und vom Testserver ueber 217.7.63.32 gemessen). Die Webansicht der App merkt sich das Proxy-Passwort, der Updater (`tauri-plugin-updater`, eigener reqwest) nicht. **Produktfehler:** das Plugin verschluckt Nicht-2xx-Status (`updater.rs` 529-559: `last_error` bleibt leer → `Err(ReleaseNotFound)`), unser `Err(_) => {}` machte daraus stumm denselben grauen Eintrag wie „kein Update“; geprueft wurde nur beim Start. **Fix (d73aad1, nur lib.rs + CHANGELOG):** drei Endzustaende, alle anklickbar — „Auf Beta-Stand … aktualisieren“ (installiert), „Kein Update verfügbar – erneut prüfen“, „Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen“ (Statuscode per eigener Diagnose-Anfrage nachgeliefert, nur Status gelesen); Benachrichtigung mit Erklaerung (Passwortschutz/Zugriffsliste am Proxy), entprellt ueber `LastCheckNotice`; Wiederhol-Thread alle 4 h (`std::thread`, ueberspringt bei abgelegtem Update); http-Server weiterhin „Update nur über https möglich“. Proxy-Zugangsdaten NICHT in den Client (T-FRG-03). `cargo fmt/clippy/test/build` gruen, 37 → 44 Tests, Rot-Nachweis 9x E0425. **Behebung beim Nutzer:** Passwortschutz vor alpha im Proxy Manager entfernen oder `/api-proxy/desktop/*` durchlassen; neuen Client einmal ueber den Browser installieren. | 2026-09-22 | d73aad1 | [260922-frg-desktop-client-update-eintrag-im-tray-ni](./quick/260922-frg-desktop-client-update-eintrag-im-tray-ni/) |
|
| 260922-frg | **Desktop-Client: Update-Eintrag im Tray nie mehr stumm ausgegraut.** Befund des Nutzers: „Update installieren“ bleibt grau, obwohl alpha `1.2.0-beta.gc001a08` anbietet und der Client auf `a6d1a64` steht — auch nach App-Neustart. Nachgemessen: Tessera-seitig antwortet `/desktop/update` auf dem alpha-Server selbst (am Proxy vorbei) mit 200 und gueltigem Manifest; DAVOR antwortet der Nginx Proxy Manager auf jede Anfrage an alpha mit `401 Basic` (vom Dev-Host und vom Testserver ueber 217.7.63.32 gemessen). Die Webansicht der App merkt sich das Proxy-Passwort, der Updater (`tauri-plugin-updater`, eigener reqwest) nicht. **Produktfehler:** das Plugin verschluckt Nicht-2xx-Status (`updater.rs` 529-559: `last_error` bleibt leer → `Err(ReleaseNotFound)`), unser `Err(_) => {}` machte daraus stumm denselben grauen Eintrag wie „kein Update“; geprueft wurde nur beim Start. **Fix (d73aad1, nur lib.rs + CHANGELOG):** drei Endzustaende, alle anklickbar — „Auf Beta-Stand … aktualisieren“ (installiert), „Kein Update verfügbar – erneut prüfen“, „Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen“ (Statuscode per eigener Diagnose-Anfrage nachgeliefert, nur Status gelesen); Benachrichtigung mit Erklaerung (Passwortschutz/Zugriffsliste am Proxy), entprellt ueber `LastCheckNotice`; Wiederhol-Thread alle 4 h (`std::thread`, ueberspringt bei abgelegtem Update); http-Server weiterhin „Update nur über https möglich“. Proxy-Zugangsdaten NICHT in den Client (T-FRG-03). `cargo fmt/clippy/test/build` gruen, 37 → 44 Tests, Rot-Nachweis 9x E0425. **Behebung beim Nutzer:** Passwortschutz vor alpha im Proxy Manager entfernen oder `/api-proxy/desktop/*` durchlassen; neuen Client einmal ueber den Browser installieren. | 2026-09-22 | d73aad1 | [260922-frg-desktop-client-update-eintrag-im-tray-ni](./quick/260922-frg-desktop-client-update-eintrag-im-tray-ni/) |
|
||||||
| fast-260922-b | **Desktop-App: Download-Knoepfe in der App ohne Funktion (fast, 747a4d4).** Befund des Nutzers: „Herunterladen“ unter Einstellungen → Desktop-App tut in der App nichts (Windows und Linux). Ursache: die Webansicht hatte keinen Download-Handler — webkit2gtk verwirft Downloads dann still, WebView2 zeigte ebenfalls nichts. Fix: Hauptfenster entsteht im Code (`app.windows` in tauri.conf.json leer), weil nur `WebviewWindowBuilder` `on_download` annimmt; der Handler bricht den Download in der App ab und oeffnet die Adresse per Opener im System-Browser (Fortschritt, Speicherort, Passwortfenster fuer den Proxy). Capability `main` unveraendert. cargo fmt/clippy/test gruen. Nicht am laufenden Client geprueft (kein Display auf dem Dev-Host) — CI baut, Nachweis beim Nutzer oder auf der Windows-VM. | 2026-09-22 | 747a4d4 | — |
|
| fast-260922-b | **Desktop-App: Download-Knoepfe in der App ohne Funktion (fast, 747a4d4).** Befund des Nutzers: „Herunterladen“ unter Einstellungen → Desktop-App tut in der App nichts (Windows und Linux). Ursache: die Webansicht hatte keinen Download-Handler — webkit2gtk verwirft Downloads dann still, WebView2 zeigte ebenfalls nichts. Fix: Hauptfenster entsteht im Code (`app.windows` in tauri.conf.json leer), weil nur `WebviewWindowBuilder` `on_download` annimmt; der Handler bricht den Download in der App ab und oeffnet die Adresse per Opener im System-Browser (Fortschritt, Speicherort, Passwortfenster fuer den Proxy). Capability `main` unveraendert. cargo fmt/clippy/test gruen. Nicht am laufenden Client geprueft (kein Display auf dem Dev-Host) — CI baut, Nachweis beim Nutzer oder auf der Windows-VM. | 2026-09-22 | 747a4d4 | — |
|
||||||
| 260922-ge2 | **XFrame: Ausschnitt der Seite waehlen und einpassen, Zoom, „Nur anzeigen“.** Wunsch des Nutzers: nur einen bestimmten Ausschnitt der eingebetteten Seite zeigen, und die Groesse soll skalieren. Config: `crop {x,y,w,h}` in Seitenpixeln bei fester Layoutbreite 1280 (`XFRAME_PAGE_WIDTH`, keine UI), Klemmung ueber EINE Funktion `clampXframeCrop` (x+w ≤ 1280 verschiebt x; w ≥ 100, h ≥ 60, y+h ≤ 4000); `zoom` (50…150 %, nur Ganzseiten-Modus); `readOnly` (transparente Flaeche ueber dem Rahmen im Ansichtsmodus). Kachel: `computeCropLayout` (contain + Zentrierung, Massstab darf > 1 sein), der `<iframe>` wird selbst verschoben und skaliert (cross-origin — die Seite laesst sich von aussen nicht scrollen), Kachelmass per ResizeObserver. Einstellungen: Vorschau der Seite bei 1280 px (Stage 3000 Seitenpixel hoch, eigener Bildlauf), Rahmen als `<fieldset>` (Biome `useSemanticElements`) mit vier Eckgriffen, Ziehen per Pointer-Events mit lokalem Entwurf und genau einem PATCH beim Loslassen, Zahlenfelder als Tastaturweg; Zoom-Auswahl nur ohne Ausschnitt; Aktivieren setzt `readOnly` mit. **Befund im Browser-Rundgang, behoben (cf70a19):** Kachel und Vorschau hatten verschiedene Rahmenhoehen (max(y+h,720) vs. 3000) — bei vh-relativen Seiten (example.com `margin: 15vh`) lag derselbe Inhalt an verschiedenen Stellen, der gewaehlte Ausschnitt haette in der Kachel daneben gelegen; jetzt dieselbe Layouthoehe. Neun Pruefpunkte bestanden (Verschieben, Ecken mit fester Gegenecke und Mindestbreite, Klemmung der Zahlenfelder, Einpassen und Mitskalieren bei Kachelgroesse, Nur-anzeigen, Zoom 60 %, verweigernde Seite). Playwright kann in einem per `transform` skalierten iframe nicht selbst klicken — per `elementFromPoint` + `mouse.click` umgangen, ist eine Werkzeuggrenze. Test-Helfer `src/test/fake-resize-observer.ts`. **Zahlen:** web 604 → 640, api 1175, type-check 4/4, lint 5/5 (web 53 Warnungen unveraendert), `as unknown as` 27/6, Umlaut-Allowlist + „Ausschnitt“. | 2026-09-22 | 445b1d3,30fdd99,cf70a19 | [260922-ge2-xframe-widget-ausschnitt-der-eingebettet](./quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/) |
|
| 260922-ge2 | **XFrame: Ausschnitt der Seite waehlen und einpassen, Zoom, „Nur anzeigen“.** Wunsch des Nutzers: nur einen bestimmten Ausschnitt der eingebetteten Seite zeigen, und die Groesse soll skalieren. Config: `crop {x,y,w,h}` in Seitenpixeln bei fester Layoutbreite 1280 (`XFRAME_PAGE_WIDTH`, keine UI), Klemmung ueber EINE Funktion `clampXframeCrop` (x+w ≤ 1280 verschiebt x; w ≥ 100, h ≥ 60, y+h ≤ 4000); `zoom` (50…150 %, nur Ganzseiten-Modus); `readOnly` (transparente Flaeche ueber dem Rahmen im Ansichtsmodus). Kachel: `computeCropLayout` (contain + Zentrierung, Massstab darf > 1 sein), der `<iframe>` wird selbst verschoben und skaliert (cross-origin — die Seite laesst sich von aussen nicht scrollen), Kachelmass per ResizeObserver. Einstellungen: Vorschau der Seite bei 1280 px (Stage 3000 Seitenpixel hoch, eigener Bildlauf), Rahmen als `<fieldset>` (Biome `useSemanticElements`) mit vier Eckgriffen, Ziehen per Pointer-Events mit lokalem Entwurf und genau einem PATCH beim Loslassen, Zahlenfelder als Tastaturweg; Zoom-Auswahl nur ohne Ausschnitt; Aktivieren setzt `readOnly` mit. **Befund im Browser-Rundgang, behoben (cf70a19):** Kachel und Vorschau hatten verschiedene Rahmenhoehen (max(y+h,720) vs. 3000) — bei vh-relativen Seiten (example.com `margin: 15vh`) lag derselbe Inhalt an verschiedenen Stellen, der gewaehlte Ausschnitt haette in der Kachel daneben gelegen; jetzt dieselbe Layouthoehe. Neun Pruefpunkte bestanden (Verschieben, Ecken mit fester Gegenecke und Mindestbreite, Klemmung der Zahlenfelder, Einpassen und Mitskalieren bei Kachelgroesse, Nur-anzeigen, Zoom 60 %, verweigernde Seite). Playwright kann in einem per `transform` skalierten iframe nicht selbst klicken — per `elementFromPoint` + `mouse.click` umgangen, ist eine Werkzeuggrenze. Test-Helfer `src/test/fake-resize-observer.ts`. **Zahlen:** web 604 → 640, api 1175, type-check 4/4, lint 5/5 (web 53 Warnungen unveraendert), `as unknown as` 27/6, Umlaut-Allowlist + „Ausschnitt“. | 2026-09-22 | 445b1d3,30fdd99,cf70a19 | [260922-ge2-xframe-widget-ausschnitt-der-eingebettet](./quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/) |
|
||||||
|
| 260922-hk4 | **Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Datenbank.** Frage des Nutzers nach der Freigabe 1.3.0, ob `bytea` auf Dauer sinnvoll ist. Befund: Geschwindigkeit ist NICHT das Argument (ein Bild wird je Browser einmal taeglich geladen), die SICHERUNG ist es — gesichert wird von Hand per `pg_dump`, und 30 Bilder à 5 MiB je Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug (alpha-DB heute 18 MB). Dazu Einheitlichkeit: Profilbilder (`user-files/avatars`, `User.avatarPath`) und DKV-Exporte liegen laengst im Volume. Umsetzung: Spalte `storagePath`, Ablage `user-files/dashboard-images/<userId>/<uuid>.<ext>` — Dateiname IMMER vom Server (UUID + Endung aus dem erkannten Mime-Typ), `originalName` nie im Pfad; ein eigener Ordner je Benutzer ist ausdruecklich KEIN Schutz, es entscheidet weiterhin die Besitzpruefung im Dienst. Umzug laeuft automatisch beim Start (`onApplicationBootstrap` ueber `forSystem()`), idempotent; die Spalte `data` bleibt bewusst vorerst stehen (Todo fuer den DROP, erst wenn alpha und live einmal gelaufen sind). **Befund im Rundgang, eigener Commit:** eine Zeile zeigte auf eine fehlende Datei (lokal Host vs. Container-Volume; im Betrieb: alter `pg_dump` + leeres Volume) — `getBytes` stellt die Datei jetzt aus der noch vorhandenen Spalte `data` wieder her, statt 404 zu melden. **Zahlen:** api 1175 → 1188, web 640, type-check 4/4, lint 5/5, RLS-Waechter 78/78. | 2026-09-22 | 9039cea,8cbfb8b,82472ee | [260922-hk4-bilderrahmen-bilder-auf-die-festplatte](./quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
@@ -502,8 +503,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-09-22T12:15:00Z
|
Last session: 2026-09-22T13:40:00Z
|
||||||
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; danach Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, zuletzt die Freigabe 1.3.0.
|
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; seitdem Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, Freigabe 1.3.0, Bilder in den Dateibereich.
|
||||||
Stopped at: Version 1.3.0 freigegeben und vollstaendig nachgewiesen (Tag, Abbilder live/v1.3.0, Release mit exe + AppImage). Offen beim Nutzer: Live-Server pullen. Kein weiterer Auftrag benannt.
|
Stopped at: hk4 fertig und nachgewiesen. Dem Nutzer vorgelegt: erst das Aufraeumen (Modul bringt seine Kachel selbst mit), dann Proxmox-Modul + Kachel — Antwort steht aus.
|
||||||
Resume file: None
|
Resume file: None
|
||||||
Last activity: 2026-09-22 - Freigabe 1.3.0 (Tag v1.3.0 auf d146234), CI gruen, Release angelegt
|
Last activity: 2026-09-22 - Quick 260922-hk4: Bilderrahmen-Bilder im Dateibereich, Selbstheilung aus der alten Spalte
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260922-hk4
|
||||||
|
plan: 01
|
||||||
|
type: tdd
|
||||||
|
autonomous: true
|
||||||
|
subsystem: apps/api/src/dashboard
|
||||||
|
requirements: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick-Aufgabe 260922-hk4: Bilderrahmen-Bilder auf die Festplatte statt in die Datenbank
|
||||||
|
|
||||||
|
## Warum (Entscheidung des Nutzers, 22.09.2026)
|
||||||
|
|
||||||
|
Der Bilderrahmen legte die Bilddaten als `bytea` in der Datenbank ab
|
||||||
|
(quick-260921-pi9). Der Nutzer hat nach der Freigabe 1.3.0 gefragt, ob das auf
|
||||||
|
Dauer sinnvoll ist. Befund und Entscheidung:
|
||||||
|
|
||||||
|
- **Geschwindigkeit ist NICHT das Argument.** Ein Bild wird je Browser einmal
|
||||||
|
taeglich geladen (`Cache-Control: private, max-age=86400`); ein paar hundert
|
||||||
|
Kilobyte aus Postgres kosten nichts gegen die uebrige Last.
|
||||||
|
- **Die Sicherung ist das Argument.** Gesichert wird von Hand per `pg_dump`
|
||||||
|
(docs/anleitung-betrieb.md Kap. 6). Jedes Bild waechst in diesen Abzug hinein:
|
||||||
|
30 Bilder à 5 MiB je Benutzer sind im Extremfall 150 MB **pro Benutzer**. Die
|
||||||
|
alpha-Datenbank ist heute 18 MB gross (gemessen 22.09.), da faellt das sofort auf.
|
||||||
|
- **Einheitlichkeit.** Tessera speichert Dateien laengst im Volume `user-files`:
|
||||||
|
Profilbilder unter `user-files/avatars/<userId>.<ext>` mit `User.avatarPath` in
|
||||||
|
der Datenbank (`user.controller.ts`), DKV-Exporte daneben mit ausschliesslich
|
||||||
|
servergenerierten Dateinamen (`dkv-export.service.ts`). Der Bilderrahmen war der
|
||||||
|
Ausreisser.
|
||||||
|
- **Ein eigener Ordner je Benutzer ist KEIN Schutz.** Wer welches Bild sehen darf,
|
||||||
|
entscheidet weiterhin der Server (Besitzpruefung + RLS-Regel). Getrennte Ordner
|
||||||
|
bringen zusaetzlich die Gefahr von Dateinamen, die aus dem Ordner herausfuehren —
|
||||||
|
dagegen hilft nur, was DKV schon macht: der Server vergibt den Dateinamen, nie
|
||||||
|
der Client.
|
||||||
|
|
||||||
|
Bestand: alpha 3 Bilder / 1,8 MB, Live 0 (noch nicht gezogen), lokal 1–2. Der
|
||||||
|
Umzug ist jetzt praktisch kostenlos.
|
||||||
|
|
||||||
|
## Gebundene Entscheidungen (Orchestrator)
|
||||||
|
|
||||||
|
1. **Ablage:** `user-files/dashboard-images/<userId>/<imageId>.<ext>` — ein Ordner
|
||||||
|
je Benutzer, Dateiname ist die UUID der Datenbankzeile plus Endung aus dem
|
||||||
|
ERKANNTEN Mime-Typ (`png|jpg|gif|webp`). Kein Byte aus der Anfrage geht in den
|
||||||
|
Pfad. Verzeichnis-Aufloesung nach dem Muster `resolveAvatarsDir()`
|
||||||
|
(`path.resolve(__dirname, '..', '..', '..', '..', 'user-files', ...)`), als
|
||||||
|
eigene Funktion `resolveDashboardImagesDir()` im Dienst.
|
||||||
|
2. **Datenbank:** Spalte `data Bytes` entfaellt, neu `storagePath String` (relativ
|
||||||
|
zur Monorepo-Wurzel, wie `User.avatarPath`: `user-files/dashboard-images/...`).
|
||||||
|
Rest der Zeile unveraendert (id, userId, tenantId, originalName, mimeType, size,
|
||||||
|
createdAt), RLS-Regel und Indizes bleiben.
|
||||||
|
3. **Migration `20260922120000_dashboard_image_to_disk`** in zwei Schritten, weil
|
||||||
|
die vorhandenen Bytes nicht verloren gehen duerfen:
|
||||||
|
- SQL-Migration: `ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;`
|
||||||
|
(erst NULLbar), **nicht** sofort `DROP COLUMN "data"`.
|
||||||
|
- Einmal-Skript `apps/api/scripts/migrate-dashboard-images-to-disk.ts`
|
||||||
|
(ausfuehrbar per `pnpm --filter @tessera/api exec tsx scripts/...`, tsx ist
|
||||||
|
vorhanden — sonst `ts-node`/kompiliertes JS; pruefen): liest alle Zeilen mit
|
||||||
|
`data IS NOT NULL`, schreibt die Datei, setzt `storagePath`, laesst `data`
|
||||||
|
stehen. Idempotent (vorhandene Datei + gesetzter `storagePath` = ueberspringen).
|
||||||
|
- Zweite SQL-Migration `20260922120100_dashboard_image_drop_data`:
|
||||||
|
`ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;` und
|
||||||
|
`ALTER TABLE "DashboardImage" DROP COLUMN "data";`.
|
||||||
|
**Reihenfolge fuer den Betrieb dokumentieren:** beide Migrationen laufen beim
|
||||||
|
Start automatisch (`migrate deploy`), das Umzugs-Skript liegt DAZWISCHEN. Damit
|
||||||
|
das ohne Handarbeit klappt, macht der Dienst den Umzug selbst: siehe Punkt 4.
|
||||||
|
4. **Automatischer Umzug beim Start statt Handarbeit** (der Nutzer soll nichts
|
||||||
|
ausfuehren muessen): `DashboardImagesService` bekommt `onApplicationBootstrap()`,
|
||||||
|
das alle Zeilen ohne `storagePath` einsammelt, die Bytes per rohem SQL liest
|
||||||
|
(`$queryRaw` auf `data`, weil die Spalte dann nicht mehr im Prisma-Modell steht —
|
||||||
|
deshalb liegt der DROP in einer SPAETEREN Migration, die erst in der naechsten
|
||||||
|
Freigabe scharf geschaltet wird), die Datei schreibt und `storagePath` setzt.
|
||||||
|
**Konsequenz fuer diese Aufgabe: die DROP-Migration wird NICHT mitgeliefert.**
|
||||||
|
Sie bekommt einen Platzhalter-Eintrag in `.planning/todos/pending/` und kommt,
|
||||||
|
wenn alle Server einmal mit dieser Version gelaufen sind. Begruendung im
|
||||||
|
Migrations-Kommentar festhalten (Muster: zweistufige Umstellung).
|
||||||
|
Der Bootstrap laeuft ueber den Systemkontext (`forSystem()`, Muster
|
||||||
|
`dkv`-Scheduler), nicht ueber einen Mandantenklienten, und protokolliert
|
||||||
|
„N Bilder auf die Festplatte umgezogen" bzw. schweigt bei 0.
|
||||||
|
5. **Dienst:** `upload` schreibt die Datei (`fs.promises.mkdir(..., {recursive:true})`
|
||||||
|
+ `writeFile`) NACH dem erfolgreichen `create` (Reihenfolge: Zeile zuerst, damit
|
||||||
|
die UUID feststeht; schlaegt das Schreiben fehl, Zeile wieder loeschen und
|
||||||
|
`InternalServerErrorException`). `getBytes` liest die Datei und liefert
|
||||||
|
`{ mimeType, data }` wie bisher; fehlt die Datei, `NotFoundException` (Kachel
|
||||||
|
zeigt dann „Bild nicht verfügbar", schon gebaut). `remove` loescht Zeile und
|
||||||
|
Datei (Datei-Fehler werden geschluckt und protokolliert — eine Dateileiche ist
|
||||||
|
harmloser als eine haengende Loeschung). `list` unveraendert.
|
||||||
|
Der Controller bleibt unveraendert (gleiche Routen, gleiche fuenf Header).
|
||||||
|
6. **Betriebsanleitung:** in Kapitel 6 den Satz zu `user-files` um die
|
||||||
|
Bilderrahmen-Bilder ergaenzen (dort steht schon, wie das Volume gesichert wird);
|
||||||
|
im Anwenderhandbuch nichts aendern (fuer Anwender aendert sich nichts).
|
||||||
|
CHANGELOG unter „Unveröffentlicht → Geändert": „Bilderrahmen: hochgeladene
|
||||||
|
Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die
|
||||||
|
Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten
|
||||||
|
Start automatisch um" (kein Fliesstext).
|
||||||
|
7. **Tests:** Dienst-Tests mit `memfs` ODER einem temporaeren Verzeichnis
|
||||||
|
(`fs.mkdtempSync(os.tmpdir())`) — pruefen, was im Repo schon genutzt wird
|
||||||
|
(`user.controller.spec.ts` fuer Avatare ansehen und demselben Muster folgen).
|
||||||
|
Mindestens: Upload legt Datei unter `<dir>/<userId>/<id>.png` an und speichert
|
||||||
|
`storagePath`; Upload mit fehlschlagendem Schreiben loescht die Zeile wieder;
|
||||||
|
`getBytes` liefert den Dateiinhalt; fehlende Datei → 404; fremder Benutzer → 404
|
||||||
|
(unveraendert); `remove` loescht Zeile und Datei; Dateiname enthaelt NIE
|
||||||
|
`originalName`; Bootstrap-Umzug schreibt Datei und setzt `storagePath`,
|
||||||
|
ueberspringt bereits umgezogene Zeilen.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer" tdd="true">
|
||||||
|
<name>Aufgabe 1: Schema, Migration, Dienst auf Dateiablage umstellen, Bootstrap-Umzug, Tests</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql, apps/api/src/dashboard/dashboard-images.service.ts, apps/api/src/dashboard/dashboard-images.service.spec.ts, apps/api/src/dashboard/dashboard-images.controller.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<action>
|
||||||
|
Entscheidungen 1–5 umsetzen. Reihenfolge: Schema + Migration, `prisma migrate deploy` + `generate` gegen die lokale Container-DB ([BLOCKING], Befehle in den Executor-Hinweisen), dann Tests rot, dann Dienst.
|
||||||
|
Das Klassifikationsdokument braucht keine neue Zeile (Modell unveraendert gebunden), aber die Begruendungsspalte erwaehnt jetzt, dass die Bytes auf der Platte liegen und die Zeile den Pfad haelt — Zahlen nachmessen wie dort beschrieben.
|
||||||
|
Commit: `refactor(quick-260922-hk4): Bilderrahmen-Bilder in user-files statt in der Datenbank`
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/dashboard src/prisma && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Migration angewendet, `storagePath` gefuellt fuer die vorhandenen lokalen Zeilen (Bootstrap nachgewiesen), Dateien liegen unter `user-files/dashboard-images/<userId>/`. Spalte `data` bleibt vorerst bestehen (zweistufig, siehe Plan). API-Tests ≥ 8 neue Faelle, RLS-Waechter unveraendert gruen. Keine `any`, Zaehler unveraendert.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Aufgabe 2: Changelog, Betriebsanleitung, Todo fuer die DROP-Migration, Voll-Tore</name>
|
||||||
|
<files>CHANGELOG.md, docs/anleitung-betrieb.md, .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md</files>
|
||||||
|
<action>
|
||||||
|
Entscheidung 6 umsetzen. Das Todo nennt: DROP der Spalte `data` erst, wenn alpha UND live einmal mit einer Version ≥ dieser gelaufen sind (Bootstrap-Umzug erledigt), Migrationsname `20260922120100_dashboard_image_drop_data`, plus `ALTER COLUMN "storagePath" SET NOT NULL`.
|
||||||
|
Volle Tore: `pnpm type-check`, `pnpm lint`, `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`.
|
||||||
|
Commit: `docs(quick-260922-hk4): Changelog, Betriebsanleitung und Todo zur data-Spalte`
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'Dateibereich' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Changelog-Zeile steht unter „Unveröffentlicht → Geändert"; Betriebsanleitung Kap. 6 nennt die Bilderrahmen-Bilder beim `user-files`-Volume; Todo angelegt; alle Tore gruen; genau zwei Commits mit Scope `quick-260922-hk4`.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
## Hinweise fuer den Executor
|
||||||
|
|
||||||
|
- Lokale Migration: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`, dann
|
||||||
|
`DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` und `prisma generate`.
|
||||||
|
- Testserver NICHT anfassen.
|
||||||
|
- Commits: Conventional Commits, Scope `quick-260922-hk4`, deutscher Betreff im Stil von `git log --oneline -15`, jede Commit-Nachricht endet mit
|
||||||
|
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||||
|
- `.planning/**` NICHT committen ausser der Todo-Datei in Aufgabe 2.
|
||||||
|
- Qualitaetsregeln wie bisher: keine neue `any`, `as unknown as` api bleibt 27, keine `!`, kein `biome-ignore`.
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
ASVS 1, block on high.
|
||||||
|
|
||||||
|
| ID | Bedrohung | Schwere | Disposition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T-HK4-01 | Pfad-Ausbruch ueber `originalName` oder Kennung aus der Anfrage | high | Dateiname = UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ; `originalName` geht nie in den Pfad (Muster DKV T-07-09). Mitigiert. |
|
||||||
|
| T-HK4-02 | Fremdzugriff auf Bilder ueber geratene Pfade | high | Die Datei wird nie direkt ausgeliefert; nur ueber `GET /dashboard/images/:id` mit Besitzpruefung (Mandant + Benutzer) und 404 fuer Fremde. Das Volume ist nicht im Webserver eingehaengt. Mitigiert. |
|
||||||
|
| T-HK4-03 | Datenverlust beim Umzug | high | Zweistufig: `data` bleibt vorerst stehen, Umzug ist idempotent, DROP erst nach nachgewiesenem Lauf auf beiden Servern (Todo). Mitigiert. |
|
||||||
|
| T-HK4-04 | Halbe Zustaende (Zeile ohne Datei / Datei ohne Zeile) | medium | Upload: Zeile zuerst, bei Schreibfehler Zeile loeschen; Loeschen: Zeile zuerst, Dateifehler wird protokolliert (Dateileiche statt haengender Loeschung); fehlende Datei = 404, die Kachel zeigt „Bild nicht verfügbar". Akzeptiert und benannt. |
|
||||||
|
| T-HK4-05 | Volume geht verloren, Datenbank ueberlebt | low | Bewusst akzeptiert (Entscheidung des Nutzers); Betriebsanleitung nennt die Sicherung des Volumes. |
|
||||||
|
</threat_model>
|
||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260922-hk4
|
||||||
|
plan: 01
|
||||||
|
subsystem: apps/api/src/dashboard
|
||||||
|
tags: [bilderrahmen, dashboard, user-files, prisma-migration, rls, tdd]
|
||||||
|
status: complete
|
||||||
|
requires: [quick-260921-pi9]
|
||||||
|
provides:
|
||||||
|
- "DashboardImage.storagePath — Bilder im Dateibereich statt als bytea"
|
||||||
|
- "DashboardImagesService.onApplicationBootstrap() — automatischer Umzug beim Start"
|
||||||
|
- "Migration 20260922120000_dashboard_image_to_disk (Stufe 1 von 2)"
|
||||||
|
affects:
|
||||||
|
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-betrieb.md
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Servergenerierter Dateiname (UUID + Endung aus dem erkannten Mime-Typ), Muster dkv-export.service.ts (T-07-09)"
|
||||||
|
- "Relativer Pfad in der Zeile, Muster User.avatarPath (user.controller.ts)"
|
||||||
|
- "Einmal systemgebunden lesen, je Zeile mandantengebunden schreiben (Muster DkvService.loadActiveConfigsForScheduler)"
|
||||||
|
- "Zweistufige Spaltenablösung: ADD + NULLbar jetzt, DROP nach nachgewiesenem Lauf"
|
||||||
|
- "Dateitests gegen ein echtes Temp-Verzeichnis statt fs-Mock (Muster desktop.service.spec.ts)"
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql
|
||||||
|
- .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||||
|
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-betrieb.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
decisions:
|
||||||
|
- "data Bytes? bleibt im Prisma-Modell (optional) statt $queryRaw — der Bootstrap-Umzug bleibt dadurch typisiert und ohne rohes SQL; Spalte und Feld fallen gemeinsam in Stufe 2"
|
||||||
|
- "Systemkontext (forSystem) nur im Startpfad; die vier Anfragewege bleiben ausnahmslos mandantengebunden, auch das Schreiben des Umzugs"
|
||||||
|
- "Neue Regel system_read_policy auf DashboardImage, damit der Umzug nach dem Scharfschalten der Datenbankrolle nicht stumm nichts findet"
|
||||||
|
- "Testschalter DASHBOARD_IMAGES_DIR (Muster DESKTOP_DIST_DIR) statt fs-Mock — die Tests schreiben und lesen wirklich"
|
||||||
|
metrics:
|
||||||
|
duration: "~35 min"
|
||||||
|
completed: 2026-09-22
|
||||||
|
actuals:
|
||||||
|
tokens: 21000
|
||||||
|
tasks: 2
|
||||||
|
commits: 2
|
||||||
|
plan_head_before: 441854a
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick-Aufgabe 260922-hk4: Bilderrahmen-Bilder auf die Festplatte — Summary
|
||||||
|
|
||||||
|
Die Bilder des Bilderrahmen-Widgets liegen jetzt unter
|
||||||
|
`user-files/dashboard-images/<userId>/<id>.<ext>`; die Datenbankzeile hält nur
|
||||||
|
noch den relativen Pfad, und vorhandene Bilder ziehen beim ersten Start
|
||||||
|
automatisch um — nachgewiesen gegen die lokale Datenbank.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Aufgabe 1 — Schema, Migration, Dienst, Bootstrap-Umzug, Tests** (`9039cea`)
|
||||||
|
|
||||||
|
- `schema.prisma`: `data Bytes` → `data Bytes?`, neu `storagePath String?`.
|
||||||
|
- Migration `20260922120000_dashboard_image_to_disk`: `ADD COLUMN "storagePath"`,
|
||||||
|
`ALTER COLUMN "data" DROP NOT NULL`, dazu `system_read_policy … FOR SELECT`
|
||||||
|
auf `"DashboardImage"`. **Kein DROP** — die Begründung steht im
|
||||||
|
Migrationskopf (Stufe 1 von 2, T-HK4-03).
|
||||||
|
- `DashboardImagesService`:
|
||||||
|
- `upload` legt die Zeile an (erst danach steht die UUID fest), schreibt die
|
||||||
|
Datei, trägt `storagePath` nach; scheitert das Schreiben, wird die Zeile
|
||||||
|
zurückgenommen und 500 geworfen.
|
||||||
|
- `getBytes` liest die Datei; fehlender Pfad oder fehlende Datei → 404.
|
||||||
|
- `remove` löscht Zeile und Datei (Dateifehler wird protokolliert, nicht
|
||||||
|
geworfen).
|
||||||
|
- `onApplicationBootstrap()` zieht Altbestand um: **einmal systemgebunden
|
||||||
|
lesen** (`forSystem`, Zeilen ohne `storagePath` über alle Mandanten),
|
||||||
|
**je Zeile mandantengebunden schreiben** (`forTenant(prisma, row.tenantId,
|
||||||
|
row.userId)`), Log „N Bilderrahmen-Bilder auf die Festplatte umgezogen",
|
||||||
|
still bei 0, wiederholbar.
|
||||||
|
- Dateiname IMMER servergeneriert; `absoluteImagePath()` weist jeden Pfad
|
||||||
|
zurück, der nicht im Bilderverzeichnis liegt (T-HK4-01).
|
||||||
|
- Tests: 23 Fälle (11 neu), echtes Temp-Verzeichnis statt `fs`-Mock.
|
||||||
|
- RLS-Wächter und Klassifikationsdokument nachgezogen (siehe Abweichungen).
|
||||||
|
|
||||||
|
**Aufgabe 2 — Changelog, Betriebsanleitung, Todo** (`8cbfb8b`)
|
||||||
|
|
||||||
|
- CHANGELOG „Unveröffentlicht → Geändert" mit der Nutzerzeile.
|
||||||
|
- `docs/anleitung-betrieb.md` Kap. 6: `user-files` nennt die
|
||||||
|
Bilderrahmen-Bilder und hält fest, dass `pg_dump` sie nicht mehr enthält.
|
||||||
|
- Todo `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md`
|
||||||
|
mit Vorbedingung (`storagePath IS NULL` = 0 auf alpha UND live),
|
||||||
|
Migrationsname `20260922120100_dashboard_image_drop_data` und allen
|
||||||
|
Nacharbeiten an Spec und Klassifikation.
|
||||||
|
|
||||||
|
## TDD-Nachweis (RED → GREEN)
|
||||||
|
|
||||||
|
- **RED** (vor der Umsetzung, `vitest run src/dashboard/dashboard-images.service.spec.ts`):
|
||||||
|
`Tests 12 failed | 11 passed (23)`, u. a.
|
||||||
|
`TypeError: makeService(...).onApplicationBootstrap is not a function`
|
||||||
|
und Erwartungen an `storagePath`, die noch niemand setzte. Die 11 grünen
|
||||||
|
Fälle sind die unveränderten Besitz-/Magic-Byte-Prüfungen aus pi9.
|
||||||
|
- **GREEN** nach dem Dienst: `Tests 23 passed (23)`.
|
||||||
|
- Ein RED war ein Testfehler, kein Dienstfehler: Test 17 („Datei fehlt")
|
||||||
|
nutzte die Kennung `img-1`, für die Test 8/10 im geteilten Temp-Verzeichnis
|
||||||
|
schon eine Datei angelegt hatten — Kennung auf `datei-fehlt` geändert.
|
||||||
|
|
||||||
|
## Nachweis am laufenden System (lokal, kein Testserver)
|
||||||
|
|
||||||
|
- `prisma migrate deploy` gegen die lokale Container-Datenbank: Migration
|
||||||
|
`20260922120000_dashboard_image_to_disk` angewendet, danach `prisma generate`.
|
||||||
|
- `\d "DashboardImage"`: `data` ist jetzt NULLbar, `storagePath text`,
|
||||||
|
Policies `tenant_isolation_policy` + `system_read_policy (FOR SELECT)`.
|
||||||
|
- Bootstrap-Umzug gegen die echte Datenbank ausgeführt (Wegwerf-Spec, danach
|
||||||
|
gelöscht):
|
||||||
|
- vorher: 1 Zeile, `storagePath = null`, 502 Byte in `data`
|
||||||
|
- Log: `1 Bilderrahmen-Bilder auf die Festplatte umgezogen`
|
||||||
|
- nachher: `storagePath = user-files/dashboard-images/1166431d-…/f43be914-….png`
|
||||||
|
- Datei auf der Platte: 502 Byte, `PNG image data, 320 x 200` (`file`)
|
||||||
|
- zweiter Lauf: keine Zeile mehr offen, Datei unverändert (wiederholbar)
|
||||||
|
|
||||||
|
## Tore
|
||||||
|
|
||||||
|
| Tor | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| `vitest run src/dashboard src/prisma` | 147 Tests, alle grün |
|
||||||
|
| `pnpm type-check` (4 Pakete) | grün |
|
||||||
|
| `pnpm lint` (5 Pakete) | grün (74 API-/53 Web-Warnungen, alle vorbestehend, keine in den geänderten Dateien) |
|
||||||
|
| `pnpm --filter @tessera/api test` | 75 Dateien, 1186 Tests grün |
|
||||||
|
| `pnpm --filter @tessera/web test` | 81 Dateien, 640 Tests grün |
|
||||||
|
| `as unknown as` in apps/api | 27 (unverändert) |
|
||||||
|
| neue `any` / `!` / `biome-ignore` | keine |
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
### [Regel 3 — blockierend] Das Klassifikationsdokument brauchte doch eine Änderung
|
||||||
|
|
||||||
|
Der Plan sagte, das Dokument brauche keine neue Zeile. Richtig — eine neue
|
||||||
|
ZEILE nicht, aber der `forSystem()`-Aufruf im Startpfad ändert den gemessenen
|
||||||
|
**Stand** des Paars `dashboard-images.service.ts`/`dashboardImage` von
|
||||||
|
`gebunden` auf `system-gebunden`, und `rls-access-inventory.spec.ts` prüft
|
||||||
|
genau diesen Wert. Zwei Tests waren rot, bis nachgezogen war:
|
||||||
|
|
||||||
|
- `FORSYSTEM_ALLOWED_CALL_SITES` (die Liste ist ein „genau", kein
|
||||||
|
„mindestens") um `apps/api/src/dashboard/dashboard-images.service.ts` = 1
|
||||||
|
erweitert, mit Begründung im Kopfkommentar: Startpfad, kein Anfrageweg;
|
||||||
|
geschrieben wird auch dort mandantengebunden. Präzedenz:
|
||||||
|
`ldap-config.service.ts`, dessen Nachverschlüsselung in
|
||||||
|
`onApplicationBootstrap()` genauso gebaut ist.
|
||||||
|
- Klassifikationsdokument: Stand `system-gebunden` mit Begründung, Zahlen der
|
||||||
|
Bereichszeile `dashboard` mit derselben Gate-Schleife nachgemessen
|
||||||
|
(1/18/0 → 1/21/1; +3 gebunden = Nachtragen von `storagePath`, Rücknahme bei
|
||||||
|
Schreibfehler, Nachtragen im Umzug), Summe 187/5 → 190/6.
|
||||||
|
|
||||||
|
Beides ist im Todo für Stufe 2 als Rückbau vermerkt.
|
||||||
|
|
||||||
|
### [Regel 2 — fehlende kritische Funktionalität] `ALTER COLUMN "data" DROP NOT NULL`
|
||||||
|
|
||||||
|
Der Plan nannte nur `ADD COLUMN "storagePath"`. Ohne das Lockern der
|
||||||
|
NOT-NULL-Bedingung wäre jeder neue Upload an der Datenbank gescheitert, weil
|
||||||
|
er keine Bytes mehr in die Zeile schreibt.
|
||||||
|
|
||||||
|
### [Regel 2 — fehlende kritische Funktionalität] `system_read_policy` auf `"DashboardImage"`
|
||||||
|
|
||||||
|
Nicht im Plan. Ohne diese Regel sähe der systemgebundene Umzug nach dem
|
||||||
|
Scharfschalten der Datenbankrolle NULL Zeilen und stellte die Arbeit stumm
|
||||||
|
ein — genau die Falle, die Migration 20260914120000 für die fünf
|
||||||
|
Hintergrunddienst-Tabellen geschlossen hat. Permissiv, nur `FOR SELECT`;
|
||||||
|
Schreiben bleibt allein der Mandantenregel unterstellt.
|
||||||
|
|
||||||
|
### [Entscheidung] Testschalter `DASHBOARD_IMAGES_DIR`
|
||||||
|
|
||||||
|
Der Plan ließ die Wahl zwischen `memfs` und einem Temp-Verzeichnis. Gewählt:
|
||||||
|
Temp-Verzeichnis (keine neue Abhängigkeit), erreichbar über die
|
||||||
|
Umgebungsvariable `DASHBOARD_IMAGES_DIR` — dasselbe Muster, das
|
||||||
|
`desktop.service.ts` mit `DESKTOP_DIST_DIR` schon nutzt. Im Betrieb nie
|
||||||
|
gesetzt; ohne sie gilt der Pfad unter der Monorepo-Wurzel.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neue Angriffsfläche über den `<threat_model>` des Plans hinaus. Der
|
||||||
|
einzige neue Dateipfad-Umgang ist vollständig servergeneriert und zusätzlich
|
||||||
|
containment-geprüft (`absoluteImagePath`).
|
||||||
|
|
||||||
|
## Von Hand zu prüfen (nach dem nächsten `--build`-Deploy)
|
||||||
|
|
||||||
|
1. Bild im Bilderrahmen-Widget hochladen → erscheint in der Kachel und in der
|
||||||
|
Verwaltung unter Einstellungen → Dashboard.
|
||||||
|
2. Auf dem Server nachsehen:
|
||||||
|
`docker compose exec api ls -R /app/user-files/dashboard-images` — je
|
||||||
|
Benutzer ein Ordner, Dateiname eine UUID mit `.png`/`.jpg`/`.gif`/`.webp`,
|
||||||
|
nie der Originalname.
|
||||||
|
3. Bild löschen → verschwindet aus der Kachel UND die Datei ist weg
|
||||||
|
(`ls` wie oben).
|
||||||
|
4. Nach dem ersten Start mit dieser Version:
|
||||||
|
`docker compose logs api | grep umgezogen` — die Zeile „N
|
||||||
|
Bilderrahmen-Bilder auf die Festplatte umgezogen" steht genau einmal; ein
|
||||||
|
zweiter Neustart schweigt.
|
||||||
|
5. `docker compose exec db psql -U tessera -d tessera -c 'SELECT count(*) FROM "DashboardImage" WHERE "storagePath" IS NULL;'`
|
||||||
|
→ muss `0` sein (Vorbedingung für Stufe 2, siehe Todo).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- `apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql` — vorhanden
|
||||||
|
- `apps/api/src/dashboard/dashboard-images.service.ts` — vorhanden
|
||||||
|
- `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md` — vorhanden
|
||||||
|
- Commit `9039cea` — vorhanden
|
||||||
|
- Commit `8cbfb8b` — vorhanden
|
||||||
|
|
||||||
|
## Rundgang durch den Orchestrator (22.09.2026, lokaler Stack, Abbilder aus dem Commit danach)
|
||||||
|
|
||||||
|
Bestanden, und dabei EIN Befund gefunden und behoben (eigener Commit):
|
||||||
|
|
||||||
|
- Hochladen ueber die Oberflaeche legt die Datei unter `user-files/dashboard-images/<userId>/<uuid>.png` im Container-Volume an; die Liste zeigt sie, die Kachel rendert sie.
|
||||||
|
- Loeschen entfernt Zeile UND Datei (3 Dateien/3 Zeilen → 2/2, gemessen im Container und in der Datenbank).
|
||||||
|
- **Befund:** eine Zeile zeigte auf eine Datei, die es im Container nicht gibt — der Bootstrap-Umzug war beim Bauen auf dem HOST gelaufen (Repo-Verzeichnis), der Container hat aber das Volume `user-files`. Lokal ein Artefakt, im Betrieb aber real: wer einen `pg_dump` von VOR dem Umzug zurueckspielt, waehrend das getrennt gesicherte Volume leer ist, haette Zeilen ohne Datei, obwohl die Bytes im Abzug noch stecken.
|
||||||
|
- **Behoben:** `getBytes` schreibt die Datei in diesem Fall aus der noch vorhandenen Spalte `data` neu und liefert sie aus (Protokoll „… aus der Datenbank wiederhergestellt"); fehlt beides, bleibt es bei 404. Nachgewiesen: Abruf lieferte 200/`image/png`/502 Byte, danach lag die Datei im Container. Zwei Tests (10b, 10c), api 1186 → 1188.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-22
|
||||||
|
title: DashboardImage — Spalte "data" entfernen und "storagePath" auf NOT NULL setzen (Stufe 2 der Umstellung aus quick-260922-hk4)
|
||||||
|
area: apps/api/prisma
|
||||||
|
severity: cleanup
|
||||||
|
trigger: erst wenn alpha UND live je einmal mit einer Version >= der Freigabe nach 1.3.0 gelaufen sind — dann hat der Bootstrap-Umzug auf beiden Servern gearbeitet und die Bytes liegen im Dateibereich.
|
||||||
|
relates_to: quick-260922-hk4 (.planning/quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/)
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worum es geht
|
||||||
|
|
||||||
|
Die Bilder des Bilderrahmen-Widgets sind mit `quick-260922-hk4` aus der
|
||||||
|
Datenbank in den Dateibereich gezogen (`user-files/dashboard-images/
|
||||||
|
<userId>/<id>.<ext>`, Pfad in der Spalte `storagePath`). Die Umstellung ist
|
||||||
|
BEWUSST ZWEISTUFIG:
|
||||||
|
|
||||||
|
- **Stufe 1, ausgeliefert** (Migration `20260922120000_dashboard_image_to_disk`):
|
||||||
|
`storagePath` dazu (NULLbar), `data` wird NULLbar — aber NICHT gelöscht.
|
||||||
|
Der Umzug der vorhandenen Zeilen passiert beim ersten Start automatisch
|
||||||
|
(`DashboardImagesService.onApplicationBootstrap()`).
|
||||||
|
- **Stufe 2, dieser Zettel:** `data` löschen, `storagePath` auf NOT NULL.
|
||||||
|
|
||||||
|
Warum nicht sofort: `prisma migrate deploy` läuft VOR dem Anwendungsstart.
|
||||||
|
Ein sofortiges `DROP COLUMN "data"` hätte die Bytes vernichtet, bevor der
|
||||||
|
Umzug beim Start sie lesen konnte (T-HK4-03).
|
||||||
|
|
||||||
|
## Was zu tun ist
|
||||||
|
|
||||||
|
1. **Vorbedingung prüfen** (auf BEIDEN Servern, alpha und live):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT count(*) FROM "DashboardImage" WHERE "storagePath" IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Muss überall `0` sein. Ist sie es nicht, ist der Umzug dort noch nicht
|
||||||
|
gelaufen (Server noch auf einer älteren Version) — dann NICHT ausliefern.
|
||||||
|
|
||||||
|
2. **Neue Migration** `20260922120100_dashboard_image_drop_data`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;
|
||||||
|
ALTER TABLE "DashboardImage" DROP COLUMN "data";
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Schema** `apps/api/prisma/schema.prisma`: Feld `data Bytes?` entfernen,
|
||||||
|
`storagePath String?` → `storagePath String`.
|
||||||
|
|
||||||
|
4. **Dienst** `apps/api/src/dashboard/dashboard-images.service.ts`:
|
||||||
|
`onApplicationBootstrap()` samt `forSystem()`-Aufruf entfernt sich damit
|
||||||
|
— der Umzug hat seine Arbeit getan. Danach:
|
||||||
|
- Eintrag `apps/api/src/dashboard/dashboard-images.service.ts` aus
|
||||||
|
`FORSYSTEM_ALLOWED_CALL_SITES` in `apps/api/src/prisma/rls-access-inventory.spec.ts`
|
||||||
|
wieder ENTFERNEN (die Liste ist ein „genau", ein veralteter Eintrag
|
||||||
|
macht die Spec rot).
|
||||||
|
- In `docs/mandantentrennung-zugriffsklassifikation.md` den Stand der Zeile
|
||||||
|
`dashboard-images.service.ts`/`dashboardImage` von `system-gebunden`
|
||||||
|
zurück auf `gebunden` setzen und die Zahlen der Bereichszeile
|
||||||
|
`dashboard` sowie die Summe neu messen (Gate-Schleife, nicht
|
||||||
|
abschreiben).
|
||||||
|
- Die Tests 18, 21, 22 und 23 der Dienst-Spec (Zeile ohne `storagePath`,
|
||||||
|
Bootstrap-Umzug) entfallen mit dem Umzug.
|
||||||
|
- Die Regel `system_read_policy` auf `"DashboardImage"` (angelegt in
|
||||||
|
20260922120000) kann bleiben oder mit `DROP POLICY` fallen — bleibt sie,
|
||||||
|
gehört sie in der Klassifikation erwähnt; fällt sie, ist die
|
||||||
|
Aufzählung „fünf/sechs Tabellen" dort nachzuziehen.
|
||||||
|
|
||||||
|
5. **Prüfen**, dass die Datenbank kleiner wird:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT pg_size_pretty(pg_total_relation_size('"DashboardImage"'));
|
||||||
|
```
|
||||||
|
|
||||||
|
(Nach dem DROP zusätzlich `VACUUM FULL "DashboardImage";`, sonst gibt
|
||||||
|
PostgreSQL den Platz nicht ans Dateisystem zurück.)
|
||||||
|
|
||||||
|
## Was passiert, wenn es liegen bleibt
|
||||||
|
|
||||||
|
Nichts Schlimmes: die Spalte steht leer herum und kostet je neuer Zeile
|
||||||
|
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
|
||||||
|
ALTEN Bilder bleiben bis dahin doppelt vorhanden — einmal in der Datei,
|
||||||
|
einmal in der Spalte.
|
||||||
@@ -4,6 +4,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
## Unveröffentlicht
|
## Unveröffentlicht
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Bilderrahmen: hochgeladene Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten Start automatisch um
|
||||||
|
|
||||||
## 1.3.0 – 2026-09-22
|
## 1.3.0 – 2026-09-22
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- quick-260922-hk4 — Bilderrahmen-Bilder wandern aus der Datenbank in den
|
||||||
|
-- Dateibereich (Volume `user-files`).
|
||||||
|
--
|
||||||
|
-- Warum: gesichert wird von Hand per `pg_dump` (docs/anleitung-betrieb.md
|
||||||
|
-- Kap. 6). Jedes Bild waechst in diesen Abzug hinein — 30 Bilder à 5 MiB je
|
||||||
|
-- Benutzer sind im Extremfall 150 MB PRO BENUTZER, gegen eine heute 18 MB
|
||||||
|
-- grosse Datenbank (gemessen 22.09.2026 auf alpha). Die Bytes liegen ab
|
||||||
|
-- dieser Version unter
|
||||||
|
-- `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`; die Zeile
|
||||||
|
-- haelt nur noch den relativen Pfad in "storagePath" — dasselbe Muster wie
|
||||||
|
-- `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
|
||||||
|
-- (dkv-export.service.ts). Der Dateiname ist IMMER servergeneriert (die
|
||||||
|
-- UUID der Zeile plus die Endung aus dem an den Magic Bytes ERKANNTEN
|
||||||
|
-- Mime-Typ); kein Byte aus der Anfrage, insbesondere nicht
|
||||||
|
-- "originalName", geht je in einen Pfad (T-HK4-01, Muster T-07-09).
|
||||||
|
--
|
||||||
|
-- ZWEISTUFIG, UND WARUM DIESE MIGRATION "data" NICHT LOESCHT (T-HK4-03):
|
||||||
|
-- Vorhandene Zeilen tragen ihre Bytes noch in "data". Der Umzug auf die
|
||||||
|
-- Platte passiert beim ersten Start dieser Version automatisch
|
||||||
|
-- (DashboardImagesService.onApplicationBootstrap, liest systemgebunden ueber
|
||||||
|
-- alle Mandanten, schreibt je Zeile mandantengebunden zurueck) — der Nutzer
|
||||||
|
-- muss nichts ausfuehren. Wuerde diese Migration die Spalte sofort
|
||||||
|
-- loeschen, laufen Migration und Umzug im selben Start in der falschen
|
||||||
|
-- Reihenfolge ("migrate deploy" laeuft VOR dem Anwendungsstart) und die
|
||||||
|
-- Bytes waeren weg, bevor sie jemand gelesen hat. Deshalb:
|
||||||
|
-- Stufe 1 (diese Migration): "storagePath" dazu (NULLbar), "data" bleibt
|
||||||
|
-- stehen und wird NULLbar, damit neue Uploads sie leer lassen.
|
||||||
|
-- Stufe 2 (spaetere Freigabe, Migration
|
||||||
|
-- 20260922120100_dashboard_image_drop_data, vorgemerkt in
|
||||||
|
-- .planning/todos/pending/): "storagePath" SET NOT NULL und
|
||||||
|
-- DROP COLUMN "data" — erst, wenn alpha UND live einmal mit
|
||||||
|
-- einer Version >= dieser gelaufen sind.
|
||||||
|
--
|
||||||
|
-- Das Prisma-Modell behaelt in Stufe 1 bewusst `data Bytes?` (optional).
|
||||||
|
-- Damit bleibt der Bootstrap-Umzug typisiert und braucht kein rohes SQL;
|
||||||
|
-- die Spalte verschwindet aus Modell und Tabelle gemeinsam in Stufe 2.
|
||||||
|
--
|
||||||
|
-- Rechte/Regeln: "tenant_isolation_policy" aus 20260921120000 bleibt
|
||||||
|
-- unveraendert. Kein DROP POLICY.
|
||||||
|
|
||||||
|
-- Relativer Pfad zur Monorepo-Wurzel, z. B.
|
||||||
|
-- "user-files/dashboard-images/<userId>/<id>.png". Stufe 2 macht die Spalte
|
||||||
|
-- NOT NULL.
|
||||||
|
ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;
|
||||||
|
|
||||||
|
-- Neue Uploads schreiben keine Bytes mehr in die Zeile; die Spalte bleibt
|
||||||
|
-- fuer die Dauer von Stufe 1 als Sicherheitsnetz erhalten.
|
||||||
|
ALTER TABLE "DashboardImage" ALTER COLUMN "data" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Systemkontext-Leserecht (Muster 20260914120000_rls_system_context_read):
|
||||||
|
-- der Bootstrap-Umzug liest die noch nicht umgezogenen Zeilen ueber ALLE
|
||||||
|
-- Mandanten (`forSystem()`), bevor er je Zeile mandantengebunden
|
||||||
|
-- zurueckschreibt. Ohne diese Regel saehe er nach dem Scharfschalten der
|
||||||
|
-- Datenbankrolle (Etappe 4, Schalter heute AUS) NULL Zeilen und stellte die
|
||||||
|
-- Arbeit stumm ein — genau die Falle, die 20260914120000 fuer die fuenf
|
||||||
|
-- Hintergrunddienst-Tabellen geschlossen hat. Permissiv und NUR FOR SELECT:
|
||||||
|
-- Schreiben bleibt allein der Mandantenregel unterstellt.
|
||||||
|
CREATE POLICY system_read_policy ON "DashboardImage"
|
||||||
|
FOR SELECT USING (is_system_context());
|
||||||
@@ -212,12 +212,16 @@ model WidgetInstance {
|
|||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers,
|
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
|
||||||
// als bytea in der Datenbank (kein Docker-Volume, die Sicherung deckt es mit
|
// Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
||||||
// ab). Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
|
||||||
// Benutzer) und die Magic-Byte-Erkennung leben in
|
// Benutzer) und die Magic-Byte-Erkennung leben in
|
||||||
// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
|
// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
|
||||||
// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
|
// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
|
||||||
|
//
|
||||||
|
// quick-260922-hk4: die Bytes liegen jetzt im Dateibereich
|
||||||
|
// (user-files/dashboard-images/<userId>/<id>.<ext>), die Zeile haelt nur
|
||||||
|
// noch den relativen Pfad — Muster User.avatarPath. Die Datenbanksicherung
|
||||||
|
// (pg_dump) bleibt dadurch klein.
|
||||||
model DashboardImage {
|
model DashboardImage {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
@@ -225,7 +229,14 @@ model DashboardImage {
|
|||||||
originalName String
|
originalName String
|
||||||
mimeType String
|
mimeType String
|
||||||
size Int
|
size Int
|
||||||
data Bytes
|
// Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die
|
||||||
|
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern
|
||||||
|
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit
|
||||||
|
// 20260922120100 (vorgemerkt in .planning/todos/pending/).
|
||||||
|
data Bytes?
|
||||||
|
// Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der
|
||||||
|
// Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL.
|
||||||
|
storagePath String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
|||||||
@@ -1,33 +1,53 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts
|
* Bindung an forTenant()/forSystem() — dasselbe Muster wie
|
||||||
* (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma`
|
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
|
||||||
* unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert,
|
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
|
||||||
* welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt
|
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
|
||||||
* damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag).
|
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
|
||||||
|
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
|
||||||
|
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
|
||||||
|
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
|
||||||
|
* unterscheidet beide ueber `via`.
|
||||||
*/
|
*/
|
||||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
||||||
prisma.__makeBoundClient(tenantId, userId),
|
prisma.__makeBoundClient(tenantId, userId),
|
||||||
),
|
),
|
||||||
|
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { DashboardImagesService } from './dashboard-images.service';
|
import { DashboardImagesService } from './dashboard-images.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* dashboard-images.service.spec — NEU (quick-260921-pi9, Bilderrahmen).
|
* dashboard-images.service.spec — quick-260921-pi9 (Bilderrahmen),
|
||||||
|
* erweitert in quick-260922-hk4 (Bilder auf der Festplatte statt in der
|
||||||
|
* Datenbank).
|
||||||
*
|
*
|
||||||
* Elf Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload
|
* Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload ohne
|
||||||
* ohne Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE
|
* Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE Richtungen
|
||||||
* Richtungen (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND
|
* (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND fremder Mandant
|
||||||
* fremder Mandant -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes,
|
* -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, Loeschen
|
||||||
* Loeschen eigen/fremd, und der Nachweis, dass jede Methode
|
* eigen/fremd, und der Nachweis, dass jede Methode
|
||||||
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
||||||
* Argument aufruft.
|
* Argument aufruft.
|
||||||
|
*
|
||||||
|
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
|
||||||
|
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
||||||
|
* desktop.service.spec.ts) ueber den Testschalter
|
||||||
|
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
||||||
|
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
||||||
|
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
||||||
|
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
||||||
|
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
|
||||||
|
* Umzug beim Start (T-HK4-03).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface ImageRow {
|
interface ImageRow {
|
||||||
@@ -37,11 +57,13 @@ interface ImageRow {
|
|||||||
originalName: string;
|
originalName: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
data: Uint8Array;
|
data: Uint8Array | null;
|
||||||
|
storagePath: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BoundCall {
|
interface BoundCall {
|
||||||
|
via: 'tenant' | 'system';
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
userId: string | undefined;
|
userId: string | undefined;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -55,11 +77,31 @@ interface FakePrisma {
|
|||||||
__rows: ImageRow[];
|
__rows: ImageRow[];
|
||||||
__boundCallLog: BoundCall[];
|
__boundCallLog: BoundCall[];
|
||||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||||
|
__makeSystemClient(): { dashboardImage: ModelMethods };
|
||||||
}
|
}
|
||||||
|
|
||||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||||
|
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]);
|
||||||
const TEXT = Buffer.from('nur Text, kein Bild');
|
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||||
|
|
||||||
|
/** Ablageort der Testdateien — echtes Verzeichnis, kein fs-Mock. */
|
||||||
|
let imagesDir: string;
|
||||||
|
const ORIGINAL_DIR_ENV = process.env.DASHBOARD_IMAGES_DIR;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
imagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-dashboard-images-'));
|
||||||
|
process.env.DASHBOARD_IMAGES_DIR = imagesDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(imagesDir, { recursive: true, force: true });
|
||||||
|
if (ORIGINAL_DIR_ENV === undefined) {
|
||||||
|
delete process.env.DASHBOARD_IMAGES_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.DASHBOARD_IMAGES_DIR = ORIGINAL_DIR_ENV;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||||
return {
|
return {
|
||||||
id: overrides.id ?? 'img-1',
|
id: overrides.id ?? 'img-1',
|
||||||
@@ -68,11 +110,30 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
|||||||
originalName: overrides.originalName ?? 'foto.png',
|
originalName: overrides.originalName ?? 'foto.png',
|
||||||
mimeType: overrides.mimeType ?? 'image/png',
|
mimeType: overrides.mimeType ?? 'image/png',
|
||||||
size: overrides.size ?? PNG.length,
|
size: overrides.size ?? PNG.length,
|
||||||
data: overrides.data ?? Uint8Array.from(PNG),
|
data: overrides.data === undefined ? null : overrides.data,
|
||||||
|
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
|
||||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine Zeile MIT passender Datei auf der Platte an — der Normalfall
|
||||||
|
* nach dem Upload (die Tests 8-12 pruefen Besitz und Bindung, nicht die
|
||||||
|
* Ablage).
|
||||||
|
*/
|
||||||
|
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
||||||
|
const row = makeRow(overrides);
|
||||||
|
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
|
||||||
|
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
||||||
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||||
|
fs.writeFileSync(absolute, bytes);
|
||||||
|
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedFile(userId: string, id: string, ext = 'png'): string {
|
||||||
|
return path.join(imagesDir, userId, `${id}.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
||||||
if (!select) return row;
|
if (!select) return row;
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
@@ -86,9 +147,20 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
const boundCallLog: BoundCall[] = [];
|
const boundCallLog: BoundCall[] = [];
|
||||||
const dashboardImage: ModelMethods = {
|
const dashboardImage: ModelMethods = {
|
||||||
findMany: vi.fn(async (raw: unknown) => {
|
findMany: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as { where: { tenantId: string; userId: string }; select?: Record<string, boolean> };
|
const args = raw as {
|
||||||
|
where: { tenantId?: string; userId?: string; storagePath?: string | null };
|
||||||
|
select?: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
const where = args.where ?? {};
|
||||||
return rows
|
return rows
|
||||||
.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId)
|
.filter((r) => {
|
||||||
|
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
||||||
|
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||||
|
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
.map((r) => pick(r, args.select));
|
.map((r) => pick(r, args.select));
|
||||||
@@ -98,11 +170,18 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
||||||
}),
|
}),
|
||||||
create: vi.fn(async (raw: unknown) => {
|
create: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as { data: Omit<ImageRow, 'id' | 'createdAt'>; select?: Record<string, boolean> };
|
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
||||||
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
||||||
rows.push(created);
|
rows.push(created);
|
||||||
return pick(created, args.select);
|
return pick(created, args.select);
|
||||||
}),
|
}),
|
||||||
|
update: vi.fn(async (raw: unknown) => {
|
||||||
|
const args = raw as { where: { id: string }; data: Partial<ImageRow> };
|
||||||
|
const row = rows.find((r) => r.id === args.where.id);
|
||||||
|
if (!row) throw new Error(`update: Zeile '${args.where.id}' gibt es nicht`);
|
||||||
|
Object.assign(row, args.data);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
findUnique: vi.fn(async (raw: unknown) => {
|
findUnique: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as { where: { id: string } };
|
const args = raw as { where: { id: string } };
|
||||||
return rows.find((r) => r.id === args.where.id) ?? null;
|
return rows.find((r) => r.id === args.where.id) ?? null;
|
||||||
@@ -116,19 +195,26 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
|
||||||
|
const wrapped: ModelMethods = {};
|
||||||
|
for (const method of Object.keys(dashboardImage)) {
|
||||||
|
wrapped[method] = async (...args: unknown[]) => {
|
||||||
|
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
|
||||||
|
return dashboardImage[method](...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { dashboardImage: wrapped };
|
||||||
|
}
|
||||||
|
|
||||||
const fake: FakePrisma = {
|
const fake: FakePrisma = {
|
||||||
dashboardImage,
|
dashboardImage,
|
||||||
__rows: rows,
|
__rows: rows,
|
||||||
__boundCallLog: boundCallLog,
|
__boundCallLog: boundCallLog,
|
||||||
__makeBoundClient(tenantId: string, userId?: string) {
|
__makeBoundClient(tenantId: string, userId?: string) {
|
||||||
const wrapped: ModelMethods = {};
|
return wrap('tenant', tenantId, userId);
|
||||||
for (const method of Object.keys(dashboardImage)) {
|
},
|
||||||
wrapped[method] = async (...args: unknown[]) => {
|
__makeSystemClient() {
|
||||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
return wrap('system', '', undefined);
|
||||||
return dashboardImage[method](...args);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { dashboardImage: wrapped };
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return fake;
|
return fake;
|
||||||
@@ -156,6 +242,7 @@ function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): Uplo
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(forTenant).mockClear();
|
vi.mocked(forTenant).mockClear();
|
||||||
|
vi.mocked(forSystem).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DashboardImagesService (quick-260921-pi9)', () => {
|
describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||||
@@ -172,6 +259,7 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
}
|
}
|
||||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
||||||
expect(call.select.data).toBeUndefined();
|
expect(call.select.data).toBeUndefined();
|
||||||
|
expect(call.select.storagePath).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
|
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
|
||||||
@@ -234,29 +322,60 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', userId: 'user-2' })]);
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
||||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||||
const service = makeService(prisma);
|
const service = makeService(prisma);
|
||||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
||||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1' })]);
|
||||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||||
expect(result.mimeType).toBe('image/png');
|
expect(result.mimeType).toBe('image/png');
|
||||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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([
|
||||||
makeRow({ id: 'eigen' }),
|
makeStoredRow({ id: 'eigen' }),
|
||||||
makeRow({ id: 'fremd-user', userId: 'user-2' }),
|
makeStoredRow({ id: 'fremd-user', userId: 'user-2' }),
|
||||||
makeRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
makeStoredRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
||||||
]);
|
]);
|
||||||
const service = makeService(prisma);
|
const service = makeService(prisma);
|
||||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||||
@@ -267,12 +386,12 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1' })]);
|
const prisma = makeFakePrisma([]);
|
||||||
const service = makeService(prisma);
|
const service = makeService(prisma);
|
||||||
await service.list('user-1', 'tenant-1');
|
await service.list('user-1', 'tenant-1');
|
||||||
await service.upload(user, file(PNG, 'image/png'));
|
const created = await service.upload(user, file(PNG, 'image/png'));
|
||||||
await service.getBytes('img-1', 'user-1', 'tenant-1');
|
await service.getBytes(created.id, 'user-1', 'tenant-1');
|
||||||
await service.remove('img-1', 'user-1', 'tenant-1');
|
await service.remove(created.id, 'user-1', 'tenant-1');
|
||||||
|
|
||||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
|
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
|
||||||
for (const call of vi.mocked(forTenant).mock.calls) {
|
for (const call of vi.mocked(forTenant).mock.calls) {
|
||||||
@@ -280,12 +399,174 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
expect(call[1]).toBe('tenant-1');
|
expect(call[1]).toBe('tenant-1');
|
||||||
expect(call[2]).toBe('user-1');
|
expect(call[2]).toBe('user-1');
|
||||||
}
|
}
|
||||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten.
|
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
|
||||||
|
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
|
||||||
|
// UUID der Zeile erst nach `create` feststeht (hk4).
|
||||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
|
||||||
for (const c of prisma.__boundCallLog) {
|
for (const c of prisma.__boundCallLog) {
|
||||||
|
expect(c.via).toBe('tenant');
|
||||||
expect(c.tenantId).toBe('tenant-1');
|
expect(c.tenantId).toBe('tenant-1');
|
||||||
expect(c.userId).toBe('user-1');
|
expect(c.userId).toBe('user-1');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)', () => {
|
||||||
|
it('Test 13: upload schreibt die Datei unter <dir>/<userId>/<id>.png und speichert den relativen Pfad in der Zeile', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const result = await makeService(prisma).upload(user, file(PNG, 'image/png'));
|
||||||
|
|
||||||
|
const onDisk = storedFile('user-1', result.id);
|
||||||
|
expect(fs.existsSync(onDisk)).toBe(true);
|
||||||
|
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||||
|
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||||
|
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
|
||||||
|
expect(prisma.__rows[0].data).toBeNull();
|
||||||
|
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||||
|
expect(createArgs.data.data).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = makeService(prisma);
|
||||||
|
const boeserName = '../../../etc/passwd.png';
|
||||||
|
const result = await service.upload(user, file(JPEG, 'image/png', boeserName));
|
||||||
|
|
||||||
|
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
||||||
|
expect(result.mimeType).toBe('image/jpeg');
|
||||||
|
const stored = prisma.__rows[0].storagePath ?? '';
|
||||||
|
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
||||||
|
expect(stored).not.toContain('passwd');
|
||||||
|
expect(stored).not.toContain('..');
|
||||||
|
expect(fs.existsSync(storedFile('user-1', result.id, 'jpg'))).toBe(true);
|
||||||
|
// Der Anzeigename bleibt in der Zeile erhalten, nur eben als Text.
|
||||||
|
expect(result.originalName).toBe(boeserName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 15: scheitert das Schreiben, wird die eben angelegte Zeile wieder geloescht und 500 geworfen (T-HK4-04)', async () => {
|
||||||
|
const blocker = path.join(imagesDir, 'blockade');
|
||||||
|
fs.writeFileSync(blocker, 'ich bin eine Datei, kein Verzeichnis');
|
||||||
|
const vorher = process.env.DASHBOARD_IMAGES_DIR;
|
||||||
|
process.env.DASHBOARD_IMAGES_DIR = path.join(blocker, 'unmoeglich');
|
||||||
|
try {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
|
||||||
|
InternalServerErrorException,
|
||||||
|
);
|
||||||
|
expect(prisma.dashboardImage.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.dashboardImage.delete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.__rows).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
process.env.DASHBOARD_IMAGES_DIR = vorher;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
|
||||||
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
|
||||||
|
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||||
|
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
||||||
|
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||||
|
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
||||||
|
]);
|
||||||
|
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
|
||||||
|
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
|
||||||
|
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
||||||
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
||||||
|
const onDisk = storedFile('user-1', 'weg');
|
||||||
|
expect(fs.existsSync(onDisk)).toBe(true);
|
||||||
|
|
||||||
|
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
||||||
|
expect(prisma.__rows).toHaveLength(0);
|
||||||
|
expect(fs.existsSync(onDisk)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 20: fehlt die Datei beim Loeschen, gelingt das Loeschen trotzdem (eine Dateileiche ist harmloser als eine haengende Loeschung)', async () => {
|
||||||
|
const prisma = makeFakePrisma([
|
||||||
|
makeRow({ id: 'nur-zeile', storagePath: 'user-files/dashboard-images/user-1/nur-zeile.png' }),
|
||||||
|
]);
|
||||||
|
await expect(makeService(prisma).remove('nur-zeile', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||||
|
id: 'nur-zeile',
|
||||||
|
});
|
||||||
|
expect(prisma.__rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
|
||||||
|
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
|
||||||
|
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
|
||||||
|
const fremderMandant = makeRow({
|
||||||
|
id: 'alt-2',
|
||||||
|
userId: 'user-9',
|
||||||
|
tenantId: 'tenant-2',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
data: Uint8Array.from(JPEG),
|
||||||
|
});
|
||||||
|
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
|
||||||
|
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
|
||||||
|
|
||||||
|
await makeService(prisma).onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
|
||||||
|
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
|
||||||
|
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
|
||||||
|
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
|
||||||
|
|
||||||
|
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
|
||||||
|
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
|
||||||
|
// gebunden ist.
|
||||||
|
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
|
||||||
|
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
|
||||||
|
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
|
||||||
|
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||||
|
where: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect(findManyArgs.where.storagePath).toBeNull();
|
||||||
|
|
||||||
|
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
|
||||||
|
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
|
||||||
|
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
|
||||||
|
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
|
||||||
|
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
|
||||||
|
|
||||||
|
// Die bereits umgezogene Zeile wird nicht angefasst.
|
||||||
|
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
|
||||||
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
|
||||||
|
await makeService(prisma).onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
|
||||||
|
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
|
||||||
|
const service = makeService(prisma);
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
|
||||||
|
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||||
|
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
|
||||||
|
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
type OnApplicationBootstrap,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
DASHBOARD_IMAGE_MAX_COUNT,
|
DASHBOARD_IMAGE_MAX_COUNT,
|
||||||
@@ -10,20 +19,53 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
|
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
|
||||||
* (quick-260921-pi9).
|
* (quick-260921-pi9), seit quick-260922-hk4 im Dateibereich statt in der
|
||||||
|
* Datenbank.
|
||||||
*
|
*
|
||||||
* Ein Bild gehoert dem hochladenden Benutzer: Besitz = gleicher Mandant UND
|
* WO DIE BYTES LIEGEN (hk4): unter
|
||||||
* gleicher Benutzer. Die Besitzpruefung in `getBytes`/`remove` (Zeile holen,
|
* `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`, die Zeile
|
||||||
* `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen, sonst 404)
|
* haelt nur noch den relativen Pfad in `storagePath` — dasselbe Muster wie
|
||||||
* ist NICHT dekorativ: die RLS-Regel auf `DashboardImage` (Migration
|
* `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
|
||||||
* 20260921120000, mit Benutzerdimension) wirkt erst, wenn die Anwendung als
|
* (dkv-export.service.ts). Grund ist die Sicherung: gesichert wird von Hand
|
||||||
* Rolle ohne Umgehungsrecht verbindet — der Schalter ist heute AUS
|
* per `pg_dump` (docs/anleitung-betrieb.md Kap. 6), und 30 Bilder à 5 MiB je
|
||||||
* (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der Vergleich
|
* Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug. Das
|
||||||
* hier der einzige wirksame Schutz gegen Quer-Lesen und Quer-Loeschen; die
|
* Volume `user-files` wird daneben gesichert. Geschwindigkeit war NICHT das
|
||||||
* `forTenant()`-Bindung je Methode LEGT eine Mandantengrenze obendrauf, sie
|
* Argument (ein Bild wird je Browser einmal taeglich geladen).
|
||||||
* ersetzt den Vergleich nicht (Muster dashboard.service.ts). Nach dem
|
*
|
||||||
* Scharfschalten liefert `findUnique` fuer eine fremde Zeile bereits `null`
|
* DER DATEINAME KOMMT IMMER VOM SERVER (T-HK4-01, Muster T-07-09 aus
|
||||||
* — die Antwort bleibt 404, nur der Weg dorthin aendert sich.
|
* `dkv-export.service.ts`): er ist die UUID der Zeile plus die Endung aus
|
||||||
|
* dem an den Magic Bytes ERKANNTEN Mime-Typ. `originalName` ist reiner
|
||||||
|
* Anzeigetext und erscheint weder im Pfad noch in einem Header (T-PI9-06).
|
||||||
|
* `absoluteImagePath()` prueft zusaetzlich, dass der aus der Zeile
|
||||||
|
* gelesene Pfad im Bilderverzeichnis liegt — ein Wert aus der Datenbank
|
||||||
|
* wird nie ungeprueft an `path.join` gereicht.
|
||||||
|
*
|
||||||
|
* EIN EIGENER ORDNER JE BENUTZER IST KEIN SCHUTZ: wer welches Bild sehen
|
||||||
|
* darf, entscheidet weiterhin dieser Dienst. Die Datei wird nie direkt
|
||||||
|
* ausgeliefert, nur ueber `GET /dashboard/images/:id` mit Besitzpruefung
|
||||||
|
* (T-HK4-02); das Volume haengt in keinem Webserver.
|
||||||
|
*
|
||||||
|
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
|
||||||
|
* die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert
|
||||||
|
* das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim
|
||||||
|
* Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
|
||||||
|
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
|
||||||
|
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
|
||||||
|
* 404 und die Kachel zeigt „Bild nicht verfügbar".
|
||||||
|
*
|
||||||
|
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
||||||
|
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
|
||||||
|
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
|
||||||
|
* sonst 404) ist NICHT dekorativ: die RLS-Regel auf `DashboardImage`
|
||||||
|
* (Migration 20260921120000, mit Benutzerdimension) wirkt erst, wenn die
|
||||||
|
* Anwendung als Rolle ohne Umgehungsrecht verbindet — der Schalter ist
|
||||||
|
* heute AUS (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der
|
||||||
|
* Vergleich hier der einzige wirksame Schutz gegen Quer-Lesen und
|
||||||
|
* Quer-Loeschen; die `forTenant()`-Bindung je Methode LEGT eine
|
||||||
|
* Mandantengrenze obendrauf, sie ersetzt den Vergleich nicht (Muster
|
||||||
|
* dashboard.service.ts). Nach dem Scharfschalten liefert `findUnique` fuer
|
||||||
|
* eine fremde Zeile bereits `null` — die Antwort bleibt 404, nur der Weg
|
||||||
|
* dorthin aendert sich.
|
||||||
*
|
*
|
||||||
* Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
|
* Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
|
||||||
* Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
|
* Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
|
||||||
@@ -31,9 +73,7 @@ import {
|
|||||||
* Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08):
|
* Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08):
|
||||||
* `file.mimetype` und `originalname` behauptet der Browser; gespeichert und
|
* `file.mimetype` und `originalname` behauptet der Browser; gespeichert und
|
||||||
* spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was
|
* spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was
|
||||||
* `detectImageMime` an den Bytes erkannt hat. Der Dateiname wird nur als
|
* `detectImageMime` an den Bytes erkannt hat.
|
||||||
* Anzeigetext gefuehrt (auf 255 Zeichen gekuerzt) und erscheint nie in
|
|
||||||
* einem HTTP-Header (T-PI9-06).
|
|
||||||
*
|
*
|
||||||
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
|
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
|
||||||
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
|
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
|
||||||
@@ -47,7 +87,10 @@ import {
|
|||||||
|
|
||||||
const ORIGINAL_NAME_MAX = 255;
|
const ORIGINAL_NAME_MAX = 255;
|
||||||
|
|
||||||
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — `data` NIE dabei. */
|
/** Ablageort unterhalb der Monorepo-Wurzel, so wie er in der Zeile steht. */
|
||||||
|
const STORAGE_PREFIX = 'user-files/dashboard-images/';
|
||||||
|
|
||||||
|
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — nie Bytes, nie Pfad. */
|
||||||
const META_SELECT = {
|
const META_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
originalName: true,
|
originalName: true,
|
||||||
@@ -64,10 +107,124 @@ export interface DashboardImageMeta {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loest das Bilderverzeichnis relativ zur Monorepo-Wurzel auf — Muster
|
||||||
|
* `resolveAvatarsDir()` (user.controller.ts): zur Laufzeit ist
|
||||||
|
* `__dirname` = apps/api/dist/dashboard/, also vier Ebenen hoch.
|
||||||
|
*
|
||||||
|
* `DASHBOARD_IMAGES_DIR` ist ein Testschalter (Muster `DESKTOP_DIST_DIR`,
|
||||||
|
* desktop.service.ts) und im Betrieb nie gesetzt; die Tests zeigen damit
|
||||||
|
* auf ein Wegwerfverzeichnis unter `os.tmpdir()`, statt `fs` nachzubauen.
|
||||||
|
*/
|
||||||
|
export function resolveDashboardImagesDir(): string {
|
||||||
|
const override = process.env.DASHBOARD_IMAGES_DIR;
|
||||||
|
if (override !== undefined && override !== '') {
|
||||||
|
return path.resolve(override);
|
||||||
|
}
|
||||||
|
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'dashboard-images');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */
|
||||||
|
function extensionFor(mimeType: string): string | null {
|
||||||
|
switch (mimeType) {
|
||||||
|
case 'image/png':
|
||||||
|
return 'png';
|
||||||
|
case 'image/jpeg':
|
||||||
|
return 'jpg';
|
||||||
|
case 'image/gif':
|
||||||
|
return 'gif';
|
||||||
|
case 'image/webp':
|
||||||
|
return 'webp';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relativer Pfad, wie er in der Zeile steht (`storagePath`). */
|
||||||
|
function relativeStoragePath(userId: string, id: string, extension: string): string {
|
||||||
|
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
|
||||||
|
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
|
||||||
|
* die erwartete Form hat oder aus dem Verzeichnis herausfuehren wuerde
|
||||||
|
* (T-HK4-01). Der Aufrufer behandelt `null` wie eine fehlende Datei.
|
||||||
|
*/
|
||||||
|
function absoluteImagePath(storagePath: string): string | null {
|
||||||
|
const normalized = storagePath.split('\\').join('/');
|
||||||
|
if (!normalized.startsWith(STORAGE_PREFIX)) return null;
|
||||||
|
|
||||||
|
const base = resolveDashboardImagesDir();
|
||||||
|
const absolute = path.resolve(base, normalized.slice(STORAGE_PREFIX.length));
|
||||||
|
if (absolute !== base && !absolute.startsWith(base + path.sep)) return null;
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardImagesService {
|
export class DashboardImagesService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(DashboardImagesService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der
|
||||||
|
* Betreiber nichts von Hand ausfuehren muss.
|
||||||
|
*
|
||||||
|
* ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die
|
||||||
|
* SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data`
|
||||||
|
* NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges
|
||||||
|
* DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte.
|
||||||
|
* Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live
|
||||||
|
* einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in
|
||||||
|
* `.planning/todos/pending/`).
|
||||||
|
*
|
||||||
|
* GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster
|
||||||
|
* `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle
|
||||||
|
* Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben
|
||||||
|
* wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer
|
||||||
|
* dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet
|
||||||
|
* (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in
|
||||||
|
* 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim
|
||||||
|
* DKV-Planer.
|
||||||
|
*
|
||||||
|
* Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein
|
||||||
|
* zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile
|
||||||
|
* wird protokolliert und haelt den Start nicht auf.
|
||||||
|
*/
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
const systemPrisma = forSystem(this.prisma);
|
||||||
|
const pending = await systemPrisma.dashboardImage.findMany({
|
||||||
|
where: { storagePath: null },
|
||||||
|
select: { id: true, userId: true, tenantId: true, mimeType: true, data: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let moved = 0;
|
||||||
|
for (const row of pending) {
|
||||||
|
if (row.data === null) continue;
|
||||||
|
try {
|
||||||
|
const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||||
|
const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId);
|
||||||
|
await tenantPrisma.dashboardImage.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { storagePath },
|
||||||
|
});
|
||||||
|
moved += 1;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moved > 0) {
|
||||||
|
this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
||||||
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -80,7 +237,13 @@ export class DashboardImagesService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
|
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
|
||||||
* begrenzt, gespeichert wird der erkannte Typ.
|
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
|
||||||
|
* die Zeile haelt den Pfad.
|
||||||
|
*
|
||||||
|
* Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der
|
||||||
|
* Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des
|
||||||
|
* Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine
|
||||||
|
* Zeile ohne Datei.
|
||||||
*/
|
*/
|
||||||
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -102,26 +265,41 @@ export class DashboardImagesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tenantPrisma.dashboardImage.create({
|
const created = await tenantPrisma.dashboardImage.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
tenantId: user.tenantId,
|
tenantId: user.tenantId,
|
||||||
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
||||||
mimeType,
|
mimeType,
|
||||||
size: file.buffer.length,
|
size: file.buffer.length,
|
||||||
// Befund am Typsystem (TS 5.9 + Prisma 6): `Bytes` verlangt
|
|
||||||
// `Uint8Array<ArrayBuffer>`, multers `Buffer` ist aber ueber
|
|
||||||
// `ArrayBufferLike` getypt (koennte ein SharedArrayBuffer sein) und
|
|
||||||
// wird ohne Zusicherung abgelehnt. `new Uint8Array(buffer)` kopiert in
|
|
||||||
// einen frischen ArrayBuffer — hoechstens 5 MiB, einmal je Upload —
|
|
||||||
// und ist damit ehrlich getypt statt zugesichert.
|
|
||||||
data: new Uint8Array(file.buffer),
|
|
||||||
},
|
},
|
||||||
select: META_SELECT,
|
select: META_SELECT,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer);
|
||||||
|
await tenantPrisma.dashboardImage.update({
|
||||||
|
where: { id: created.id },
|
||||||
|
data: { storagePath },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
await tenantPrisma.dashboardImage.delete({ where: { id: created.id } });
|
||||||
|
throw new InternalServerErrorException('Das Bild konnte nicht gespeichert werden.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt -> 404. */
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
|
||||||
|
* 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad
|
||||||
|
* (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404.
|
||||||
|
*/
|
||||||
async getBytes(
|
async getBytes(
|
||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -132,10 +310,51 @@ export class DashboardImagesService {
|
|||||||
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
||||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||||
}
|
}
|
||||||
return { mimeType: row.mimeType, data: row.data };
|
|
||||||
|
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||||
|
if (absolute === null) {
|
||||||
|
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
|
||||||
|
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. */
|
try {
|
||||||
|
const data = await fs.readFile(absolute);
|
||||||
|
return { mimeType: row.mimeType, data };
|
||||||
|
} catch (error) {
|
||||||
|
// 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(
|
||||||
|
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||||
|
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||||
|
* protokolliert und geschluckt (T-HK4-04).
|
||||||
|
*/
|
||||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
|
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
|
||||||
@@ -143,6 +362,47 @@ export class DashboardImagesService {
|
|||||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||||
}
|
}
|
||||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||||
|
|
||||||
|
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||||
|
if (absolute !== null) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(absolute);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Datei des geloeschten Bilderrahmen-Bildes ${id} konnte nicht entfernt werden: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt die Bytes an den servergenerierten Ort und liefert den
|
||||||
|
* relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht
|
||||||
|
* dabei (`recursive: true`).
|
||||||
|
*/
|
||||||
|
private async writeImageFile(
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
mimeType: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): Promise<string> {
|
||||||
|
const extension = extensionFor(mimeType);
|
||||||
|
if (extension === null) {
|
||||||
|
throw new Error(`Unbekannter Bildtyp '${mimeType}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storagePath = relativeStoragePath(userId, id, extension);
|
||||||
|
const absolute = absoluteImagePath(storagePath);
|
||||||
|
if (absolute === null) {
|
||||||
|
throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||||
|
await fs.writeFile(absolute, bytes);
|
||||||
|
return storagePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,8 +150,24 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
|||||||
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
|
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
|
||||||
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
|
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
|
||||||
* Summe: 4 Dateien, 5 Aufrufe.
|
* Summe: 4 Dateien, 5 Aufrufe.
|
||||||
|
*
|
||||||
|
* SIEBTER FALL (quick-260922-hk4): `dashboard-images.service.ts`, EIN
|
||||||
|
* Aufruf, ausschliesslich in `onApplicationBootstrap()` — der einmalige
|
||||||
|
* Umzug der Bilderrahmen-Bilder aus der Spalte `data` in den Dateibereich.
|
||||||
|
* Ein Startpfad hat keinen Mandanten im Ruecken und muss die noch nicht
|
||||||
|
* umgezogenen Zeilen ALLER Mandanten sehen; die passende Regel
|
||||||
|
* `system_read_policy ... FOR SELECT` auf "DashboardImage" legt die
|
||||||
|
* Migration 20260922120000 an. Dieselbe Datei bedient daneben Anfragewege
|
||||||
|
* (`list`/`upload`/`getBytes`/`remove`) — die bleiben ausnahmslos
|
||||||
|
* mandantengebunden, und auch der Umzug SCHREIBT je Zeile ueber
|
||||||
|
* `forTenant(prisma, row.tenantId, row.userId)`, nie ueber den
|
||||||
|
* Systemklienten. Praezedenz fuer "ein Dienst mit Anfrageweg UND
|
||||||
|
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
|
||||||
|
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
|
||||||
|
* Summe neu: 5 Dateien, 6 Aufrufe.
|
||||||
*/
|
*/
|
||||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||||
|
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
|
||||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||||
|
|||||||
@@ -283,8 +283,11 @@ Restores stattfinden.
|
|||||||
- **Verschlüsselungsschlüssel** `TESSERA_ENCRYPTION_KEY`: liegt nur in `.env` auf
|
- **Verschlüsselungsschlüssel** `TESSERA_ENCRYPTION_KEY`: liegt nur in `.env` auf
|
||||||
dem Host, **nicht** im Datenbank-Dump. Getrennt sichern (siehe Kapitel 2/3) – ohne
|
dem Host, **nicht** im Datenbank-Dump. Getrennt sichern (siehe Kapitel 2/3) – ohne
|
||||||
ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos.
|
ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos.
|
||||||
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, generierte
|
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, Bilder des
|
||||||
DKV-Exporte unter `user-files/`, siehe `apps/api/src/user/user.controller.ts` und
|
Bilderrahmen-Widgets unter `user-files/dashboard-images/<Benutzerkennung>/`,
|
||||||
|
generierte DKV-Exporte unter `user-files/`, siehe
|
||||||
|
`apps/api/src/user/user.controller.ts`,
|
||||||
|
`apps/api/src/dashboard/dashboard-images.service.ts` und
|
||||||
`apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien liegen im benannten
|
`apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien liegen im benannten
|
||||||
Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`. Der
|
Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`. Der
|
||||||
Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen:
|
Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen:
|
||||||
@@ -296,7 +299,11 @@ Restores stattfinden.
|
|||||||
user-files:
|
user-files:
|
||||||
```
|
```
|
||||||
|
|
||||||
Damit überstehen Avatare und DKV-Exporte ein `--force-recreate` von `api`. Wie bei
|
Damit überstehen Avatare, Bilderrahmen-Bilder und DKV-Exporte ein
|
||||||
|
`--force-recreate` von `api`. Seit den Bilderrahmen-Bildern (Version nach 1.3.0)
|
||||||
|
gehört dieses Volume zwingend zur Sicherung: `pg_dump` allein enthält diese
|
||||||
|
Bilder nicht mehr — genau das ist der Zweck der Umstellung, der
|
||||||
|
Datenbank-Abzug bleibt dadurch klein. Wie bei
|
||||||
`pgdata` zeigt `docker volume ls` das Volume mit vorangestelltem Projektnamen an
|
`pgdata` zeigt `docker volume ls` das Volume mit vorangestelltem Projektnamen an
|
||||||
(`<projekt>_user-files`). Gesichert werden die Dateien weiterhin mit
|
(`<projekt>_user-files`). Gesichert werden die Dateien weiterhin mit
|
||||||
`docker compose cp api:/app/user-files ./user-files-backup`, alternativ über eine
|
`docker compose cp api:/app/user-files ./user-files-backup`, alternativ über eine
|
||||||
|
|||||||
@@ -168,14 +168,14 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
|||||||
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
|
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
|
||||||
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
|
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
|
||||||
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
|
||||||
| dashboard | 1 | 18 | 0 | **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
| dashboard | 1 | 21 | 1 | **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
|
||||||
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
|
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
|
||||||
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
|
||||||
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
|
||||||
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
|
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
|
||||||
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
||||||
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
||||||
| **Summe** | **61** | **187** | **5** | **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
| **Summe** | **61** | **190** | **6** | **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||||
|
|
||||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare)
|
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 74 Paare)
|
||||||
|
|
||||||
@@ -673,7 +673,7 @@ werden.
|
|||||||
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
|
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
|
||||||
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
|
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
|
||||||
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
|
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
|
||||||
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
|
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
|
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
|
||||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
|
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
|
||||||
@@ -808,7 +808,10 @@ werden.
|
|||||||
Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext
|
Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext
|
||||||
(Etappe 3c) — erledigt (260914-eym):** Migration
|
(Etappe 3c) — erledigt (260914-eym):** Migration
|
||||||
`20260914120000_rls_system_context_read` (`is_system_context()`,
|
`20260914120000_rls_system_context_read` (`is_system_context()`,
|
||||||
`system_read_policy … FOR SELECT` auf fünf Tabellen), Schwesterhelfer
|
`system_read_policy … FOR SELECT` auf fünf Tabellen; seit 260922-hk4
|
||||||
|
kommt "DashboardImage" als sechste dazu, angelegt in der Migration
|
||||||
|
20260922120000 für den Bootstrap-Umzug der Bilderrahmen-Bilder),
|
||||||
|
Schwesterhelfer
|
||||||
`forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste;
|
`forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste;
|
||||||
siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||||
"## Systemkontext (Etappe 3c, 260914-eym)" und den Regelschluss je Fall
|
"## Systemkontext (Etappe 3c, 260914-eym)" und den Regelschluss je Fall
|
||||||
|
|||||||
Reference in New Issue
Block a user