Files
tessera-ctl/.planning/quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/260922-hk4-SUMMARY.md
T
schalli ee2b0256b5
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
docs(quick-260922-hk4): Akte - Bilder im Dateibereich, Rundgang mit Selbstheilungs-Befund
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:36:04 +02:00

222 lines
12 KiB
Markdown

---
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.