--- 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/.` 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//.` — 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 `//.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 Aufgabe 1: Schema, Migration, Dienst auf Dateiablage umstellen, Bootstrap-Umzug, Tests 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 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` 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 Migration angewendet, `storagePath` gefuellt fuer die vorhandenen lokalen Zeilen (Bootstrap nachgewiesen), Dateien liegen unter `user-files/dashboard-images//`. Spalte `data` bleibt vorerst bestehen (zweistufig, siehe Plan). API-Tests ≥ 8 neue Faelle, RLS-Waechter unveraendert gruen. Keine `any`, Zaehler unveraendert. Aufgabe 2: Changelog, Betriebsanleitung, Todo fuer die DROP-Migration, Voll-Tore CHANGELOG.md, docs/anleitung-betrieb.md, .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md 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` cd /home/vicolab/projects/tessera-ctl && grep -q 'Dateibereich' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test 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`. ## 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) ` - `.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`. 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. |