Files
tessera-ctl/.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-SUMMARY.md
T
schalli 8686b1a673
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 1m11s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m4s
docs(quick-260921-pi9): Akte - Bilderrahmen-Widget gebaut, zehnpunktiger Browser-Rundgang bestanden
Plan, Zusammenfassung (mit Rot-Nachweis, curl-Rundgang, abgehakter
Pruefliste und den drei im Rundgang gefundenen Befunden) sowie die Zeile in
der Quick-Tabelle von STATE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:22:32 +02:00

18 KiB
Raw Blame History

phase, plan, subsystem, tags, status, requires, provides, affects, tech-stack, key-files, decisions, metrics, actuals, plan_head_before
phase plan subsystem tags status requires provides affects tech-stack key-files decisions metrics actuals plan_head_before
quick-260921-pi9 01 apps/api/src/dashboard, apps/web/src/components/dashboard/widgets, apps/web/src/components/settings
dashboard
widget
bilderrahmen
upload
bytea
magic-bytes
rls
tdd
i18n
complete
STATE.md „NAECHSTER AUFTRAG“: erstes der zwei neuen Dashboard-Widgets, Produktfragen geklaert
Migration 20260911120000 (Regelform mit Benutzerdimension)
Widget-Typ picture-frame: Diashow aus hochgeladenen Bildern und https-Adressen mit Grossansicht
API dashboard/images: Bilder je Benutzer als bytea, Magic-Byte-Pruefung, 5 MiB / 30 Stueck, Besitz = Mandant UND Benutzer
Bildverwaltung im WidgetSettingsPanel (Einstellungen -> Dashboard)
apps/api/prisma/schema.prisma (neues Modell DashboardImage)
apps/api/src/dashboard/*
apps/web/src/components/dashboard/widget-registry.tsx (achter Typ)
apps/web/src/messages/de.json, en.json (Namensraum widgets.pictureFrame)
docs/mandantentrennung-zugriffsklassifikation.md (neues Paar, Zahlen nachgemessen)
added patterns
Magic-Byte-Erkennung als reine Funktion (kein file-type-Paket), entscheidet Annahme UND gespeicherten Typ
Eintragstyp als Vereinigung mit kind-Unterscheider in EINER geordneten Liste (promote, kein Listenpaar)
Fremdbilder laedt nur der Browser (<img referrerPolicy=no-referrer>), die API kennt keinen URL-Proxy
Prisma-Bytes: new Uint8Array(buffer) statt Zusicherung (TS 5.9 verlangt Uint8Array<ArrayBuffer>)
created modified
apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
apps/api/src/dashboard/dashboard-image-rules.ts
apps/api/src/dashboard/dashboard-image-rules.spec.ts
apps/api/src/dashboard/dashboard-images.service.ts
apps/api/src/dashboard/dashboard-images.service.spec.ts
apps/api/src/dashboard/dashboard-images.controller.ts
apps/api/src/dashboard/dashboard-images.controller.spec.ts
apps/web/src/lib/dashboard-images-api.ts
apps/web/src/lib/dashboard-images-api.test.ts
apps/web/src/components/dashboard/widgets/picture-frame-config.ts
apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts
apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx
apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx
apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx
apps/web/src/components/settings/picture-frame-config-form.tsx
apps/web/src/components/settings/picture-frame-config-form.test.tsx
apps/api/prisma/schema.prisma
apps/api/src/dashboard/dashboard.module.ts
apps/api/src/dashboard/dto/create-widget.dto.ts
docs/mandantentrennung-zugriffsklassifikation.md
apps/web/src/components/settings/widget-settings-panel.tsx
apps/web/src/components/dashboard/widget-registry.tsx
apps/web/src/components/dashboard/widget-registry.test.tsx
apps/web/src/components/dashboard/widget-catalog-modal.tsx
apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
apps/web/src/app/(portal)/page.tsx
apps/web/src/app/(portal)/page.test.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
apps/web/src/messages/umlaut-dictionary.ts
CHANGELOG.md
docs/anleitung-anwender.md
Prisma-Bytes ohne Zusicherung: new Uint8Array(file.buffer) kopiert einmal je Upload (hoechstens 5 MiB) — der Plan-Hinweis „data: file.buffer geht“ stimmt unter TS 5.9 + Prisma 6 nicht, der Compiler lehnt Buffer<ArrayBufferLike> ab
Widget-Test 11 prueft die Pause des Wechsels ueber das Verhalten (vier Intervalle vergehen, Bild bleibt), nicht ueber vi.getTimerCount(): React haelt nach einer Interaktion selbst einen Scheduler-Timer (gemessen 1), der Zaehler misst also nicht nur unseren Timer
Klassifikationsdokument: Bereichs- und Summenzeilen nachgemessen statt +6 addiert — settings (3 -> 4) und bug-reports waren seit 260914-m97 in der Summe nie mitgezaehlt, die Paarzahl der Klassen-Verteilung stand auf 72 bei tatsaechlich 73 Zeilen; jetzt 187 gebunden / 74 Paare, beides der Messung entnommen
Vorschau im Formular aria-hidden (dekorativ, die Unterschrift traegt den Sinn); das Kachelbild behaelt sein alt und damit eine Biome-Warnung der Stufe warn (onError auf <img> gilt der a11y-Regel als Interaktion — Fehlbefund)
duration completed
ca. 75 min (18:35 bis 19:50 Uhr, 21.09.2026) 2026-09-21
tokens tasks commits
36000 3 3
573d070

Quick-Aufgabe 260921-pi9: Dashboard-Widget „Bilderrahmen“ Summary

Ein neues Dashboard-Widget zeigt eigene Bilder als Diashow: hochgeladen (in der Datenbank, dem Benutzer gehoerend, 5 MiB je Datei, 30 je Benutzer) oder per https-Adresse eingebunden (der Browser laedt sie direkt, der Server ruft nie eine Adresse ab). Bildausschnitt, Wechselintervall, Reihenfolge/Zufall und Bildunterschrift stellt der Benutzer unter Einstellungen -> Dashboard ein; ein Klick zeigt das Bild gross. Alle Tore sind gruen, der curl-Rundgang lief gegen die lebende lokale API.

Was gebaut wurde

API (Commit 737974b). Prisma-Modell DashboardImage (data Bytes, keine Relation) mit handgeschriebener Migration 20260921120000_dashboard_image: Tabelle, beide Indizes, ENABLE/FORCE ROW LEVEL SECURITY und tenant_isolation_policy mit Benutzerdimension von Anfang an. Die Migration ist lokal angewendet (prisma migrate status: keine ausstehende), prisma generate gelaufen. dashboard-image-rules.ts erkennt PNG/JPEG/GIF/WebP an den Magic Bytes — file.mimetype und Dateiendung werden nie gelesen, der erkannte Typ ist zugleich der gespeicherte und der spaeter ausgelieferte. DashboardImagesService (list/upload/getBytes/remove) holt je Methode const tenantPrisma = forTenant(this.prisma, tenantId, userId); Liste und Zaehler filtern explizit { tenantId, userId }, getBytes/remove pruefen Besitz gegen Mandant UND Benutzer und antworten sonst 404 (nie 403). DashboardImagesController unter dashboard/images: GET -> POST (FileInterceptor('image', 5 MiB, eine Datei)) -> GET :id (Content-Type aus dem gespeicherten Typ, Cache-Control: private, max-age=86400, X-Content-Type-Options: nosniff, Content-Disposition: inline ohne Dateinamen, CSP default-src 'none'; sandbox) -> DELETE :id. Kein @Roles. CreateWidgetDto kennt 'picture-frame'.

Web (Commit c080580). picture-frame-config.ts: PictureFrameEntry als Vereinigung (upload | url) in EINER Liste, resolvePictureFrameConfig laesst alles weg, was der URL-Parser nicht als https: erkennt (T-PI9-07: die API prueft Config-Inhalte nicht, deshalb entscheidet allein diese Funktion, was zum src wird), Intervall 0 oder 5..3600 s (Vorgabe 30), pickNextIndex (Zufall zieht aus count-1 Kandidaten, nie das aktuelle). dashboard-images-api.ts schickt die Datei als FormData-Feld image ohne eigenen Content-Type, macht aus 413 die deutsche Meldung, 400-Meldungen kommen bereits deutsch von der API. PictureFrameWidget: Leerhinweis im Stil der anderen Widgets, <img referrerPolicy="no-referrer"> (Upload ueber /api-proxy/dashboard/images/:id, URL direkt), object-contain/object-cover, Unterschrift als Streifen, Timer nur bei > 1 Bild und Intervall > 0 und geschlossener Grossansicht (Raeumung im Cleanup), kaputte Bilder verlassen den Umlauf („Bild nicht verfuegbar“, wenn alle). Ausserhalb des Bearbeitungsmodus liegt das Bild in einem <button> (Grossansicht PictureFrameLightbox: Dialog fokussiert, Escape/Hintergrund/ Schliessen-Knopf, danach Fokus zurueck am Bild-Knopf); im Bearbeitungsmodus ein <div> ohne Handler — die Karte bleibt der Ziehgriff. PictureFrameConfigForm im WidgetSettingsPanel: drei Auswahlfelder (senden nur ihr Feld), Eintragsliste mit Vorschau/Unterschrift (Entwurf, Uebernahme bei Blur/Enter)/Pfeilen/Entfernen (Upload wird auch serverseitig geloescht, Fehler verschluckt), Datei hochladen (deaktiviert ab 30), Webadresse hinzufuegen (http -> role="alert", kein onChange). Listenaenderungen senden IMMER das ganze images-Array. Registry (minW 4, minH 4, defaultW 8, defaultH 8), Katalog, Seite, 28 Schluessel je Sprache unter widgets.pictureFrame.

Doku (Commit c3b4597). Changelog-Stichpunkt als erster unter „Unveroeffentlicht -> Neu“, Zeile in der Widget-Tabelle und Absatz unter „Dashboard > Widgets“ im Anwenderhandbuch, zwei Woerter auf der Erlaubnisliste des Umlaut-Waechters (siehe Deviations).

Die Tests, und der Beleg dass sie rot waren

Datei Faelle Rot-Lauf (vor der Umsetzung)
dashboard-image-rules.spec.ts 10 pnpm --filter @tessera/api exec vitest run src/dashboard/dashboard-image-rules.spec.ts -> Error: Cannot find module './dashboard-image-rules', 1 Test File failed, 10 Faelle nicht ausfuehrbar
dashboard-images.service.spec.ts 12 ... vitest run src/dashboard/dashboard-images.service.spec.ts -> Cannot find module './dashboard-images.service', 1 failed
dashboard-images.controller.spec.ts 5 ... vitest run src/dashboard/dashboard-images.controller.spec.ts -> Cannot find module './dashboard-images.controller', 1 failed
picture-frame-config.test.ts 11 pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/picture-frame-config.test.ts src/lib/dashboard-images-api.test.ts -> Failed to resolve import "./picture-frame-config", 2 Test Files failed
dashboard-images-api.test.ts 5 derselbe Lauf -> Failed to resolve import "./dashboard-images-api"
picture-frame-widget.test.tsx 12 nach der Umsetzung geschrieben (Plan verlangt Rot nur fuer Regel-/Dienst-/Helfer-Tests); erster Lauf 11/12, Test 11 wegen des React-Scheduler-Timers umgestellt (siehe decisions)
picture-frame-config-form.test.tsx 9 nach der Umsetzung geschrieben; erster Lauf 9/9

Zusammen 64 neue Faelle; die bestehenden Registry-/Katalog-/Seiten-Tests laufen mit dem achten Typ (Constraints-Tabelle, counted 28 -> 32, Attrappen um pictureFrame.* und das neue Widget-Modul ergaenzt).

curl-Rundgang gegen die lebende API

Die Container api/web lagen mit einem alten Image still; die API lief deshalb aus dem Quelltext (nest build + node dist/main.js) gegen eine eigens angelegte, leere Datenbank tessera_pi9 auf dem lokalen db-Container (Migrationen angewendet, Admin per Erstanlage), danach wieder geloescht. Kein Zugriff auf den Testserver.

Schritt Ergebnis
POST /dashboard/images mit 4x4-PNG 201, { id, originalName, mimeType: "image/png", size: 73, createdAt }
GET /dashboard/images 200, Liste mit denselben fuenf Feldern, kein data
GET /dashboard/images/<id> 200, Content-Type: image/png, Cache-Control: private, max-age=86400, X-Content-Type-Options: nosniff, Content-Disposition: inline, Content-Security-Policy: default-src 'none'; sandbox; Bytes per cmp identisch mit der Quelle
Textdatei als .png (type=image/png) 400 Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.
6-MiB-Datei 413 File too large (multer/Nest, im Web-Klienten deutsch)
erfundene Kennung 404
ohne Cookie 401
Kennung mit dem Cookie eines ZWEITEN Benutzers (GET und DELETE) 404 / 404 (Punkt (h) der Pruefliste bereits erledigt)
eigener DELETE 200 { id }, Liste danach []

Messungen (Endstand, HEAD c3b4597)

Groesse Ausgang (573d070) Jetzt
pnpm type-check 4/4 4/4
pnpm lint 5/5 5/5 (api 74 Warnungen, web 53, keine Stufe error)
API-Tests 1148 1175 (75 Dateien)
Web-Tests 531 569 (77 Dateien)
as unknown as in apps/api/src 27 27
as unknown as in apps/web/src 6 6
noNonNullAssertion in apps/api/src (biome) 56 56
noExplicitAny in apps/api/src (biome) 13 13
biome-ignore in apps/api/src 1 1
ts-expect-error 0 0
dangerouslySetInnerHTML in den drei neuen Komponenten – 0
RLS-Waechter src/prisma 30/30 laut Plan 78/78 (davon rls-coverage + rls-access-inventory 35/35)
de/en-Schluesselgleichheit widgets.pictureFrame – 28 = 28

Keine neue any, kein !, kein neues Paket.

Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP)

  • (a) Dashboard -> Bearbeiten -> „Widget hinzufuegen“ zeigt „Bilderrahmen“ mit Rahmen-Symbol; die platzierte Kachel (8x8) zeigt „Noch keine Bilder — ueber die Einstellungen hinzufuegen“
  • (b) Einstellungen -> Dashboard -> „Bilderrahmen #1“ aufklappen: PNG hochladen -> Vorschau erscheint in der Liste, GET /dashboard/images enthaelt den Eintrag ohne data
  • (c) https-Adresse hinzufuegen -> Eintrag mit Vorschau; http-Adresse -> rote Meldung „Bitte geben Sie eine vollstaendige https-Adresse ein.“, kein Eintrag
  • (d) .txt als .png umbenannt hochladen -> rote Meldung „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“
  • (e) Intervall 5 s, zwei Bilder -> Kachel wechselt; Zufall mit drei Bildern -> nie dasselbe zweimal hintereinander; Bildausschnitt umschalten -> object-cover / object-contain sichtbar anders
  • (f) Klick auf das Bild -> Grossansicht mit Unterschrift; Escape schliesst, Hintergrund-Klick schliesst, Schliessen-Knopf schliesst; waehrend geoeffnet kein Wechsel
  • (g) Bearbeitungsmodus: Klick auf das Bild oeffnet nichts, die Kachel laesst sich an jeder Stelle ziehen
  • (h) curl -b <cookie zweiter Benutzer> -o /dev/null -w '%{http_code}' .../dashboard/images/<id> -> 404 (bereits mit curl gegen die lokale API belegt, siehe Rundgang oben; im Browser optional wiederholen)
  • (i) Eintrag entfernen -> Bild verschwindet aus GET /dashboard/images und aus der Kachel
  • (j) Netzwerk-Tab: das Fremdbild laedt der Browser selbst (Anfrage an den Fremdhost mit Referrer Policy: no-referrer), im API-Log kein Aufruf der Fremdadresse

Rundgang durch den Orchestrator am 21.09.2026 (lokaler Stack, Abbilder aus HEAD, Playwright-MCP): alle zehn Punkte bestanden. Belege: (a) Katalog zeigt „Bilderrahmen“ mit Beschreibung, Leerhinweis in der Kachel; (b) rot.png hochgeladen, Vorschau 320 px, GET /dashboard/images liefert {id, originalName, mimeType, size, createdAt} ohne data; (c) http://example.com/bild.png → Meldung, kein Eintrag; https://www.gstatic.com/webp/gallery/1.webp → Eintrag mit Vorschau (eine zuvor eingetragene, serverseitig 400 liefernde Wikimedia-Adresse zeigte korrekt „Bild nicht verfügbar“); (d) Textdatei als .png → „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“; (e) Intervall 5 s: blau → gstatic → rot → blau im Sekundentakt gemessen; Zufall mit drei Bildern: 8 Wechsel in 42 s, nie dasselbe zweimal hintereinander; object-cover nach Umschalten; (f) Grossansicht nach Portal-Korrektur 8bf3601 ueber den ganzen Viewport (Hintergrund 1905x949), Escape schliesst mit Fokusrueckgabe auf „Bild groß anzeigen“, Hintergrund-Klick schliesst, waehrend geoeffnet 6 s lang kein Wechsel; (g) Bearbeitungsmodus: kein Bild-Knopf im Widget, Ziehen ueber die Bildflaeche verschiebt die Kachel (translate 8 → 480 px), kein Dialog; (h) siehe curl; (i) zweiten Eintrag entfernt → GET /dashboard/images nur noch rot.png, Config nur noch zwei Eintraege; (j) Netzwerk: gstatic-Abruf kommt vom Browser, API-Log ohne Treffer auf gstatic.

Drei Befunde aus dem Rundgang, behoben in 8bf3601: (1) die Grossansicht war auf die Kachelflaeche (531x216) beschraenkt — die Kachel liegt in einem react-grid-item mit CSS-transform, und ein transformierter Vorfahr wird fuer position: fixed zum Bezugsrahmen; jetzt createPortal in document.body wie der Kalender-Tooltip; (2) „1 Minuten“ im Wechselintervall → ICU-Plural in de/en, Formular-Test nutzt dafuer createTranslator von next-intl auf der echten de.json; (3) Standardgroesse 8x8 (216 px hoch) zu flach → 8x12 wie der Kalender. Web-Tests 569 unveraendert in der Zahl (ein Fall um die Singular-Pruefung ergaenzt).

Deviations from Plan

  1. [Rule 1 - Bug] data: file.buffer kompiliert nicht. Der Executor-Hinweis „data: file.buffer beim Anlegen geht“ stimmt unter TS 5.9 + Prisma 6.19 nicht: Bytes verlangt Uint8Array<ArrayBuffer>, multers Buffer ist ueber ArrayBufferLike getypt und wird abgelehnt (TS2322). Statt einer Zusicherung kopiert new Uint8Array(file.buffer) einmal je Upload (hoechstens 5 MiB). Aufgabe 1, Commit 737974b.
  2. [Rule 3 - Blocking] Umlaut-Waechter. Der volle Web-Testlauf meldete die neuen de.json-Woerter „Bildausschnitt“ und „Webadresse“ als unbekannte ss-Tokens. Beide sind korrektes Deutsch und stehen jetzt auf UMLAUT_ALLOWLIST in apps/web/src/messages/umlaut-dictionary.ts (Datei nicht im Plan). Aufgabe 3, Commit c3b4597.
  3. Verify-Skript Aufgabe 1: git show --stat kuerzt den Migrationspfad auf .../20260921120000_dashboard_image/migration.sql, der grep des Plans auf den vollen Pfad schlaegt deshalb fehl; mit --stat=200 ist die Migration im Commit eindeutig nachgewiesen. Kein Code-Befund.
  4. Klassifikationsdokument, mehr als die geplante eine Zeile: die Nachmessung mit der Gate-Schleife ergab, dass Bereichs- und Summenzeilen bereits vor dieser Aufgabe um zwei Rohtreffer (settings 3 statt 4, bug-reports nie summiert) und die Klassen-Verteilung um ein Paar (bug-reports) hinterherhingen. Beides ist nachgezogen und im Dokument als Nachtrag 260921-pi9 begruendet; der Waechter rls-access-inventory prueft nur die Paartabelle und war davon nicht betroffen.
  5. Widget-Test 11 misst die Pause des Wechsels ueber das Verhalten statt ueber vi.getTimerCount() (siehe decisions).
  6. Zusatz des Orchestrators umgesetzt: Zeile und Absatz in docs/anleitung-anwender.md im Aufgabe-3-Commit.

Nicht geaendert: STATE.md, ROADMAP.md, keine neue Abhaengigkeit, kein Deploy, kein Zugriff auf den Testserver.

Known Stubs

Keine. Jede Kette ist verdrahtet: Datei -> Upload -> Config -> Kachel -> Proxy -> API -> Bytes; https-Adresse -> Config -> Kachel -> Browser.

Threat Flags

Keine neue Flaeche ausserhalb des <threat_model> des Plans: die vier Routen unter dashboard/images und die <img>-Fremdabrufe sind dort als T-PI9-01 bis T-PI9-11 erfasst und mitigiert; Content-Disposition ohne Dateinamen und Cache-Control: private sind mit curl belegt.

Self-Check: PASSED

Alle 16 neu angelegten Dateien liegen auf der Platte, die drei Commits 737974b, c080580 und c3b4597 sind in git log auffindbar (git rev-list --count 573d070..HEAD = 3). Die Zahlen der Tabelle stammen aus tatsaechlich gelaufenen Befehlen.