diff --git a/.planning/STATE.md b/.planning/STATE.md index ecd55d1..852b20e 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Plan: 6 of 6 Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen -Last activity: 2026-09-21 - Quick 260921-oxm: IMAP-Einstellung STARTTLS erzwingt jetzt wirklich Verschluesselung (die bisher gesetzte Option existiert in imapflow gar nicht), Outlook-Anhaenge werden ueber die Content-Disposition wieder erkannt +Last activity: 2026-09-21 - Quick 260921-pi9: Dashboard-Widget „Bilderrahmen“ (Upload in der Datenbank oder https-Adresse, Diashow mit Grossansicht), im Browser zehnpunktig nachgewiesen; Quick 260921-qd3 (XFrame) laeuft Progress: [██████████] 99% @@ -455,6 +455,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests. | 260921-ldf | **Der Wackeltest war ein echter Produktfehler — nachgewiesen, nicht vermutet.** CI-Lauf 395 war rot; durchgefallen war ein Test aus quick-260914-m97, rund einmal in 17 vollen Laeufen, isoliert nie. Symptom: Vorschaubild da, Haekchen "Bildschirmfoto anhaengen" aus. **Ursache:** der Fehler-melden-Dialog war dauerhaft eingehaengt, sein `useState(screenshot !== null)` lief damit genau einmal — beim allerersten Laden der Seite, als noch kein Bild existierte — und der richtige Wert wurde erst von einem `useEffect` nachgezogen, der bauartbedingt nach dem Commit laeuft. **Beleg, deterministisch statt statistisch:** ein MutationObserver ueber jeden einzelnen DOM-Commit zeigt gegen den alten Stand, ohne jede kuenstliche Verzoegerung: `COMMIT dialog=true img=ja box=AUS` gefolgt von `COMMIT dialog=true img=ja box=AN`. Der falsche Zustand entsteht bei JEDEM Oeffnen, nicht nur unter Last, und haelt zwei Makrotask-Runden — dazwischen darf der Browser zeichnen, ein Nutzer kann es also sehen. **Ehrliche Einordnung der Tragweite:** die Korrektur kommt binnen Millisekunden, lange bevor jemand "Senden" treffen kann. Der befuerchtete Fall (Bild gesehen, abgeschickt, Bild fehlt) ist NICHT erreichbar; es bleibt ein kurzes Flackern. Repariert wurde trotzdem der Produktcode, nicht der Test — wer einen wirklich vorhandenen falschen Zustand im Test wegberuhigt, laesst ihn stehen. **Zwei Teilursachen, einzeln reicht keine:** der Dialog wird nur noch eingehaengt, solange er offen ist (frischer Mount je Oeffnen, der zuruecksetzende Effekt entfaellt), und das Haekchen wird beim Rendern abgeleitet statt nachgezogen. Dieselbe Ursache lag an einer zweiten Stelle: nach einem Versand stand beim erneuten Oeffnen zwei Runden lang der alte Danke-Bildschirm im DOM. **Zur Statistik, weil es der Kern der Sache ist:** 20 volle Laeufe ohne Fehlschlag gelten ausdruecklich NICHT als Beweis — bei der Ausgangsrate 1:17 waeren sie auch ohne Reparatur zu rund 30 Prozent zu erwarten. Tragend ist, dass der falsche Zwischenzustand nicht mehr existiert und die neuen Tests gegen den alten Stand 5 von 5 rot sind. Kein `retry`, kein hoeheres Zeitlimit — die Ursache war nie blosse Zeit. **Zwei Konstruktionsfehler des Tests mitbehoben:** das `expect` innerhalb der Attrappe (wirft es, landet der Fehler mitten im `await` von `captureScreenshot`, dessen `catch` still `null` liefert — der Test waere viel spaeter mit "kein Vorschaubild" durchgefallen, also in die falsche Richtung zeigend) und die per `Object.defineProperty` gesetzte `document.body`-Groesse, die `cleanup()` ueberlebte und alle zwoelf folgenden Tests derselben Datei 3200x1000 sehen liess. **Widerlegt unterwegs:** der Verdacht auf den dynamischen Import von `html-to-image` — er loest auf, bevor ein zuvor gesetzter `setTimeout(0)` feuert, ueberschreitet also keine Makrotask-Grenze. **Zahlen:** Warnungen 399 unveraendert, web-Tests 529 → 531, api 72/1143 unveraendert, type-check 4/4, lint 5/5. | 2026-09-21 | c0ab5b5,de7fdb7,9f02fcc,a6181e2 | [260921-ldf-wackeltest-fehler-melden-haekchen-bildsc](./quick/260921-ldf-wackeltest-fehler-melden-haekchen-bildsc/) | | 260921-m34 | **288 `any` im Backend beurteilt: 15 bleiben, mit Urteil je Stelle.** Drei Durchgaenge. **Der groesste Posten war ein einziges Missverstaendnis:** 105 Stellen trugen `forTenant(...) as any`, obwohl `prisma.$extends()` laengst einen getypten Klienten liefert — die Zusicherung war nie noetig. Entfernen ergab genau EINEN Folgefehler, und der war selbst ein Befund (eine Handannotation, die nur existierte, um unter dem ungetypten Klienten eine Meldung zu umgehen, und falsch geworden war). **Aufgabe 2 war die sicherheitsrelevante:** ein gemeinsamer Typ `AuthUser` fuer die Aufrufer-Identitaet. Die `tenantId`-Frage wurde HERGELEITET, nicht nach Bequemlichkeit entschieden — `string | undefined` erzeugt 8 Fehler, `string` keinen, und das war ausdruecklich kein Argument. Belege: Pflichtspalte in `schema.prisma:38`, Bestandstyp `SessionUser`, und der Super-Admin-Zweig in `TenantGuard`. Der dritte Beleg widerlegt `string` NICHT, weil der Waechter sein Anfrageobjekt ungetypt holt und `AuthUser` gar nicht liest — der Zweig kann also nicht zu totem Code werden. Dass es ihn gibt, steht trotzdem im Typsystem: `AuthenticatedRequest.tenantId` ist `string | null | undefined`, das `null` stammt nur von dort, mit Warnkommentar. `tenant.guard.ts` ueber den ganzen Lauf 0 geaenderte Zeilen (Tor). **Aufgabe 3 ist zugleich das Urteilsregister:** typisiert 252, auf `unknown` umgestellt 21, bleibt 15 — jede der 15 mit Begruendung im Code (6 node-forge, wo die mitgelieferten Typen die Bibliothek nachweislich falsch beschreiben; 3 Cron; 4 `withTenantTransaction`, wo der genaue Typ eine bewusst unvollstaendige Test-Attrappe braeche; 2 imapflow). Null war ausdruecklich NICHT das Ziel. **Vier Befunde gemeldet statt still repariert** — zwei davon brauchen eine Entscheidung des Nutzers: (B-06, sicherheitsrelevant) `imap.provider.ts:402` setzt `requireTLS`, das es in imapflow 1.4.3 NIRGENDS gibt (vom Orchestrator unabhaengig nachgeprueft: kein Treffer im ganzen Paket). Die Option wird still verworfen, die Einstellung "STARTTLS" erzwingt also nichts; die Bibliothek faellt dann auf ihr Standardverhalten zurueck und setzt laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — sie nennt das selbst eine Downgrade-Angriffsflaeche. Richtig waere `doSTARTTLS: true`. Die `as any`-Zusicherung hatte das verdeckt. (B-05) `imap.provider.ts:78` liest `.parameters` von einer Zeichenkette (imapflow deklariert `disposition: string`, die Parameter liegen in `dispositionParameters`) — zur Laufzeit immer `undefined`, Outlook-Anhaenge als `application/octet-stream` werden ueber Content-Disposition nicht erkannt; betrifft den DKV-Rechnungseinzug. Dazu (B-04) eine Falle im RLS-Erkenner (er zaehlt jede `select:`-Angabe ausserhalb eines Modellaufrufs als Verstoss) — Erkenner NICHT aufgeweicht, Typ anders hergeleitet; und (B-07) httpntlm liefert den Rumpf als Zeichenkette, nicht als Buffer. **Zahlen:** Diagnosen 399 → 125, `any` im Quellcode 288 → 15, `apps/web` 1 → 0, Disziplin-Zaehler unveraendert (`as unknown as` 33, `noNonNullAssertion` 56, Unterdrueckungen 1, `ts-expect-error` 0), api 72/1143, web 73/531, type-check 4/4, lint 5/5, RLS-Waechter 30/30. | 2026-09-21 | b188946,f2fc39f,7c9d7c1,52668c2,32591b6,3892c5f,d8fb9ae | [260921-m34-288-any-im-backend-einzeln-beurteilen-un](./quick/260921-m34-288-any-im-backend-einzeln-beurteilen-un/) | | 260921-oxm | **IMAP: STARTTLS erzwingt jetzt wirklich, Outlook-Anhaenge werden erkannt.** Die zwei Befunde aus m34, beide mit Entscheidung des Nutzers behoben. **(B-06, Sicherheit)** `imap.provider.ts` setzte `requireTLS` — eine Option, die es in imapflow 1.4.3 NIRGENDS gibt (Orchestrator: kein Treffer im ganzen Paket). Sie wurde still verworfen, die Bibliothek fiel auf ihr Standardverhalten zurueck und setzte laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — Benutzername und Kennwort gingen dann im Klartext. Ersetzt durch `doSTARTTLS`, nachgeprueft in `imap-flow.d.ts:81` und `imap-flow.js:1183`. Bei implizitem TLS wird ausdruecklich `false` gesetzt, nicht weggelassen: die Bibliothek wirft bei `secure=true` zusammen mit `doSTARTTLS=true`. **Gewollte Verhaltensaenderung:** ein auf STARTTLS eingestelltes Postfach, dessen Server das nicht anbietet, meldet ab jetzt einen Verbindungsfehler statt still im Klartext zu verbinden. **(B-05)** `imap.provider.ts:78` las `.parameters` von einer Zeichenkette — imapflow fuehrt die Parameter in `dispositionParameters` (`imap-flow.d.ts:450`), der Ausdruck war zur Laufzeit immer leer. Anhaenge als `application/octet-stream` (typisch Outlook) wurden ueber die Content-Disposition nicht erkannt; betraf den DKV-Rechnungseinzug. Nachgeprueft: imapflow schreibt die Schluessel klein und setzt RFC-2231-Fortsetzungen selbst zusammen — dafuer war nichts zu tun. **Rot-dann-gruen belegt:** gegen den Stand mit Tests aber ohne Reparatur scheiterten genau 3 von 12 Faellen, danach 12/12. Zwei der fuenf neuen Faelle sind absichtlich von Anfang an gruen — sie sichern ab, dass B-05 nicht zu viel einsammelt. **Die `as any`-Zusicherung konnte ersatzlos entfallen** (sie existierte nur wegen der erfundenen Option); alle sechs uebergebenen Felder sind jetzt deklariert. **Zahlen:** `any` im Backend 15 → 13, `as unknown as` 33 → 27 (Testdoppel-Einhaengung in einen Helfer gezogen statt fuenf neue Umdeutungen), kein Zaehler gestiegen, api-Tests 1143 → 1148, web 73/531, type-check 4/4, lint 5/5. | 2026-09-21 | 7691d1f,d0266bf,6def539 | [260921-oxm-imap-starttls-wirklich-erzwingen-und-anh](./quick/260921-oxm-imap-starttls-wirklich-erzwingen-und-anh/) | +| 260921-pi9 | **Dashboard-Widget „Bilderrahmen“: eigene Bilder oder https-Adressen als Diashow.** Erstes der zwei vom Nutzer bestellten Widgets. **API:** neues Prisma-Modell `DashboardImage` (Bytes in der Datenbank — kein neues Docker-Volume, Sicherung ueber den DB-Dump), handgeschriebene Migration `20260921120000_dashboard_image` mit RLS-Regel inklusive Benutzerdimension; Routen `GET/POST /dashboard/images`, `GET/DELETE /dashboard/images/:id`; Bildtyp ausschliesslich ueber Magic Bytes (PNG/JPEG/GIF/WebP), nicht ueber den behaupteten MIME-Typ; 5 MiB je Datei (multer-Grenze, 413), 30 Bilder je Benutzer; fremde Kennung → 404, nie 403; Binaerantwort mit `Cache-Control: private`, `nosniff`, `Content-Disposition: inline` ohne Dateinamen, CSP `default-src 'none'; sandbox`. **Web:** Widget `picture-frame` mit einer geordneten Liste `images` aus Eintraegen mit `kind`-Unterscheider (`upload` | `url`), Bildausschnitt contain/cover, Intervall 0/5…3600 s, Reihenfolge oder Zufall (nie dasselbe zweimal), Unterschrift-Streifen, Grossansicht per Klick (nicht im Bearbeitungsmodus), kaputte Bilder fallen aus dem Umlauf; Bildverwaltung im `WidgetSettingsPanel` (Upload, https-Adresse, Unterschrift, Pfeile, Entfernen loescht den Upload auch serverseitig). Fremdbilder laedt AUSSCHLIESSLICH der Browser (``) — die API ruft nie eine Adresse ab, keine SSRF-Flaeche; https-Pflicht web-seitig zweifach (Formular + Render-Resolver), weil die API Widget-Configs nicht inhaltlich prueft. **Browser-Rundgang (Orchestrator, zehn Punkte) fand drei Dinge, behoben in 8bf3601:** die Grossansicht war auf die Kachelflaeche beschraenkt (ein `react-grid-item` mit CSS-`transform` wird fuer `position: fixed` zum Bezugsrahmen → `createPortal` in `document.body` wie der Kalender-Tooltip), „1 Minuten“ → ICU-Plural, Standardkachel 8x8 zu flach → 8x12. curl-Rundgang gegen die lebende API belegt 201/400/413/404/401 und fremder Benutzer → 404. **Zahlen:** api 1148 → 1175, web 531 → 569, type-check 4/4, lint 5/5, RLS-Waechter 78/78, Zaehler unveraendert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` 13). Anwenderhandbuch und CHANGELOG ergaenzt. | 2026-09-21 | 737974b,c080580,c3b4597,8bf3601 | [260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc](./quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/) | ## Deferred Items @@ -496,8 +497,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen. ## Session Continuity -Last session: 2026-09-21T04:50:00Z -Resumed: 2026-09-21 — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 55aa287, CI-Lauf 387 fuer 55aa287 erfolgreich (Beta-Images gebaut). Push und CI aus dem letzten Stopp-Punkt sind damit erledigt. -Stopped at: Warte auf Nutzerentscheidung, womit weitergearbeitet wird. Offen fuer den User: alpha pullen (web+api) und danach am Windows-VM-Client die echte Fehlermeldung schicken (Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile pruefen); eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf. Technisch offen im Ledger: WINDOWS #35 (Biome laeuft nicht — biome.json:3 `organizeImports` ist in Biome 2.5.0 unbekannt, `biome check` bricht mit Konfigurationsfehler ab, reproduziert 2026-09-21) und WINDOWS #36 (403-Antworten bleiben in handleSubmit/handleDelete ohne sichtbare Reaktion). +Last session: 2026-09-21T16:45:00Z +Resumed: 2026-09-21 (abends) — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 573d070, CI-Lauf 400 fuer 573d070 erfolgreich (Beta-Images gebaut). WINDOWS #35 und #36 sind im Ledger auf fixed; der Fehler- und Lint-Rueckstand ist abgearbeitet. +Stopped at: Warte auf Nutzerentscheidung. Naechster Auftrag laut Stopp-Punkt: zwei neue Dashboard-Widgets — das erste ein Bilderrahmen (Produktfragen geklaert, siehe stopped_at im Kopf), das zweite hat der Nutzer noch nicht benannt. Offen beim Nutzer: alpha ziehen, Windows-Client pruefen, Freigabe 1.3.0 auf Zuruf. Resume file: None -Last activity: 2026-09-21 - Quick 260921-oxm: IMAP-Einstellung STARTTLS erzwingt jetzt wirklich Verschluesselung (die bisher gesetzte Option existiert in imapflow gar nicht), Outlook-Anhaenge werden ueber die Content-Disposition wieder erkannt +Last activity: 2026-09-21 - Quick 260921-pi9: Dashboard-Widget „Bilderrahmen“ (Upload in der Datenbank oder https-Adresse, Diashow mit Grossansicht), im Browser zehnpunktig nachgewiesen; Quick 260921-qd3 (XFrame) laeuft diff --git a/.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-PLAN.md b/.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-PLAN.md new file mode 100644 index 0000000..619415c --- /dev/null +++ b/.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-PLAN.md @@ -0,0 +1,287 @@ +--- +phase: quick-260921-pi9 +plan: 01 +type: execute +wave: 1 +depends_on: [] +autonomous: true +requirements: [QUICK-260921-PI9] + +files_modified: + - apps/api/prisma/schema.prisma + - 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/api/src/dashboard/dashboard.module.ts + - apps/api/src/dashboard/dto/create-widget.dto.ts + - docs/mandantentrennung-zugriffsklassifikation.md + - 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/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 + - CHANGELOG.md + +estimate: + tokens: 260000 + raw_tokens: 260000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "Im Widget-Katalog gibt es „Bilderrahmen“ (en „Picture frame“); eine frisch platzierte Kachel zeigt den Hinweis „Noch keine Bilder — über die Einstellungen hinzufügen“ im Stil der anderen leeren Widgets." + - "Unter Einstellungen → Dashboard → Bilderrahmen kann der Benutzer Bilder hochladen (PNG/JPEG/GIF/WebP, je Datei höchstens 5 MiB, je Benutzer höchstens 30) ODER eine https-Webadresse eintragen; http-, data- oder javascript-Adressen werden mit deutscher Meldung abgewiesen, eine umbenannte Nicht-Bild-Datei ebenso (Erkennung an den Magic Bytes, nicht am Dateinamen oder am gemeldeten MIME-Typ)." + - "Jeder Eintrag hat Vorschaubild, Bildunterschrift, Entfernen und Pfeile Nach oben/Nach unten; das Entfernen eines hochgeladenen Bildes löscht es auch auf dem Server (best effort); ein Eintrag, dessen Bild nicht mehr existiert, wird als „Bild nicht verfügbar“ angezeigt und in der Kachel übersprungen." + - "Die Kachel zeigt die Bilder gemäß Einstellung ganz sichtbar (`object-contain`) oder formatfüllend (`object-cover`), wechselt im eingestellten Intervall (5–3600 s, 0 = kein Wechsel, Voreinstellung 30 s) in Reihenfolge oder zufällig (Zufall wählt bei mehr als einem Bild nie das aktuelle erneut), zeigt die Bildunterschrift als Streifen am unteren Rand; Fremdbilder lädt ausschließlich der Browser (``), der Server ruft nie eine Webadresse ab." + - "Klick auf das Bild (nur außerhalb des Bearbeitungsmodus) öffnet eine Großansicht mit Bildunterschrift; Escape, Klick auf den Hintergrund oder der Schließen-Knopf schließen sie, der Fokus kehrt zum Bild zurück, der Bildwechsel pausiert solange. Im Bearbeitungsmodus bleibt die ganze Karte der Ziehgriff, ein Klick öffnet nichts." + - "Hochgeladene Bilder gehören dem hochladenden Benutzer: `GET/DELETE /dashboard/images/:id` liefern für eine fremde Kennung (anderer Benutzer ODER anderer Mandant) 404, nie 403; die Auslieferung trägt `Content-Type` aus dem gespeicherten, per Magic Bytes bestimmten Typ, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`." + - "Alle Tore bleiben grün: `pnpm type-check` 4/4, `pnpm lint` 5/5, API-Tests mindestens 1170 (heute 1148), Web-Tests mindestens 560 (heute 531), RLS-Wächter 30/30; keine neue `any`, `as unknown as` in apps/api/src bleibt 27, `noNonNullAssertion` bleibt 56." + artifacts: + - "apps/api/prisma/schema.prisma — Modell `DashboardImage` (id, userId, tenantId, originalName, mimeType, size, data Bytes, createdAt; @@index userId, tenantId)" + - "apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql — CREATE TABLE + Indizes + ENABLE/FORCE ROW LEVEL SECURITY + `tenant_isolation_policy` mit Benutzerdimension" + - "apps/api/src/dashboard/dashboard-image-rules.ts — reine Regeln: `detectImageMime(buffer)`, `DASHBOARD_IMAGE_MAX_BYTES`, `DASHBOARD_IMAGE_MAX_COUNT`" + - "apps/api/src/dashboard/dashboard-images.service.ts — `list`, `upload`, `getBytes`, `remove`, alle über `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`" + - "apps/api/src/dashboard/dashboard-images.controller.ts — `@Controller('dashboard/images')`: `GET /`, `POST /` (FileInterceptor `image`), `GET /:id` (Binär), `DELETE /:id`" + - "apps/web/src/components/dashboard/widgets/picture-frame-config.ts — Typen `PictureFrameEntry`/`PictureFrameConfig`, `resolvePictureFrameConfig`, `isHttpsUrl`, `pickNextIndex`, Intervall-Grenzen" + - "apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx + picture-frame-lightbox.tsx — Kachel mit Wechsel, Bildunterschrift, Großansicht" + - "apps/web/src/components/settings/picture-frame-config-form.tsx — Bildverwaltung im WidgetSettingsPanel" + - "apps/web/src/lib/dashboard-images-api.ts — `fetchDashboardImages`, `uploadDashboardImage`, `deleteDashboardImage`, `dashboardImageSrc`" + - "apps/web/src/messages/de.json + en.json — Namensraum `widgets.pictureFrame`" + - "CHANGELOG.md — Stichpunkt unter „Unveröffentlicht → Neu“" + key_links: + - "Datei-Eingabe im Einstellungsformular -> `uploadDashboardImage(file)` (FormData-Feld `image`) -> `POST /dashboard/images` -> `detectImageMime` + Zähler -> `DashboardImage`-Zeile -> Antwort `{ id, … }` -> `onChange({ images: [...alt, { kind: 'upload', imageId }] })` -> `PATCH /dashboard/widgets/:id/config` (bestehend, flache Zusammenführung: `images` immer als GANZES Array senden)" + - "Kachel: `resolvePictureFrameConfig(config)` -> sichtbare Einträge -> `` -> `/api-proxy/dashboard/images/:id` (Next-Rewrite aus next.config.ts, Cookies laufen mit) -> `GET /dashboard/images/:id` -> Besitzprüfung -> Bytes" + - "Wächter: neues Modell mit `tenantId` -> `rls-coverage.spec.ts` verlangt ENABLE + POLICY in einer Migration; neue (Datei, Modell)-Fundstelle `dashboard-images.service.ts`/`dashboardImage` -> `rls-access-inventory.spec.ts` verlangt eine Zeile in docs/mandantentrennung-zugriffsklassifikation.md" +--- + +# Quick-Aufgabe 260921-pi9: Dashboard-Widget „Bilderrahmen“ + + +Ein neues Dashboard-Widget „Bilderrahmen“ (Widget-Typ `picture-frame`, Übersetzungs-Namensraum `widgets.pictureFrame`): Bilder werden hochgeladen (in PostgreSQL als `bytea`, dem Benutzer gehörend, 5 MiB je Datei, 30 je Benutzer) ODER als https-Webadresse eingebunden (der Browser lädt sie direkt, der Server ruft nie etwas ab). Einstellungen: Bildausschnitt, Wechselintervall, Reihenfolge/Zufall, Bildunterschrift je Eintrag; Klick zeigt das Bild groß. Stil und Bedienmuster wie die bestehenden Widgets. + +Purpose: erstes der zwei vom Nutzer gewünschten neuen Widgets (STATE.md „NAECHSTER AUFTRAG“); die Produktfragen sind geklärt, die technischen Entscheidungen hat der Orchestrator getroffen (siehe Kasten unten) — dieser Plan setzt sie um, ohne sie neu zu öffnen. +Output: API-Modell + Migration + Endpunkte mit Tests, Web-Widget + Einstellungsformular + Großansicht + Übersetzungen mit Tests, Changelog-Eintrag; alle Tore grün. + + +## Gebundene Entscheidungen (Orchestrator, nicht neu verhandeln) + +1. Speicherung als `bytea` im neuen Prisma-Modell `DashboardImage` (kein Docker-Volume; Sicherung deckt es mit ab). Migration als SQL-Datei, angewendet mit `prisma migrate deploy` — nie `db push`. +2. Grenzen: 5 MiB je Datei (`limits.fileSize`), 30 Bilder je Benutzer (Dienst zählt je Mandant+Benutzer). Erlaubt PNG/JPEG/GIF/WebP, entschieden über Magic Bytes; alles andere 400 mit deutscher Meldung. +3. Endpunkte unter `dashboard/images` (angemeldet): `GET` (eigene Liste, nur Metadaten), `POST` (multipart-Feld `image`), `GET :id` (Binär mit den genannten Headern), `DELETE :id`. Besitz = gleicher Mandant UND gleicher Benutzer; fremde Kennung → 404. Statische Route vor `:id`. +4. Widget-Konfiguration im bestehenden Config-JSON: `images: Array<{ kind: 'upload', imageId, caption? } | { kind: 'url', url, caption? }>`, `fit: 'contain' | 'cover'`, `intervalSeconds` (0 = kein Wechsel, sonst 5–3600, Voreinstellung 30), `order: 'sequence' | 'random'`. **Befund am Code:** die API prüft Widget-Konfigurationen NICHT inhaltlich — `UpdateWidgetConfigDto` trägt nur `@IsObject()`, `DashboardService.updateWidgetConfig` führt flach zusammen (`{ ...alt, ...neu }`). Es gibt also keine serverseitige Stelle, die erweitert werden könnte; die https-Prüfung läuft deshalb **web-seitig zweifach**: im Formular (Eingabe abweisen) UND beim Rendern (`resolvePictureFrameConfig` lässt jede Nicht-https-Adresse weg). Ein manipulierter Config-Wert schadet damit nur dem eigenen Dashboard und wird dort nicht einmal gerendert. +5. Klick auf das Bild nur außerhalb des Bearbeitungsmodus → Großansicht (Escape / Hintergrund / Schließen-Knopf; Fokus-Handhabung wie `widget-catalog-modal.tsx`: Dialog bei Öffnen fokussieren, zusätzlich Fokus-Rückgabe an den Auslöser). Im Bearbeitungsmodus kein Knopf → die ganze Karte bleibt Ziehgriff (`widget-wrapper.tsx`). +6. Wechsel per Timer; Zufall wählt bei >1 Bild nie das aktuelle; Timer beim Aushängen geräumt; pausiert bei offener Großansicht. +7. Leerzustand: „Noch keine Bilder — über die Einstellungen hinzufügen“, Stil `flex h-full items-center justify-center text-sm text-muted-foreground` (wie `PlaceholderWidget`/Favoriten-`empty`). +8. Bildverwaltung im **WidgetSettingsPanel** (Einstellungen → Dashboard, je Instanz aufklappbar) — das ist die „Einstellungen“-Stelle dieser App; einen Dialog je Widget gibt es nicht. Vorschaubilder über `/api-proxy/dashboard/images/:id` (Muster `FavoriteIcon`, favorites-widget.tsx). +9. Texte Deutsch mit „Sie“, plus Englisch; keine kundenspezifischen Vorgaben. +10. Katalogname „Bilderrahmen“ / „Picture frame“, Beschreibung kurz („Bilder hochladen oder verlinken, als Diashow“ / „Upload or link images as a slideshow“). + +## Ausgangsmessung (21.09.2026, 573d070) + +| Größe | Wert | +|---|---:| +| API-Tests | 1148 | +| Web-Tests | 531 | +| `as unknown as` in apps/api/src | 27 | +| `as unknown as` in apps/web/src | 6 | +| `lint/style/noNonNullAssertion` in apps/api/src | 56 | +| `lint/suspicious/noExplicitAny` in apps/api/src | 13 (jede begründet) | +| RLS-Wächter (`src/prisma`) | 30/30 | + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@/home/vicolab/projects/tessera-ctl/CLAUDE.md +@/home/vicolab/projects/tessera-ctl/apps/api/src/dashboard/dashboard.service.ts +@/home/vicolab/projects/tessera-ctl/apps/api/src/dashboard/dashboard.controller.ts +@/home/vicolab/projects/tessera-ctl/apps/api/src/bug-reports/bug-reports.controller.ts +@/home/vicolab/projects/tessera-ctl/apps/api/src/bug-reports/bug-reports.service.ts +@/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/favorites.controller.ts +@/home/vicolab/projects/tessera-ctl/apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql +@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx +@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widget-catalog-modal.tsx +@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.tsx + + +## Hinweise für den Executor + +- **Lokale Datenbank ohne Host-Port.** Migration anwenden: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`; `DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`; danach `pnpm --filter @tessera/api exec prisma generate`. Läuft der Container nicht: `docker compose up -d db`. **Nie** auf den Testserver deployen, **nie** `prisma db push`. +- **Tore vor jedem Commit:** `pnpm type-check`, `pnpm lint`, die betroffenen Vitest-Dateien; am Ende `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollständig. +- **Wächter:** `rls-coverage.spec.ts` liest Schema und Migrationen (Regex `ALTER TABLE "X" ENABLE ROW LEVEL SECURITY` und `CREATE POLICY \w+ ON "X"`); `rls-access-inventory.spec.ts` verlangt `forTenant(` nur in der Zuweisungsform `const X = forTenant(`, jede `select:`-Angabe innerhalb eines Modellaufrufs, und je (Datei, Modell) eine Zeile in `docs/mandantentrennung-zugriffsklassifikation.md` (Tabelle „| Datei | Modell | Klasse | Stand | Begründung |“, Zeile ~660). +- **Prisma 6: `Bytes` ist `Uint8Array`, nicht `Buffer`.** `data: file.buffer` beim Anlegen geht (Buffer ist eine Uint8Array-Unterklasse); bei der Auslieferung `res.send(Buffer.from(row.data.buffer, row.data.byteOffset, row.data.byteLength))` — keine Zusicherung nötig. +- **multer:** `LIMIT_FILE_SIZE` bildet Nest auf 413 mit englischer Meldung ab (Muster T-M97-03 in bug-reports.controller.ts) — die deutsche Meldung für „zu groß“ entsteht im Web-Klienten aus dem Status 413. +- **Kein `any`**, keine neue `as unknown as`, kein `!`. Multipart-Datei als bestehender Typ `UploadedFileLike` (`apps/api/src/auth/types/auth-user.ts`), Aufrufer als `AuthUser` über `@CurrentUser()`. +- **Commits:** je Aufgabe genau ein Commit, Stil wie `git log --oneline -15`, Scope `quick-260921-pi9`, deutsche Betreffzeile. Die Akte/STATE-Commit macht der Orchestrator. +- **Schema-Tor (Prisma erkannt):** der `[BLOCKING]`-Schritt „Migration anwenden + `prisma generate`“ steht in Aufgabe 1 VOR dem Dienstcode; ohne ihn wären Typprüfung und Tests falsch-grün. + + + + + Aufgabe 1: API — Modell, Migration, Regeln, Dienst, Controller (Ende-zu-Ende „Bild hochladen und wieder abrufen“) + apps/api/prisma/schema.prisma, 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/api/src/dashboard/dashboard.module.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md + + - `detectImageMime`: PNG-Signatur (`89 50 4E 47 0D 0A 1A 0A`) → `image/png`; `FF D8 FF` → `image/jpeg`; `GIF87a`/`GIF89a` → `image/gif`; `RIFF????WEBP` (Bytes 0–3 `RIFF`, 8–11 `WEBP`) → `image/webp`; leerer Puffer, Textdatei, SVG-Text, PDF (`%PDF`) → `null`; ein Puffer, der mit `RIFF` beginnt, aber ohne `WEBP` an Stelle 8 → `null`. + - Dienst `upload`: keine Datei → `BadRequestException('Bitte wählen Sie eine Bilddatei aus.')`; `detectImageMime === null` → `BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.')` — auch wenn `file.mimetype` „image/png“ behauptet; Zähler `count({ where: { tenantId, userId } })` ≥ 30 → `BadRequestException('Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.')`; sonst `create` mit `mimeType` aus der Erkennung (NICHT aus `file.mimetype`), `originalName` auf 255 Zeichen gekürzt, `size = buffer.length`, Antwort nur Metadaten (`id, originalName, mimeType, size, createdAt`). + - Dienst `list`: `findMany({ where: { tenantId, userId }, select: { id, originalName, mimeType, size, createdAt }, orderBy: { createdAt: 'asc' } })` — `data` wird nie mitgeladen. + - Dienst `getBytes`/`remove`: `findUnique({ where: { id } })`; fehlt die Zeile ODER `row.userId !== userId` ODER `row.tenantId !== tenantId` → `NotFoundException` (nie Forbidden); `remove` löscht danach. + - Jede Methode holt ihren Klienten in der Zuweisungsform `const tenantPrisma = forTenant(this.prisma, tenantId, userId)` (Spec-Attrappe wie in dashboard.service.spec.ts: `forTenant: vi.fn((prisma, tenantId) => prisma.__makeBoundClient(tenantId))`, ein vergessener Aufruf fällt im Test auf). + - Controller: `GET /dashboard/images` → `list`; `POST` mit `FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } })` → `upload(user, file)`; `GET /dashboard/images/:id` setzt `Content-Type` = gespeicherter `mimeType`, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline` (ohne Dateinamen — `originalName` gehört nie in einen Header), zusätzlich `Content-Security-Policy: default-src 'none'; sandbox` (Muster `getIcon`), dann `res.send(Buffer)`; `DELETE /:id` → `remove`, Antwort `{ id }`. Kein `@Roles`-Dekorator (alle angemeldeten Rollen, Muster bug-reports). Mandant/Benutzer ausschließlich aus `@CurrentUser()`. + - `CreateWidgetDto`: `@IsIn([...])` enthält zusätzlich `'picture-frame'`. + + + **Schritt A — Schema und Migration.** In `schema.prisma` neben `WidgetInstance` das Modell `DashboardImage` anlegen: `id String @id @default(uuid())`, `userId String`, `tenantId String`, `originalName String`, `mimeType String`, `size Int`, `data Bytes`, `createdAt DateTime @default(now())`, `@@index([userId])`, `@@index([tenantId])` (keine Relation, wie `WidgetInstance`). Migration `20260921120000_dashboard_image/migration.sql` von Hand schreiben (Muster CREATE TABLE: `20260708090000_add_favorite_link`, Muster RLS: `20260909140000_rls_remaining_tenant_tables` Abschnitt FavoriteLink plus Benutzerdimension aus `20260911120000`): deutscher Kopfkommentar (Zweck, Grenzen, Besitz), `CREATE TABLE "DashboardImage" (… "data" BYTEA NOT NULL …)`, beide Indizes, `ALTER TABLE "DashboardImage" ENABLE ROW LEVEL SECURITY;`, `ALTER TABLE "DashboardImage" FORCE ROW LEVEL SECURITY;`, `CREATE POLICY tenant_isolation_policy ON "DashboardImage" USING ("tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id()));`. Rechte für `tessera_app` kommen über `ALTER DEFAULT PRIVILEGES` aus `20260909130000_rls_app_role` automatisch — nichts zu tun, im Kopfkommentar erwähnen. + + **Schritt B [BLOCKING] — Migration anwenden und Klient erzeugen** (Befehle aus den Executor-Hinweisen: `prisma migrate deploy` gegen die Container-IP, dann `prisma generate`). Erst danach gibt es `tenantPrisma.dashboardImage` im Typsystem. Prüfen: `pnpm --filter @tessera/api exec vitest run src/prisma/rls-coverage.spec.ts` muss grün sein (Test 1/2 sehen das neue Modell und die neue Policy). + + **Schritt C — Regeln zuerst, rot.** `dashboard-image-rules.ts` mit `export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024`, `DASHBOARD_IMAGE_MAX_COUNT = 30`, `export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'`, `export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null`. Spec mit den Fällen aus `` (mindestens 8), vor der Umsetzung rot. + + **Schritt D — Dienst und Controller, rot dann grün.** `dashboard-images.service.ts` (`@Injectable() DashboardImagesService`, Konstruktor `private readonly prisma: PrismaService`) mit `list(userId, tenantId)`, `upload(user: AuthUser, file: UploadedFileLike | undefined)`, `getBytes(id, userId, tenantId)` → `{ mimeType, data }`, `remove(id, userId, tenantId)`. Dateikopf-Kommentar wie in dashboard.service.ts: warum die Besitzprüfung zusätzlich zur RLS-Regel nicht dekorativ ist (Schalter heute aus). Spec mit `makeFakePrisma`-Muster aus dashboard.service.spec.ts, mindestens 10 Fälle (Liste ohne `data`; Upload ohne Datei; PNG mit behauptetem `text/plain`-mimetype gelingt und speichert `image/png`; Textdatei mit behauptetem `image/png` scheitert; Zähler 30 blockt, 29 lässt durch; fremder Benutzer → 404; fremder Mandant → 404; eigenes Bild liefert Bytes; Löschen eigen/fremd; `forTenant` mit `(prisma, tenantId, userId)` aufgerufen). `dashboard-images.controller.ts` (`@Controller('dashboard/images')`, Reihenfolge `@Get()` → `@Post()` → `@Get(':id')` → `@Delete(':id')`, `@Res() res: Response` aus `express` wie favorites.controller.ts). Controller-Spec (Muster bug-reports.controller.spec.ts, mindestens 4 Fälle): Interceptor-Grenzen `fileSize === DASHBOARD_IMAGE_MAX_BYTES`, `files === 1` über die Nest-Metadaten oder den Aufruf; die vier Header der Binärantwort inklusive `Content-Disposition: inline` ohne Dateinamen; kein `@Roles`-Metadatum; Pfad `dashboard/images`. Beide in `dashboard.module.ts` registrieren (`controllers`, `providers`). `create-widget.dto.ts` um `'picture-frame'` erweitern. + + **Schritt E — Wächter-Dokument.** In `docs/mandantentrennung-zugriffsklassifikation.md` in der (Datei, Modell)-Tabelle eine Zeile `| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | … |` mit Begründung (Bilder eines Benutzers, `tenantId`-Spalte, Benutzerdimension in der Policy seit 20260921120000, Besitzprüfung zusätzlich in `getBytes`/`remove`, Liste/Zähler mit explizitem `where: { tenantId, userId }`). Die Bereichstabelle (Zeile „| dashboard | 1 | 12 | 0 |“) um die neuen gebundenen Treffer erhöhen — die Zahl mit der dort genannten Schleife messen, nicht schätzen. + + Commit: `feat(quick-260921-pi9): Bilderrahmen-API - Bilder je Benutzer in der Datenbank, Magic-Byte-Pruefung, 5 MiB / 30 Stueck` (Wortlaut frei, Stil beachten). + + + 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 && test "$(grep -rn 'as unknown as' apps/api/src --include=*.ts | wc -l)" -eq 27 && STAT=$(git show --stat --format= HEAD) && printf '%s' "$STAT" | grep -q 'migrations/20260921120000_dashboard_image/migration.sql' + + Migration lokal angewendet (`prisma migrate status` meldet keine ausstehende Migration), `prisma generate` gelaufen. `dashboard-image-rules.spec.ts` ≥ 8, `dashboard-images.service.spec.ts` ≥ 10, `dashboard-images.controller.spec.ts` ≥ 4 Fälle — alle grün, davon die Regel- und Diensttests nachweislich zuerst rot (Rot-Lauf im SUMMARY nennen). RLS-Wächter `src/prisma` weiterhin 30/30 inklusive der neuen Zeile im Klassifikationsdokument. Ein Rundgang mit `curl` gegen die laufende lokale API (Cookie aus einer Anmeldung): `POST` mit einer PNG-Datei liefert 201 mit Metadaten, `GET /dashboard/images` listet sie ohne `data`, `GET /dashboard/images/` liefert die Bytes mit den vier Headern, eine Textdatei als `.png` liefert 400 mit der deutschen Meldung, eine 6-MiB-Datei 413, eine erfundene Kennung 404. Zähler `as unknown as` = 27, keine neue `any`, kein `!`. + + + + Aufgabe 2: Web — Widget, Großansicht, Einstellungsformular, Katalog, Übersetzungen + 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/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 + + - `resolvePictureFrameConfig({})` → `{ images: [], fit: 'contain', intervalSeconds: 30, order: 'sequence' }`; `intervalSeconds` 3 → 5, 9999 → 3600, 0 → 0, `'abc'` → 30; `fit: 'x'` → `'contain'`; `order: 'x'` → `'sequence'`; Einträge ohne gültiges `kind`, Upload ohne `imageId`-String, URL mit `http://`, `javascript:`, `data:` oder ohne Parser-Erfolg (`new URL` wirft) werden weggelassen; `caption` nur übernommen, wenn String, auf 200 Zeichen gekürzt. + - `isHttpsUrl('https://a.de/b.jpg')` true; `'http://…'`, `'HTTPS://'`-Schreibweise → true (Protokoll kleingeschrieben vergleichen); `'ftp://'`, `'javascript:alert(1)'`, `'nicht-url'` false. + - `pickNextIndex(current, count, order, random)`: `count ≤ 1` → 0; `sequence` → `(current + 1) % count`; `random` → mit gestelltem `random` nie `current` (über 200 Ziehungen bei `count = 3` kommt `current` nie heraus). + - Widget: leere Konfiguration → Text `pictureFrame.empty`; ein URL-Eintrag → `` mit `src` = URL, `referrerPolicy="no-referrer"`, Klasse `object-contain`, bei `fit: 'cover'` `object-cover`, `alt` = Bildunterschrift oder `''`; Bildunterschrift als Streifen am unteren Rand; Upload-Eintrag → `src="/api-proxy/dashboard/images/"`; mit `intervalSeconds: 5` und zwei Bildern zeigt `vi.advanceTimersByTime(5000)` das zweite, `unmount()` räumt den Timer (kein `setState` nach dem Aushängen, `vi.getTimerCount()` 0); `intervalSeconds: 0` wechselt nie; `onError` am `` nimmt den Eintrag aus dem Umlauf (bei zwei Einträgen bleibt nur der andere; sind alle kaputt: Text `pictureFrame.unavailable`); Klick auf das Bild bei `isEditMode: false` öffnet `role="dialog"` mit großem Bild und Unterschrift, Escape schließt, Klick auf den Hintergrund-Knopf schließt, Schließen-Knopf schließt, danach hat der Bild-Knopf wieder den Fokus; bei offener Großansicht läuft `advanceTimersByTime` ohne Bildwechsel; bei `isEditMode: true` gibt es keinen Knopf (kein `role="button"` im Widget) und kein Klick öffnet etwas. + - Einstellungsformular: zeigt Auswahl Bildausschnitt (`contain`/`cover`), Intervall (Auswahl mit Werten 0/5/10/15/30/60/120/300/600/1800/3600), Reihenfolge (`sequence`/`random`) — jede Änderung ruft `onChange` mit dem einen Feld; die Liste zeigt je Eintrag Vorschau (`` mit `referrerPolicy="no-referrer"`, bei `onError` stattdessen Text `pictureFrame.unavailable`), Unterschrift-Feld (Entwurf, Übernahme bei Blur/Enter → `onChange({ images })` mit dem ganzen Array), Pfeile hoch/runter (oberster Eintrag ohne „hoch“, unterster ohne „runter“, wie Favoriten), Entfernen; Entfernen eines Upload-Eintrags ruft `deleteDashboardImage(imageId)` (Fehler verschluckt) UND `onChange` mit dem verkürzten Array; „Webadresse hinzufügen“ mit `http://` zeigt `role="alert"` `pictureFrame.urlInvalid` und ruft `onChange` nicht; mit https ruft `onChange({ images: [...alt, { kind: 'url', url }] })`; Datei wählen ruft `uploadDashboardImage(file)` und danach `onChange({ images: [...alt, { kind: 'upload', imageId: '' }] })`; wirft der Upload, erscheint dessen Meldung als `role="alert"` und `onChange` bleibt aus; bei 30 Einträgen ist „Bild hochladen“ deaktiviert mit Hinweis `pictureFrame.limitReached`. + - `dashboard-images-api`: `uploadDashboardImage` sendet `POST ${API_URL}/dashboard/images` mit `credentials: 'include'` und einem `FormData`, dessen Feld `image` die Datei ist (kein `Content-Type`-Header von Hand); Status 413 → `Error('Die Datei ist zu groß – erlaubt sind höchstens 5 MB.')`; Status 400 mit `{ message: string }` → `Error(message)`; sonst allgemeine Meldung; `dashboardImageSrc('a b')` → `/api-proxy/dashboard/images/a%20b`. + + + **Reihenfolge: reine Helfer zuerst (rot → grün), dann Widget, dann Formular, zuletzt Verdrahtung.** + + 1. `picture-frame-config.ts`: Typen `PictureFrameEntry` (Vereinigung mit `kind`-Unterscheider, siehe Entscheidung 4), `PictureFrameConfig`, Konstanten `PICTURE_FRAME_INTERVAL_MIN = 5`, `PICTURE_FRAME_INTERVAL_MAX = 3600`, `PICTURE_FRAME_INTERVAL_DEFAULT = 30`, `PICTURE_FRAME_INTERVAL_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]`, `PICTURE_FRAME_MAX_IMAGES = 30`, `PICTURE_FRAME_CAPTION_MAX = 200`; Funktionen `isHttpsUrl`, `resolvePictureFrameConfig`, `pickNextIndex`, `entryKey(entry, index)` (stabiler React-Schlüssel `upload:` bzw. `url::`). Ohne React-Import, damit der Test schlank bleibt (Muster `clock-font-size.ts`, `calendar-month.ts`). + + 2. `dashboard-images-api.ts` nach dem Muster `favorites-api.ts` (`API_URL` aus `NEXT_PUBLIC_API_URL`, `credentials: 'include'`): `DashboardImageMeta`, `fetchDashboardImages()`, `uploadDashboardImage(file: File)`, `deleteDashboardImage(id)`, `dashboardImageSrc(id)`. Test mit `vi.stubGlobal('fetch', …)`. + + 3. `picture-frame-widget.tsx` (`'use client'`, Props `WidgetProps`): `resolvePictureFrameConfig(config)` per `useMemo`; Zustand `index`, `brokenKeys: string[]`, `lightboxOpen`; sichtbare Einträge = alle ohne kaputte Schlüssel; `useEffect` mit `setInterval` nur wenn `intervalSeconds > 0 && visible.length > 1 && !lightboxOpen`, Räumung in der Aufräumfunktion; `random` über `pickNextIndex(…, Math.random)`. Darstellung: Rumpf `relative h-full w-full overflow-hidden`, `{caption`, Unterschrift als `absolute inset-x-0 bottom-0 bg-black/50 px-2 py-1 text-xs text-white truncate` (nur wenn vorhanden). Außerhalb des Bearbeitungsmodus liegt Bild+Streifen in einem `