--- 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//.`; 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 `` 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//.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.