docs(quick-260922-hk4): Akte - Bilder im Dateibereich, Rundgang mit Selbstheilungs-Befund
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m16s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m4s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 15:36:04 +02:00
parent 82472ee665
commit ee2b0256b5
3 changed files with 388 additions and 7 deletions
+8 -7
View File
@@ -4,10 +4,10 @@ milestone: v1.3
current_phase: 18
current_phase_name: desktop-client-fertigstellen
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."
last_updated: "2026-09-22T12:15:00.000Z"
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-22T13:40:00.000Z"
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
progress:
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/) |
| 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-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
@@ -502,8 +503,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity
Last session: 2026-09-22T12:15:00Z
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; danach Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, zuletzt die Freigabe 1.3.0.
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.
Last session: 2026-09-22T13:40:00Z
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: 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
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>
@@ -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.