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