--- phase: quick-260921-pi9 plan: 01 subsystem: apps/api/src/dashboard, apps/web/src/components/dashboard/widgets, apps/web/src/components/settings tags: [dashboard, widget, bilderrahmen, upload, bytea, magic-bytes, rls, tdd, i18n] status: complete requires: - "STATE.md „NAECHSTER AUFTRAG“: erstes der zwei neuen Dashboard-Widgets, Produktfragen geklaert" - "Migration 20260911120000 (Regelform mit Benutzerdimension)" provides: - "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)" affects: - "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)" tech-stack: 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 (), die API kennt keinen URL-Proxy" - "Prisma-Bytes: new Uint8Array(buffer) statt Zusicherung (TS 5.9 verlangt Uint8Array)" key-files: created: - 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 modified: - 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 decisions: - "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 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 gilt der a11y-Regel als Interaktion — Fehlbefund)" metrics: duration: "ca. 75 min (18:35 bis 19:50 Uhr, 21.09.2026)" completed: 2026-09-21 actuals: tokens: 36000 tasks: 3 commits: 3 plan_head_before: 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, `` (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 `