222 lines
12 KiB
Markdown
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.
|