docs(quick-260914-m97): Plan revidiert (Runde 1) — Task 2 mit Commit-Grenze 2a/2b, computeCaptureSize automatisiert getestet, Dialog-Tests fuer 413/429/502/allgemein aus de.json

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 16:34:41 +02:00
parent 04f933c972
commit 17a7e5ef9b
@@ -60,7 +60,7 @@ must_haves:
- "Im Browser laeuft seit `app-shell.tsx` ein Fehlerpuffer (`apps/web/src/lib/error-buffer.ts`, Ringpuffer 20, einmalig installiert, SSR-sicher): `window` `error`, `unhandledrejection`, `console.error` (Original wird weiter aufgerufen) und ein `window.fetch`-Wrapper, der NUR bei `!response.ok` `<METHODE> <Pfad ohne Suchteil> -> <Status>` plus die ersten 200 Zeichen des ANTWORT-Rumpfs notiert — nie den Anfrage-Rumpf, nie Cookies, nie Kopfzeilen; Tests pinnen: ok-Antworten werden nicht notiert, der Suchteil fehlt, der Anfrage-Rumpf taucht nicht auf, Installation ist idempotent." - "Im Browser laeuft seit `app-shell.tsx` ein Fehlerpuffer (`apps/web/src/lib/error-buffer.ts`, Ringpuffer 20, einmalig installiert, SSR-sicher): `window` `error`, `unhandledrejection`, `console.error` (Original wird weiter aufgerufen) und ein `window.fetch`-Wrapper, der NUR bei `!response.ok` `<METHODE> <Pfad ohne Suchteil> -> <Status>` plus die ersten 200 Zeichen des ANTWORT-Rumpfs notiert — nie den Anfrage-Rumpf, nie Cookies, nie Kopfzeilen; Tests pinnen: ok-Antworten werden nicht notiert, der Suchteil fehlt, der Anfrage-Rumpf taucht nicht auf, Installation ist idempotent."
- "Falsifizierungen als Specs: (a) sechster Bericht in 10 Minuten -> 429, nach 10 Minuten (Fake-Timer) wieder 200; (b) manipulierte Bilddatei (kein PNG-Kopf) -> 400, `sendBugReport` nie gerufen; (c) weder Feld noch Variable -> 409, `sendBugReport` nie gerufen; Leerstring in der Variable zaehlt als ungesetzt; (d) Rumpf mit fremdem Mandanten-Feld -> `ValidationPipe({ whitelist: true, transform: true })` entfernt das Feld (Pipe-Test mit `metatype: BugReportDto`), und der Dienst ruft `getBugReportRecipient`, `forTenant` und `sendBugReport` ausschliesslich mit der Sitzungs-Mandantenkennung." - "Falsifizierungen als Specs: (a) sechster Bericht in 10 Minuten -> 429, nach 10 Minuten (Fake-Timer) wieder 200; (b) manipulierte Bilddatei (kein PNG-Kopf) -> 400, `sendBugReport` nie gerufen; (c) weder Feld noch Variable -> 409, `sendBugReport` nie gerufen; Leerstring in der Variable zaehlt als ungesetzt; (d) Rumpf mit fremdem Mandanten-Feld -> `ValidationPipe({ whitelist: true, transform: true })` entfernt das Feld (Pipe-Test mit `metatype: BugReportDto`), und der Dienst ruft `getBugReportRecipient`, `forTenant` und `sendBugReport` ausschliesslich mit der Sitzungs-Mandantenkennung."
- "Handbuecher: `docs/anleitung-anwender.md` neuer Abschnitt „Einen Fehler melden“ (Ablauf, was mitgeschickt wird, Datenschutz-Hinweis: das Bild zeigt die aktuelle Seite so wie Sie sie sehen; Kopfleisten-Beschreibung nennt jetzt drei Bedienelemente); `docs/anleitung-administration.md` Kapitel 6 SMTP nennt das Feld „Fehlermeldungen an“ und die Fehlersuche-Tabelle den Fall „Fehler melden antwortet, es sei kein Postfach eingerichtet“; `docs/anleitung-betrieb.md` Kapitel 3 Konfigurationstabelle bekommt `TESSERA_BUGREPORT_TO` als Rueckfall (mit Hinweis, dass die Serverdatei `/opt/tessera/docker-compose.prod.yml` die Zeile von Hand braucht, Kapitel 9 Muster). Echte Umlaute wie im Bestand aller drei Dateien." - "Handbuecher: `docs/anleitung-anwender.md` neuer Abschnitt „Einen Fehler melden“ (Ablauf, was mitgeschickt wird, Datenschutz-Hinweis: das Bild zeigt die aktuelle Seite so wie Sie sie sehen; Kopfleisten-Beschreibung nennt jetzt drei Bedienelemente); `docs/anleitung-administration.md` Kapitel 6 SMTP nennt das Feld „Fehlermeldungen an“ und die Fehlersuche-Tabelle den Fall „Fehler melden antwortet, es sei kein Postfach eingerichtet“; `docs/anleitung-betrieb.md` Kapitel 3 Konfigurationstabelle bekommt `TESSERA_BUGREPORT_TO` als Rueckfall (mit Hinweis, dass die Serverdatei `/opt/tessera/docker-compose.prod.yml` die Zeile von Hand braucht, Kapitel 9 Muster). Echte Umlaute wie im Bestand aller drei Dateien."
- "Baseline am Ende: API `Test Files 67 passed (67)` / `Tests 1076 passed (1076)` (Planungszeit 65/1060 plus 3 + 2 + 8 + 3 neue), Web `Test Files 43 passed (43)` / `Tests 255 passed (255)` (Planungszeit 40/243 plus 4 + 6 + 2 neue), `tsc --noEmit` in api, web und shared je Exit 0; `pnpm install --frozen-lockfile` Exit 0; `git diff --stat 5c42c55 -- . ':!.planning'` nennt genau `35 files changed`; `apps/api/src/main.ts`, `.env*`, `biome.json` und alle 36 bestehenden Migrationsordner unangetastet; drei Commits mit Scope `quick-260914-m97`, gepusht, CI-Lauf beobachtet." - "Baseline am Ende: API `Test Files 67 passed (67)` / `Tests 1076 passed (1076)` (Planungszeit 65/1060 plus 3 + 2 + 8 + 3 neue), Web `Test Files 43 passed (43)` / `Tests 260 passed (260)` (Planungszeit 40/243 plus 4 + 11 + 2 neue; revidiert Runde 1), `tsc --noEmit` in api, web und shared je Exit 0; `pnpm install --frozen-lockfile` Exit 0; `git diff --stat 5c42c55 -- . ':!.planning'` nennt genau `35 files changed`; `apps/api/src/main.ts`, `.env*`, `biome.json` und alle 36 bestehenden Migrationsordner unangetastet; vier Commits (Task 2 in zwei Teilen 2a/2b) mit Scope `quick-260914-m97`, gepusht, CI-Lauf beobachtet."
artifacts: artifacts:
- "apps/api/prisma/schema.prisma — `bugReportRecipient String?` in `model SmtpConfig` hinter `fromAddress` (Kommentar: Postfach fuer den Fehler-melden-Knopf, quick-260914-m97)" - "apps/api/prisma/schema.prisma — `bugReportRecipient String?` in `model SmtpConfig` hinter `fromAddress` (Kommentar: Postfach fuer den Fehler-melden-Knopf, quick-260914-m97)"
- "apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql — Kopfkommentar in der ASCII-Form von `20260909120000_user_email_optional`, dann `ALTER TABLE \"SmtpConfig\" ADD COLUMN \"bugReportRecipient\" TEXT;`" - "apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql — Kopfkommentar in der ASCII-Form von `20260909120000_user_email_optional`, dann `ALTER TABLE \"SmtpConfig\" ADD COLUMN \"bugReportRecipient\" TEXT;`"
@@ -77,10 +77,10 @@ must_haves:
- "docker-compose.prod.yml — `TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}` im `environment`-Block von `api` hinter `TESSERA_SMTP_FROM`, mit Kommentar im Ton der Datei" - "docker-compose.prod.yml — `TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}` im `environment`-Block von `api` hinter `TESSERA_SMTP_FROM`, mit Kommentar im Ton der Datei"
- "apps/web/package.json — `\"html-to-image\": \"1.11.13\"` (exakt gepinnt) unter `dependencies`; `pnpm-lock.yaml` entsprechend" - "apps/web/package.json — `\"html-to-image\": \"1.11.13\"` (exakt gepinnt) unter `dependencies`; `pnpm-lock.yaml` entsprechend"
- "apps/web/src/lib/error-buffer.ts — `BufferedError { at, kind, message }`, `recordError`, `getRecentErrors`, `formatErrorsForReport`, `installErrorBuffer` (Guard-Symbol auf `window`, `typeof window === 'undefined'` -> no-op)" - "apps/web/src/lib/error-buffer.ts — `BufferedError { at, kind, message }`, `recordError`, `getRecentErrors`, `formatErrorsForReport`, `installErrorBuffer` (Guard-Symbol auf `window`, `typeof window === 'undefined'` -> no-op)"
- "apps/web/src/lib/bug-report-api.ts — `captureScreenshot(): Promise<string | null>` (Data-URL; `toPng` aus `html-to-image` mit `pixelRatio: 1`, `skipFonts: true`, `cacheBust: true`, `canvasWidth/canvasHeight` = Body-Masse * min(1, 1600 / laengste Kante)), `dataUrlToBlob(dataUrl): Blob` (atob, kein fetch), `sendBugReport(payload): Promise<{ ok: true } | { ok: false; status: number }>` (FormData, `credentials: 'include'`, KEIN Content-Type-Header)" - "apps/web/src/lib/bug-report-api.ts — `computeCaptureSize(width, height, maxEdge = 1600): { width: number; height: number }` (reine, exportierte Funktion, direkt getestet — revidiert Runde 1), `captureScreenshot(): Promise<string | null>` (Data-URL; `toPng` aus `html-to-image` mit `pixelRatio: 1`, `skipFonts: true`, `cacheBust: true`, `canvasWidth/canvasHeight` aus `computeCaptureSize(body.scrollWidth, body.scrollHeight)`), `dataUrlToBlob(dataUrl): Blob` (atob, kein fetch), `sendBugReport(payload): Promise<{ ok: true } | { ok: false; status: number }>` (FormData, `credentials: 'include'`, KEIN Content-Type-Header)"
- "apps/web/src/components/bug-report/bug-report-button.tsx — `'use client'`, Symbol-Knopf (Kaefer-Symbol als Inline-SVG 20x20 im Stil des ThemeToggle), Zustand `capturing`, ruft `captureScreenshot()` VOR dem Oeffnen, rendert `BugReportDialog`" - "apps/web/src/components/bug-report/bug-report-button.tsx — `'use client'`, Symbol-Knopf (Kaefer-Symbol als Inline-SVG 20x20 im Stil des ThemeToggle), Zustand `capturing`, ruft `captureScreenshot()` VOR dem Oeffnen, rendert `BugReportDialog`"
- "apps/web/src/components/bug-report/bug-report-dialog.tsx — Muster `marketplace/components/ActivationDialog.tsx` (`role=\"dialog\"`, `aria-modal`, Escape, Fokus), Zustaende `ready | sending | sent | failed`, Props `open`, `screenshot: string | null`, `isAdmin`, `onClose`" - "apps/web/src/components/bug-report/bug-report-dialog.tsx — Muster `marketplace/components/ActivationDialog.tsx` (`role=\"dialog\"`, `aria-modal`, Escape, Fokus), Zustaende `ready | sending | sent | failed`, Props `open`, `screenshot: string | null`, `isAdmin`, `onClose`"
- "apps/web/src/components/bug-report/bug-report-button.test.tsx — NEU, 6 Tests, `html-to-image` per `vi.mock` (jsdom kann `toPng` nicht — gemessen: `HTMLVideoElement is not defined` / kein Canvas-Backend)" - "apps/web/src/components/bug-report/bug-report-button.test.tsx — NEU, 11 Tests (revidiert Runde 1: Kantenmass in Test 1, Tests 7-10 fuer 413/429/502/allgemein mit Texten aus `de.json`, Test 11 `computeCaptureSize`), `html-to-image` per `vi.mock` (jsdom kann `toPng` nicht — gemessen: `HTMLVideoElement is not defined` / kein Canvas-Backend)"
- "apps/web/src/lib/error-buffer.test.ts — NEU, 4 Tests" - "apps/web/src/lib/error-buffer.test.ts — NEU, 4 Tests"
- "apps/web/src/components/layout/header.tsx — `<BugReportButton />` unmittelbar VOR `<ThemeToggle />` (Zeile 112)" - "apps/web/src/components/layout/header.tsx — `<BugReportButton />` unmittelbar VOR `<ThemeToggle />` (Zeile 112)"
- "apps/web/src/components/layout/app-shell.tsx — `useEffect(() => { installErrorBuffer(); }, [])`" - "apps/web/src/components/layout/app-shell.tsx — `useEffect(() => { installErrorBuffer(); }, [])`"
@@ -105,7 +105,7 @@ Fehler-melden-Knopf in der Kopfzeile: Ein Klick nimmt SOFORT ein Bild der aktuel
Purpose: Morgen (2026-09-15) geht Tessera live. Der User (kein Programmierer, betreibt die Installation) will Fehler der Anwender mit Bild und Kontext in sein Postfach bekommen, ohne Rueckfragen stellen zu muessen. Die Versionsangabe im Bericht ist der Grund, warum 260914-ku1 vorher gebaut wurde. Purpose: Morgen (2026-09-15) geht Tessera live. Der User (kein Programmierer, betreibt die Installation) will Fehler der Anwender mit Bild und Kontext in sein Postfach bekommen, ohne Rueckfragen stellen zu muessen. Die Versionsangabe im Bericht ist der Grund, warum 260914-ku1 vorher gebaut wurde.
Output: 35 Dateien (16 API/Compose/Doku-Tabelle, 16 Web inkl. Lockfile, 3 Handbuecher), drei Commits mit Scope `quick-260914-m97`, gepusht, CI-Lauf beobachtet. Danach (nicht in diesem Plan): Erstfreigabe v1.0.0 durch den Orchestrator. Output: 35 Dateien (16 API/Compose/Doku-Tabelle, 16 Web inkl. Lockfile, 3 Handbuecher), vier Commits (Task 2 in zwei Teilen 2a/2b) mit Scope `quick-260914-m97`, gepusht, CI-Lauf beobachtet. Danach (nicht in diesem Plan): Erstfreigabe v1.0.0 durch den Orchestrator.
</objective> </objective>
<execution_context> <execution_context>
@@ -166,6 +166,14 @@ Zur Planungszeit (2026-09-14, HEAD `5c42c55`, Arbeitsbaum sauber, main == origin
|---|---|---|---|---| |---|---|---|---|---|
| html-to-image | 1.11.13 (exakt) | npm-Registry via `pnpm view` | dist-tag latest, 2025-02-14, MIT, 0 deps, Repo github.com/bubkoo/html-to-image, 4.822.813 Downloads/Woche (api.npmjs.org, 2026-09-05..11), `pnpm view … dependencies` leer | [VERIFIED] | | html-to-image | 1.11.13 (exakt) | npm-Registry via `pnpm view` | dist-tag latest, 2025-02-14, MIT, 0 deps, Repo github.com/bubkoo/html-to-image, 4.822.813 Downloads/Woche (api.npmjs.org, 2026-09-05..11), `pnpm view … dependencies` leer | [VERIFIED] |
</package_legitimacy_audit> </package_legitimacy_audit>
<revision_log>
Runde 1 (Plan-Pruefer: 0 Blocker, 3 Warnungen), gezielt eingearbeitet, keine Neuplanung:
1. scope_sanity — Task 2 bleibt EINE Aufgabe, bekommt aber zwei Commit-Grenzen mit eigenem Gate: Teil 2a (Abhaengigkeit, Fehlerpuffer, API-Client, Komponenten, Header/AppShell, i18n `bugReport`, Woerterbuch; Schritt F2, 13 Dateien) und Teil 2b (SMTP-Formular, settings-api, i18n `settings.smtp`; Schritte G/H, 5 Dateien). Wiederaufsetzpunkt nach Commit 2a ist Schritt G.
2. task_completeness (Kantenmass) — `computeCaptureSize(width, height, maxEdge = 1600)` als reine, exportierte Funktion; Test 11 prueft sie direkt (3200x1000 -> 1600x500, 800x600 unveraendert, 1000x4000 -> 400x1600, 0x0 -> 1x1, maxEdge 800), Test 1 prueft die an `toPng` uebergebenen `canvasWidth/canvasHeight` bei gestubbten Body-Massen; das serverseitige 4-MiB-Limit bekommt ein Grep-Gate in Task 1.
3. task_completeness (HTTP-Zweige) — Tests 7-10 fuer 413, 429, 502 und den allgemeinen Fall (500 und Netzwerkfehler); der next-intl-Mock liest die Texte aus `de.json` (Muster `tessera-logo.test.tsx`), Erwartungen zitieren `de.bugReport.<key>`.
Zahlen: Button-Spec 6 -> 11 Tests, Web 255 -> 260 Tests (43 Dateien unveraendert), Commits 3 -> 4, Dateiliste unveraendert 35 (neue Tests liegen in bereits gelisteten Spec-Dateien).
</revision_log>
</context> </context>
<tasks> <tasks>
@@ -225,15 +233,15 @@ Zur Planungszeit (2026-09-14, HEAD `5c42c55`, Arbeitsbaum sauber, main == origin
Commit: `feat(quick-260914-m97): Fehlermeldungen per E-Mail — Empfaenger in SmtpConfig (Migration), MailService-Anhaenge, Modul bug-reports mit Drossel, PNG-Pruefung und Mandant aus der Sitzung` mit genau den 16 Dateien dieser Aufgabe (`git show --stat HEAD` zeigt 16). Commit: `feat(quick-260914-m97): Fehlermeldungen per E-Mail — Empfaenger in SmtpConfig (Migration), MailService-Anhaenge, Modul bug-reports mit Drossel, PNG-Pruefung und Mandant aus der Sitzung` mit genau den 16 Dateien dieser Aufgabe (`git show --stat HEAD` zeigt 16).
</action> </action>
<verify> <verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run src/bug-reports src/mail/mail.service.spec.ts src/settings/settings.service.spec.ts src/prisma/rls-access-inventory.spec.ts 2>&1 | grep -E "^\s+(Test Files|Tests)" ; DBIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1); (cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate status 2>&1 | tail -1 ; DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script 2>/dev/null | head -1) ; ls apps/api/prisma/migrations | grep -c "" ; grep -c "bugReportRecipient String?" apps/api/prisma/schema.prisma ; grep -c "ADD COLUMN \"bugReportRecipient\" TEXT" apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql ; grep -c "FileInterceptor('screenshot'" apps/api/src/bug-reports/bug-reports.controller.ts ; grep -v '^\s*//' apps/api/src/bug-reports/bug-reports.controller.ts | grep -v '^\s*\*' | grep -c "@Roles" ; grep -v '^\s*//' apps/api/src/bug-reports/dto/bug-report.dto.ts | grep -v '^\s*\*' | grep -c "tenantId" ; grep -c "const tenantPrisma = forTenant(" apps/api/src/bug-reports/bug-reports.service.ts ; grep -c "sendBugReport" apps/api/src/mail/mail.service.ts ; grep -c "BugReportsModule" apps/api/src/app.module.ts ; grep -c "bug-reports.service.ts | user | muss-mandantengebunden | gebunden" docs/mandantentrennung-zugriffsklassifikation.md ; grep -c 'TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}' docker-compose.prod.yml ; M=$(git diff --stat 5c42c55 -- apps/api/src/main.ts '.env*' apps/api/prisma/migrations/2026061* apps/api/prisma/migrations/2026062* apps/api/prisma/migrations/2026080* apps/api/prisma/migrations/2026081* apps/api/prisma/migrations/2026090* apps/api/prisma/migrations/2026091[0-4]12*); echo M_EXIT=$? ; test -z "$M"; echo M_EMPTY=$? ; pnpm -C apps/api exec tsc --noEmit; echo TSC_api=$?</automated> <automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run src/bug-reports src/mail/mail.service.spec.ts src/settings/settings.service.spec.ts src/prisma/rls-access-inventory.spec.ts 2>&1 | grep -E "^\s+(Test Files|Tests)" ; DBIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1); (cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate status 2>&1 | tail -1 ; DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script 2>/dev/null | head -1) ; ls apps/api/prisma/migrations | grep -c "" ; grep -c "bugReportRecipient String?" apps/api/prisma/schema.prisma ; grep -c "ADD COLUMN \"bugReportRecipient\" TEXT" apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql ; grep -c "FileInterceptor('screenshot'" apps/api/src/bug-reports/bug-reports.controller.ts ; grep -c "fileSize: 4 \* 1024 \* 1024" apps/api/src/bug-reports/bug-reports.controller.ts ; grep -v '^\s*//' apps/api/src/bug-reports/bug-reports.controller.ts | grep -v '^\s*\*' | grep -c "@Roles" ; grep -v '^\s*//' apps/api/src/bug-reports/dto/bug-report.dto.ts | grep -v '^\s*\*' | grep -c "tenantId" ; grep -c "const tenantPrisma = forTenant(" apps/api/src/bug-reports/bug-reports.service.ts ; grep -c "sendBugReport" apps/api/src/mail/mail.service.ts ; grep -c "BugReportsModule" apps/api/src/app.module.ts ; grep -c "bug-reports.service.ts | user | muss-mandantengebunden | gebunden" docs/mandantentrennung-zugriffsklassifikation.md ; grep -c 'TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}' docker-compose.prod.yml ; M=$(git diff --stat 5c42c55 -- apps/api/src/main.ts '.env*' apps/api/prisma/migrations/2026061* apps/api/prisma/migrations/2026062* apps/api/prisma/migrations/2026080* apps/api/prisma/migrations/2026081* apps/api/prisma/migrations/2026090* apps/api/prisma/migrations/2026091[0-4]12*); echo M_EXIT=$? ; test -z "$M"; echo M_EMPTY=$? ; pnpm -C apps/api exec tsc --noEmit; echo TSC_api=$?</automated>
</verify> </verify>
<done> <done>
Vitest-Zeilen `Test Files 5 passed (5)` und `Tests <Summe: bisherige Tests der vier Dateien + 16 neue>` — die volle Suite danach `67 passed (67)` / `1076 passed (1076)`; `migrate status` letzte Zeile `Database schema is up to date!`, `migrate diff` erste Zeile `-- This is an empty migration.`; Migrationsordner-Zaehlung `38` (36 + `migration_lock.toml` + 1 neu); Greps liefern `1` (Schema), `1` (Migration), `1` (FileInterceptor), `0` (kein Rollen-Dekorator im Controller), `0` (kein Mandantenfeld im DTO), `1` (Zuweisungsform), mindestens `2` (sendBugReport in mail.service.ts), `2` (Import + Eintrag), `1` (Doku-Zeile), `1` (Compose); `M_EXIT=0` und `M_EMPTY=0` (main.ts, .env*, bestehende Migrationen unangetastet); `TSC_api=0`. Der RED-Lauf aus Schritt A und die drei Prisma-Ausgaben aus Schritt B stehen im SUMMARY. Commit existiert mit genau 16 Dateien. Vitest-Zeilen `Test Files 5 passed (5)` und `Tests <Summe: bisherige Tests der vier Dateien + 16 neue>` — die volle Suite danach `67 passed (67)` / `1076 passed (1076)`; `migrate status` letzte Zeile `Database schema is up to date!`, `migrate diff` erste Zeile `-- This is an empty migration.`; Migrationsordner-Zaehlung `38` (36 + `migration_lock.toml` + 1 neu); Greps liefern `1` (Schema), `1` (Migration), `1` (FileInterceptor), `1` (4-MiB-Limit je Route, revidiert Runde 1), `0` (kein Rollen-Dekorator im Controller), `0` (kein Mandantenfeld im DTO), `1` (Zuweisungsform), mindestens `2` (sendBugReport in mail.service.ts), `2` (Import + Eintrag), `1` (Doku-Zeile), `1` (Compose); `M_EXIT=0` und `M_EMPTY=0` (main.ts, .env*, bestehende Migrationen unangetastet); `TSC_api=0`. Der RED-Lauf aus Schritt A und die drei Prisma-Ausgaben aus Schritt B stehen im SUMMARY. Commit existiert mit genau 16 Dateien.
</done> </done>
</task> </task>
<task type="auto" tdd="true"> <task type="auto" tdd="true">
<name>Task 2: Web — html-to-image, Fehlerpuffer, Knopf in der Kopfzeile, Dialog mit Vorschau, Feld „Fehlermeldungen an" im SMTP-Formular, i18n de/en, Komponententests</name> <name>Task 2: Web — html-to-image, Fehlerpuffer, Knopf in der Kopfzeile, Dialog mit Vorschau, Feld „Fehlermeldungen an" im SMTP-Formular, i18n de/en, Komponententests (zwei Commit-Teile 2a/2b)</name>
<files>apps/web/package.json, pnpm-lock.yaml, apps/web/src/lib/error-buffer.ts, apps/web/src/lib/error-buffer.test.ts, apps/web/src/lib/bug-report-api.ts, apps/web/src/components/bug-report/bug-report-button.tsx, apps/web/src/components/bug-report/bug-report-dialog.tsx, apps/web/src/components/bug-report/bug-report-button.test.tsx, apps/web/src/components/layout/header.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/lib/settings-api.ts, apps/web/src/components/settings/smtp-settings-form.tsx, apps/web/src/components/settings/smtp-settings-form.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files> <files>apps/web/package.json, pnpm-lock.yaml, apps/web/src/lib/error-buffer.ts, apps/web/src/lib/error-buffer.test.ts, apps/web/src/lib/bug-report-api.ts, apps/web/src/components/bug-report/bug-report-button.tsx, apps/web/src/components/bug-report/bug-report-dialog.tsx, apps/web/src/components/bug-report/bug-report-button.test.tsx, apps/web/src/components/layout/header.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/lib/settings-api.ts, apps/web/src/components/settings/smtp-settings-form.tsx, apps/web/src/components/settings/smtp-settings-form.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<behavior> <behavior>
`apps/web/src/lib/error-buffer.test.ts` (NEU, 4 Tests; jsdom; `afterEach`: `vi.restoreAllMocks()`, `vi.unstubAllGlobals()`, Puffer per exportiertem `clearErrorBuffer()` leeren, Installations-Guard per exportiertem `uninstallErrorBuffer()` zuruecksetzen): `apps/web/src/lib/error-buffer.test.ts` (NEU, 4 Tests; jsdom; `afterEach`: `vi.restoreAllMocks()`, `vi.unstubAllGlobals()`, Puffer per exportiertem `clearErrorBuffer()` leeren, Installations-Guard per exportiertem `uninstallErrorBuffer()` zuruecksetzen):
@@ -241,13 +249,18 @@ Zur Planungszeit (2026-09-14, HEAD `5c42c55`, Arbeitsbaum sauber, main == origin
- Test 2 (fetch-Wrapper): `vi.stubGlobal('fetch', vi.fn(async (input, init) => new Response('{"statusCode":500,"message":"kaputt"}', { status: 500 })))`; `installErrorBuffer()`; `await fetch('/api/x?token=geheim', { method: 'POST', body: '{"password":"p"}' })` -> genau ein Eintrag `kind: 'fetch'`, Meldung beginnt mit `POST /api/x -> 500`, enthaelt `kaputt`, enthaelt NICHT `geheim` und NICHT `password`; danach Antwort 200 -> KEIN neuer Eintrag; die Antwort selbst ist weiterhin lesbar (`await res.json()` liefert das Objekt — Wrapper liest nur einen `clone()`). - Test 2 (fetch-Wrapper): `vi.stubGlobal('fetch', vi.fn(async (input, init) => new Response('{"statusCode":500,"message":"kaputt"}', { status: 500 })))`; `installErrorBuffer()`; `await fetch('/api/x?token=geheim', { method: 'POST', body: '{"password":"p"}' })` -> genau ein Eintrag `kind: 'fetch'`, Meldung beginnt mit `POST /api/x -> 500`, enthaelt `kaputt`, enthaelt NICHT `geheim` und NICHT `password`; danach Antwort 200 -> KEIN neuer Eintrag; die Antwort selbst ist weiterhin lesbar (`await res.json()` liefert das Objekt — Wrapper liest nur einen `clone()`).
- Test 3 (console.error reicht durch): `const orig = vi.spyOn(console, 'error').mockImplementation(() => {})` VOR `installErrorBuffer()`; `console.error('boom', { a: 1 })` -> `orig` genau einmal gerufen, ein Eintrag `kind: 'console.error'` mit `boom` im Text. - Test 3 (console.error reicht durch): `const orig = vi.spyOn(console, 'error').mockImplementation(() => {})` VOR `installErrorBuffer()`; `console.error('boom', { a: 1 })` -> `orig` genau einmal gerufen, ein Eintrag `kind: 'console.error'` mit `boom` im Text.
- Test 4 (idempotent): `installErrorBuffer()` zweimal, dann eine fehlgeschlagene Antwort -> genau EIN Eintrag (kein doppeltes Wrapping); `formatErrorsForReport()` liefert Zeilen der Form `[<ISO>] fetch: …`. - Test 4 (idempotent): `installErrorBuffer()` zweimal, dann eine fehlgeschlagene Antwort -> genau EIN Eintrag (kein doppeltes Wrapping); `formatErrorsForReport()` liefert Zeilen der Form `[<ISO>] fetch: …`.
`apps/web/src/components/bug-report/bug-report-button.test.tsx` (NEU, 6 Tests; `vi.mock('html-to-image', () => ({ toPng: (...a) => mockToPng(...a) }))`; `vi.mock('next-intl')` mit Namensraum `bugReport` (Schluessel wie in Schritt E, Werte deutsch) und `header`; `vi.mock('@/lib/stores/auth-store', () => ({ useAuthStore: (sel?: any) => (sel ? sel({ user: mockUser }) : { user: mockUser }) }))`; `vi.mock('@/lib/app-version', () => ({ appVersion: { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' } }))`; `vi.stubGlobal('fetch', mockFetch)`; `@testing-library/user-event` fuer Klicks; Komponente per dynamischem Import nach den Mocks; `cleanup` im `afterEach`): `apps/web/src/components/bug-report/bug-report-button.test.tsx` (NEU, 11 Tests; `vi.mock('html-to-image', () => ({ toPng: (...a) => mockToPng(...a) }))`; `vi.mock('next-intl')` liest die Texte aus der ECHTEN Uebersetzungsdatei — `import de from '@/messages/de.json'` (Muster `tessera-logo.test.tsx`) und `useTranslations: (ns) => (key) => lookup(de, ns + '.' + key) ?? key` mit einem kleinen Punktpfad-Lookup; Erwartungen zitieren `de.bugReport.<key>`, nie hartkodierte Saetze (revidiert Runde 1); `vi.mock('@/lib/stores/auth-store', () => ({ useAuthStore: (sel?: any) => (sel ? sel({ user: mockUser }) : { user: mockUser }) }))`; `vi.mock('@/lib/app-version', () => ({ appVersion: { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' } }))`; `vi.stubGlobal('fetch', mockFetch)`; `@testing-library/user-event` fuer Klicks; Komponente per dynamischem Import nach den Mocks; `cleanup` im `afterEach`):
- Test 1 (Bild VOR dem Dialog): `mockToPng` prueft in seiner Implementierung `expect(screen.queryByRole('dialog')).toBeNull()` und liefert `'data:image/png;base64,iVBORw0KGgo='`; Klick auf `getByRole('button', { name: 'Fehler melden' })` -> `mockToPng` genau einmal mit `document.body` als erstem Argument und Optionen `pixelRatio: 1`, `skipFonts: true`; danach `findByRole('dialog')` sichtbar, `<img>` mit `src` = Data-URL, Checkbox `checked`, Textfeld leer. - Test 1 (Bild VOR dem Dialog): `mockToPng` prueft in seiner Implementierung `expect(screen.queryByRole('dialog')).toBeNull()` und liefert `'data:image/png;base64,iVBORw0KGgo='`; Klick auf `getByRole('button', { name: 'Fehler melden' })` -> `mockToPng` genau einmal mit `document.body` als erstem Argument und Optionen `pixelRatio: 1`, `skipFonts: true`, und — nachdem vor dem Klick `Object.defineProperty(document.body, 'scrollWidth', { value: 3200, configurable: true })` und `scrollHeight` mit `1000` gestubbt wurden — `canvasWidth: 1600`, `canvasHeight: 500` (revidiert Runde 1: die 1600-px-Kante ist damit in der CI regressionsgetestet); danach `findByRole('dialog')` sichtbar, `<img>` mit `src` = Data-URL, Checkbox `checked`, Textfeld leer.
- Test 2 (Senden mit Bild): Beschreibung `Knopf tut nichts` tippen, `recordError('fetch', 'GET /modules -> 500')` vorher, `mockFetch` -> `new Response('{"sent":true}', { status: 200 })`; Klick „Senden" -> `mockFetch` einmal; URL endet auf `/bug-reports`; `init.method === 'POST'`, `init.credentials === 'include'`, `init.body instanceof FormData`, `body.get('description') === 'Knopf tut nichts'`, `body.get('webVersion') === 'v1.2.3'`, `body.get('webChannel') === 'beta'`, `body.get('page')` beginnt mit `/`, `body.getAll('errors')` enthaelt einen Eintrag mit `GET /modules -> 500`, `body.get('screenshot')` ist eine `Blob` mit `type === 'image/png'` und `size > 0`; kein `Content-Type`-Header in `init.headers`; danach Text `Vielen Dank, die Meldung wurde gesendet.` und ein Knopf „Schließen". - Test 2 (Senden mit Bild): Beschreibung `Knopf tut nichts` tippen, `recordError('fetch', 'GET /modules -> 500')` vorher, `mockFetch` -> `new Response('{"sent":true}', { status: 200 })`; Klick „Senden" -> `mockFetch` einmal; URL endet auf `/bug-reports`; `init.method === 'POST'`, `init.credentials === 'include'`, `init.body instanceof FormData`, `body.get('description') === 'Knopf tut nichts'`, `body.get('webVersion') === 'v1.2.3'`, `body.get('webChannel') === 'beta'`, `body.get('page')` beginnt mit `/`, `body.getAll('errors')` enthaelt einen Eintrag mit `GET /modules -> 500`, `body.get('screenshot')` ist eine `Blob` mit `type === 'image/png'` und `size > 0`; kein `Content-Type`-Header in `init.headers`; danach Text `Vielen Dank, die Meldung wurde gesendet.` und ein Knopf „Schließen".
- Test 3 (Haekchen aus): Checkbox abwaehlen, senden -> `body.has('screenshot') === false`. - Test 3 (Haekchen aus): Checkbox abwaehlen, senden -> `body.has('screenshot') === false`.
- Test 4 (409 als Admin): `mockUser.role = 'ADMIN'`, `mockFetch` -> `new Response('{"statusCode":409,"message":"…"}', { status: 409 })` -> Text der `notConfigured`-Meldung UND der Admin-Hinweis mit einem Link `href="/admin/smtp"`; als `USER` (zweiter Render) KEIN Link. - Test 4 (409 als Admin): `mockUser.role = 'ADMIN'`, `mockFetch` -> `new Response('{"statusCode":409,"message":"…"}', { status: 409 })` -> Text der `notConfigured`-Meldung UND der Admin-Hinweis mit einem Link `href="/admin/smtp"`; als `USER` (zweiter Render) KEIN Link.
- Test 5 (Escape schliesst): Dialog offen, `user.keyboard('{Escape}')` -> `queryByRole('dialog')` null. - Test 5 (Escape schliesst): Dialog offen, `user.keyboard('{Escape}')` -> `queryByRole('dialog')` null.
- Test 6 (Aufnahme scheitert): `mockToPng` lehnt ab -> Dialog oeffnet trotzdem, Text `Kein Bildschirmfoto möglich`, Checkbox `disabled` und nicht `checked`, Senden -> `body.has('screenshot') === false`. - Test 6 (Aufnahme scheitert): `mockToPng` lehnt ab -> Dialog oeffnet trotzdem, Text `Kein Bildschirmfoto möglich`, Checkbox `disabled` und nicht `checked`, Senden -> `body.has('screenshot') === false`.
- Test 7 (413, revidiert Runde 1): `mockFetch` -> `new Response('{"statusCode":413}', { status: 413 })` -> der Dialog zeigt genau `de.bugReport.errorTooLarge`; Knoepfe bleiben, ein zweiter Klick auf „Senden" ruft `mockFetch` ein zweites Mal (erneutes Senden moeglich).
- Test 8 (429): Status 429 -> `de.bugReport.errorTooMany` sichtbar, `de.bugReport.errorTooLarge` NICHT.
- Test 9 (502): Status 502 -> `de.bugReport.errorSendFailed` sichtbar.
- Test 10 (allgemein): Status 500 -> `de.bugReport.errorGeneric`; im selben Test `mockFetch` -> `Promise.reject(new Error('netz'))` (Status 0) -> ebenfalls `errorGeneric`, und keine der vier spezifischen Meldungen (`errorNotConfigured`, `errorTooMany`, `errorTooLarge`, `errorSendFailed`) im Dokument.
- Test 11 (`computeCaptureSize`, reine Funktion, eigener `describe`-Block ohne Rendern, Import aus `@/lib/bug-report-api`): `(3200, 1000)` -> `{ width: 1600, height: 500 }`; `(800, 600)` -> `{ width: 800, height: 600 }` (unveraendert); `(1000, 4000)` -> `{ width: 400, height: 1600 }`; `(0, 0)` -> `{ width: 1, height: 1 }` (Mindestmass, kein 0-Canvas); `(3200, 1000, 800)` -> `{ width: 800, height: 250 }`.
`apps/web/src/components/settings/smtp-settings-form.test.tsx` (NEU, 2 Tests; `vi.mock('@/lib/settings-api')` mit `fetchSmtp`, `saveSmtp`, `testSmtp` als `vi.fn`; next-intl-Mock fuer `settings` mit den `smtp.*`-Schluesseln): `apps/web/src/components/settings/smtp-settings-form.test.tsx` (NEU, 2 Tests; `vi.mock('@/lib/settings-api')` mit `fetchSmtp`, `saveSmtp`, `testSmtp` als `vi.fn`; next-intl-Mock fuer `settings` mit den `smtp.*`-Schluesseln):
- Test 1 (vorbelegt): `fetchSmtp` liefert `{ host: 'h', port: 587, encryption: 'starttls', fromAddress: 'a@b.invalid', hasPassword: false, bugReportRecipient: 'fehler@b.invalid' }` -> `findByLabelText('Fehlermeldungen an')` hat den Wert `fehler@b.invalid`. - Test 1 (vorbelegt): `fetchSmtp` liefert `{ host: 'h', port: 587, encryption: 'starttls', fromAddress: 'a@b.invalid', hasPassword: false, bugReportRecipient: 'fehler@b.invalid' }` -> `findByLabelText('Fehlermeldungen an')` hat den Wert `fehler@b.invalid`.
- Test 2 (Payload): Wert auf `neu@b.invalid` aendern, Formular absenden -> `saveSmtp` mit `bugReportRecipient: 'neu@b.invalid'`; Feld leeren und erneut absenden -> `bugReportRecipient: null`. - Test 2 (Payload): Wert auf `neu@b.invalid` aendern, Formular absenden -> `saveSmtp` mit `bugReportRecipient: 'neu@b.invalid'`; Feld leeren und erneut absenden -> `bugReportRecipient: null`.
@@ -255,31 +268,33 @@ Zur Planungszeit (2026-09-14, HEAD `5c42c55`, Arbeitsbaum sauber, main == origin
<action> <action>
Schritt A — Abhaengigkeit: in `apps/web/package.json` unter `dependencies` alphabetisch `"html-to-image": "1.11.13"` (exakt, ohne Caret — Bildaufnahme ist empfindlich gegen Verhaltensaenderungen); dann `pnpm install` (Lockfile aendert sich, erwartet), danach `pnpm install --frozen-lockfile` Exit 0 (Gate). `ls apps/web/node_modules/html-to-image/lib/index.d.ts` vorhanden. Kein anderes Paket anfassen; `git diff --stat 5c42c55 -- apps/api/package.json packages/shared/package.json package.json` bleibt leer. Schritt A — Abhaengigkeit: in `apps/web/package.json` unter `dependencies` alphabetisch `"html-to-image": "1.11.13"` (exakt, ohne Caret — Bildaufnahme ist empfindlich gegen Verhaltensaenderungen); dann `pnpm install` (Lockfile aendert sich, erwartet), danach `pnpm install --frozen-lockfile` Exit 0 (Gate). `ls apps/web/node_modules/html-to-image/lib/index.d.ts` vorhanden. Kein anderes Paket anfassen; `git diff --stat 5c42c55 -- apps/api/package.json packages/shared/package.json package.json` bleibt leer.
Schritt B — RED: die drei Testdateien aus `<behavior>` anlegen; `pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report src/components/settings/smtp-settings-form.test.tsx` muss rot sein — Ausgabezeilen ins SUMMARY. Schritt B — RED (Teil 2a): die zwei Testdateien `error-buffer.test.ts` und `bug-report-button.test.tsx` aus `<behavior>` anlegen; `pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report` muss rot sein — Ausgabezeilen ins SUMMARY.
Schritt C — Fehlerpuffer `apps/web/src/lib/error-buffer.ts` (Kopfkommentar deutsch ASCII: Zweck, Grenzen, Sicherheitsregel T-M97-02): `export interface BufferedError { at: string; kind: 'error' | 'unhandledrejection' | 'console.error' | 'fetch'; message: string }`; `MAX_ENTRIES = 20`, `MAX_MESSAGE = 1000`, `BODY_EXCERPT = 200`; Modul-Array als Ringpuffer; `export function recordError(kind, message)` (kuerzt auf `MAX_MESSAGE`, `shift()` bei Ueberlauf); `export function getRecentErrors(): BufferedError[]` (Kopie); `export function clearErrorBuffer()`; `export function formatErrorsForReport(): string[]` (`[${at}] ${kind}: ${message}`); `export function installErrorBuffer(): void` — bei `typeof window === 'undefined'` sofort zurueck; Guard ueber eine Eigenschaft `__tesseraErrorBufferInstalled` auf `window` (idempotent, auch bei React-StrictMode-Doppeleffekten); registriert `window.addEventListener('error', e => recordError('error', e.message + ' @ ' + e.filename + ':' + e.lineno))` und `('unhandledrejection', e => recordError('unhandledrejection', String(e.reason?.message ?? e.reason)))`; ersetzt `console.error` durch eine Funktion, die ZUERST das Original mit denselben Argumenten aufruft und dann die Argumente als Text notiert (Strings direkt, Error-Objekte ueber `.message`, sonst `JSON.stringify` mit try/catch); ersetzt `window.fetch` durch einen Wrapper, der das Original aufruft und NUR bei `!response.ok` notiert: Methode (`init?.method ?? 'GET'`, gross), Pfad ohne Suchteil (`new URL(String(typeof input === 'string' ? input : input.url), window.location.href).pathname`), Status und die ersten 200 Zeichen von `await response.clone().text()` (try/catch; nie `init.body`, nie Kopfzeilen); Netzwerkfehler (`fetch` wirft) werden als `fetch: <METHODE> <Pfad> -> Netzwerkfehler` notiert und weitergeworfen; `export function uninstallErrorBuffer()` stellt Original-`fetch`/`console.error` wieder her und loescht den Guard (nur fuer Tests; kein Aufrufer im Produktionscode). Schritt C — Fehlerpuffer `apps/web/src/lib/error-buffer.ts` (Kopfkommentar deutsch ASCII: Zweck, Grenzen, Sicherheitsregel T-M97-02): `export interface BufferedError { at: string; kind: 'error' | 'unhandledrejection' | 'console.error' | 'fetch'; message: string }`; `MAX_ENTRIES = 20`, `MAX_MESSAGE = 1000`, `BODY_EXCERPT = 200`; Modul-Array als Ringpuffer; `export function recordError(kind, message)` (kuerzt auf `MAX_MESSAGE`, `shift()` bei Ueberlauf); `export function getRecentErrors(): BufferedError[]` (Kopie); `export function clearErrorBuffer()`; `export function formatErrorsForReport(): string[]` (`[${at}] ${kind}: ${message}`); `export function installErrorBuffer(): void` — bei `typeof window === 'undefined'` sofort zurueck; Guard ueber eine Eigenschaft `__tesseraErrorBufferInstalled` auf `window` (idempotent, auch bei React-StrictMode-Doppeleffekten); registriert `window.addEventListener('error', e => recordError('error', e.message + ' @ ' + e.filename + ':' + e.lineno))` und `('unhandledrejection', e => recordError('unhandledrejection', String(e.reason?.message ?? e.reason)))`; ersetzt `console.error` durch eine Funktion, die ZUERST das Original mit denselben Argumenten aufruft und dann die Argumente als Text notiert (Strings direkt, Error-Objekte ueber `.message`, sonst `JSON.stringify` mit try/catch); ersetzt `window.fetch` durch einen Wrapper, der das Original aufruft und NUR bei `!response.ok` notiert: Methode (`init?.method ?? 'GET'`, gross), Pfad ohne Suchteil (`new URL(String(typeof input === 'string' ? input : input.url), window.location.href).pathname`), Status und die ersten 200 Zeichen von `await response.clone().text()` (try/catch; nie `init.body`, nie Kopfzeilen); Netzwerkfehler (`fetch` wirft) werden als `fetch: <METHODE> <Pfad> -> Netzwerkfehler` notiert und weitergeworfen; `export function uninstallErrorBuffer()` stellt Original-`fetch`/`console.error` wieder her und loescht den Guard (nur fuer Tests; kein Aufrufer im Produktionscode).
In `app-shell.tsx`: Import und `useEffect(() => { installErrorBuffer(); }, []);` neben dem bestehenden `setMounted`-Effekt (Kommentar: einmal je Seitenladung, SSR-sicher). In `app-shell.tsx`: Import und `useEffect(() => { installErrorBuffer(); }, []);` neben dem bestehenden `setMounted`-Effekt (Kommentar: einmal je Seitenladung, SSR-sicher).
Schritt D — `apps/web/src/lib/bug-report-api.ts`: `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'` (Muster `settings-api.ts`). `export async function captureScreenshot(): Promise<string | null>`: `const { toPng } = await import('html-to-image')` (dynamischer Import, Bibliothek nur bei Klick geladen — Vorsicht: der Test-Mock greift auch bei dynamischem Import); `const node = document.body`; `const w = node.scrollWidth, h = node.scrollHeight`; `const scale = Math.min(1, 1600 / Math.max(w, h, 1))`; `toPng(node, { pixelRatio: 1, skipFonts: true, cacheBust: true, canvasWidth: Math.round(w * scale), canvasHeight: Math.round(h * scale), filter: (n) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true') })`; Fehler -> `null` (nie werfen; Kommentar: Bild ist Beigabe, der Bericht geht auch ohne). `export function dataUrlToBlob(dataUrl: string): Blob` (Base64 nach dem Komma per `atob` in `Uint8Array`, `new Blob([bytes], { type: 'image/png' })`). `export interface BugReportPayload { description: string; page: string; webVersion: string; webChannel: string; webCommit: string; userAgent: string; viewport: string; clientTime: string; errors: string[]; screenshot: Blob | null }`. `export async function sendBugReport(p: BugReportPayload): Promise<{ ok: true } | { ok: false; status: number }>`: `FormData` mit allen Textfeldern (`errors` je Eintrag per `append('errors', …)`), `screenshot` nur wenn nicht null (`append('screenshot', blob, 'screenshot.png')`); `fetch(`${API_URL}/bug-reports`, { method: 'POST', credentials: 'include', body })` OHNE `headers` (der Browser setzt die Multipart-Grenze selbst); `res.ok` -> `{ ok: true }`, sonst `{ ok: false, status: res.status }`; `fetch`-Fehler -> `{ ok: false, status: 0 }`. Schritt D — `apps/web/src/lib/bug-report-api.ts`: `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'` (Muster `settings-api.ts`). `export async function captureScreenshot(): Promise<string | null>`: `const { toPng } = await import('html-to-image')` (dynamischer Import, Bibliothek nur bei Klick geladen — Vorsicht: der Test-Mock greift auch bei dynamischem Import); `export function computeCaptureSize(width: number, height: number, maxEdge = 1600): { width: number; height: number }` als reine Funktion (`w = Math.max(1, Math.round(width))`, `h` ebenso, `scale = Math.min(1, maxEdge / Math.max(w, h))`, Rueckgabe `{ width: Math.max(1, Math.round(w * scale)), height: Math.max(1, Math.round(h * scale)) }`) — exportiert, damit die 1600-px-Kante direkt testbar ist (revidiert Runde 1); in `captureScreenshot`: `const node = document.body`; `const size = computeCaptureSize(node.scrollWidth, node.scrollHeight)`; `toPng(node, { pixelRatio: 1, skipFonts: true, cacheBust: true, canvasWidth: size.width, canvasHeight: size.height, filter: (n) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true') })`; Fehler -> `null` (nie werfen; Kommentar: Bild ist Beigabe, der Bericht geht auch ohne). `export function dataUrlToBlob(dataUrl: string): Blob` (Base64 nach dem Komma per `atob` in `Uint8Array`, `new Blob([bytes], { type: 'image/png' })`). `export interface BugReportPayload { description: string; page: string; webVersion: string; webChannel: string; webCommit: string; userAgent: string; viewport: string; clientTime: string; errors: string[]; screenshot: Blob | null }`. `export async function sendBugReport(p: BugReportPayload): Promise<{ ok: true } | { ok: false; status: number }>`: `FormData` mit allen Textfeldern (`errors` je Eintrag per `append('errors', …)`), `screenshot` nur wenn nicht null (`append('screenshot', blob, 'screenshot.png')`); `fetch(`${API_URL}/bug-reports`, { method: 'POST', credentials: 'include', body })` OHNE `headers` (der Browser setzt die Multipart-Grenze selbst); `res.ok` -> `{ ok: true }`, sonst `{ ok: false, status: res.status }`; `fetch`-Fehler -> `{ ok: false, status: 0 }`.
Schritt E — Uebersetzungen `de.json`/`en.json` (in BEIDEN Dateien an derselben Stelle: neuer Namensraum `bugReport` hinter `theme`; `settings.smtp` um zwei Schluessel hinter `testFailed`). Deutsch (Sie-Form, echte Umlaute): `bugReport.button` „Fehler melden"; `title` „Fehler melden"; `intro` „Tessera hat gerade ein Bild dieser Seite aufgenommen – es zeigt genau das, was Sie sehen. Bild, Beschreibung, Seite, Version, Browser und die letzten Fehlermeldungen gehen als E-Mail an Ihren Administrator."; `screenshotAlt` „Vorschau des Bildschirmfotos"; `screenshotUnavailable` „Kein Bildschirmfoto möglich – die Meldung wird ohne Bild gesendet."; `attachScreenshot` „Bildschirmfoto beifügen"; `descriptionLabel` „Was ist passiert?"; `descriptionPlaceholder` „Optional: Was haben Sie getan, was haben Sie erwartet, was ist stattdessen geschehen?"; `send` „Senden"; `sending` „Wird gesendet…"; `cancel` „Abbrechen"; `close` „Schließen"; `sent` „Vielen Dank, die Meldung wurde gesendet."; `errorNotConfigured` „Für Fehlermeldungen ist noch kein Postfach eingerichtet."; `errorNotConfiguredAdminHint` „Legen Sie die Adresse unter Administrator → SMTP im Feld „Fehlermeldungen an" fest."; `errorNotConfiguredAdminLink` „Zu den SMTP-Einstellungen"; `errorTooMany` „Zu viele Meldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut."; `errorTooLarge` „Das Bild ist zu groß. Bitte senden Sie die Meldung ohne Bildschirmfoto."; `errorSendFailed` „Die E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator."; `errorGeneric` „Die Meldung konnte nicht gesendet werden."; `settings.smtp.bugReportRecipient` „Fehlermeldungen an"; `settings.smtp.bugReportRecipientHelp` „Optional – Postfach, an das Anwender über den Knopf „Fehler melden" ihre Meldungen mit Bildschirmfoto schicken. Leer lassen, wenn der Knopf keine E-Mails senden soll.". Englisch sinngemaess (`Report a problem`, `Attach screenshot`, `What happened?`, `Thank you, your report has been sent.`, `Bug reports to`, …). Danach `pnpm -C apps/web exec vitest run src/messages` — flaggt der Umlaut-Waechter korrekte Woerter (erwartet mindestens `passiert`; moeglich `aktuellen`, `geschehen` ist frei), diese GENAU SO in `UMLAUT_ALLOWLIST` in `umlaut-dictionary.ts` eintragen (mit Kommentar `// 260914-m97`); keine Ersatzschreibung (`ae/oe/ue/ss` statt Umlaut) einfuehren. Schritt E — Uebersetzungen `de.json`/`en.json`, Teil 2a: NUR der neue Namensraum `bugReport` hinter `theme` (in BEIDEN Dateien an derselben Stelle); die zwei `settings.smtp`-Schluessel werden erst in Teil 2b (Schritt G) eingetragen, damit Commit 2a ohne das SMTP-Formular vollstaendig ist (revidiert Runde 1). Deutsch (Sie-Form, echte Umlaute): `bugReport.button` „Fehler melden"; `title` „Fehler melden"; `intro` „Tessera hat gerade ein Bild dieser Seite aufgenommen – es zeigt genau das, was Sie sehen. Bild, Beschreibung, Seite, Version, Browser und die letzten Fehlermeldungen gehen als E-Mail an Ihren Administrator."; `screenshotAlt` „Vorschau des Bildschirmfotos"; `screenshotUnavailable` „Kein Bildschirmfoto möglich – die Meldung wird ohne Bild gesendet."; `attachScreenshot` „Bildschirmfoto beifügen"; `descriptionLabel` „Was ist passiert?"; `descriptionPlaceholder` „Optional: Was haben Sie getan, was haben Sie erwartet, was ist stattdessen geschehen?"; `send` „Senden"; `sending` „Wird gesendet…"; `cancel` „Abbrechen"; `close` „Schließen"; `sent` „Vielen Dank, die Meldung wurde gesendet."; `errorNotConfigured` „Für Fehlermeldungen ist noch kein Postfach eingerichtet."; `errorNotConfiguredAdminHint` „Legen Sie die Adresse unter Administrator → SMTP im Feld „Fehlermeldungen an" fest."; `errorNotConfiguredAdminLink` „Zu den SMTP-Einstellungen"; `errorTooMany` „Zu viele Meldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut."; `errorTooLarge` „Das Bild ist zu groß. Bitte senden Sie die Meldung ohne Bildschirmfoto."; `errorSendFailed` „Die E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator."; `errorGeneric` „Die Meldung konnte nicht gesendet werden."; (Wortlaut fuer Teil 2b, Eintrag erst in Schritt G:) `settings.smtp.bugReportRecipient` „Fehlermeldungen an"; `settings.smtp.bugReportRecipientHelp` „Optional – Postfach, an das Anwender über den Knopf „Fehler melden" ihre Meldungen mit Bildschirmfoto schicken. Leer lassen, wenn der Knopf keine E-Mails senden soll.". Englisch sinngemaess (`Report a problem`, `Attach screenshot`, `What happened?`, `Thank you, your report has been sent.`, `Bug reports to`, …). Danach `pnpm -C apps/web exec vitest run src/messages` — flaggt der Umlaut-Waechter korrekte Woerter (erwartet mindestens `passiert`; moeglich `aktuellen`, `geschehen` ist frei), diese GENAU SO in `UMLAUT_ALLOWLIST` in `umlaut-dictionary.ts` eintragen (mit Kommentar `// 260914-m97`); keine Ersatzschreibung (`ae/oe/ue/ss` statt Umlaut) einfuehren.
Schritt F — Komponenten (Verzeichnis `apps/web/src/components/bug-report/`): Schritt F — Komponenten (Verzeichnis `apps/web/src/components/bug-report/`):
1. `bug-report-dialog.tsx` (`'use client'`; Props `open: boolean`, `screenshot: string | null`, `isAdmin: boolean`, `onClose: () => void`; `useTranslations('bugReport')`): Aufbau wie `ActivationDialog.tsx` (`fixed inset-0 z-50 flex items-center justify-center bg-black/50`, innen `w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg`, `role="dialog" aria-modal="true" aria-labelledby`), Escape -> `onClose` (nicht waehrend `sending`), Fokus beim Oeffnen auf das Textfeld; Zustand `status: 'ready' | 'sending' | 'sent' | 'failed'`, `failedStatus: number`, `description`, `attach` (Vorgabe `screenshot !== null`); Inhalt: Titel, `intro`-Absatz (`text-sm text-muted-foreground`), Bild `<img src={screenshot} alt={t('screenshotAlt')} className="max-h-48 w-auto rounded border border-border" />` oder `screenshotUnavailable`-Text, Checkbox (`id="bug-report-attach"`, `disabled={screenshot === null}`), Textarea (`id="bug-report-description"`, `rows={4}`, `maxLength={4000}`, Platzhalter), Knopfzeile „Abbrechen"/„Senden" (Stile der ActivationDialog-Knoepfe, Primaerknopf `bg-primary text-primary-foreground`), `disabled` waehrend `sending`; nach `sent`: Erfolgstext und ein Knopf „Schließen"; nach `failed`: Meldung je Status (409 -> `errorNotConfigured` + bei `isAdmin` `errorNotConfiguredAdminHint` und `next/link` auf `/admin/smtp` mit `errorNotConfiguredAdminLink`; 429 -> `errorTooMany`; 413 -> `errorTooLarge`; 502 -> `errorSendFailed`; sonst `errorGeneric`) in `text-sm text-destructive`, Knoepfe bleiben, erneutes Senden moeglich. `handleSend`: `sendBugReport({ description: description.trim(), page: window.location.pathname + window.location.search, webVersion: appVersion.version, webChannel: appVersion.channel, webCommit: appVersion.commit, userAgent: navigator.userAgent, viewport: `${window.innerWidth}x${window.innerHeight}`, clientTime: new Date().toISOString(), errors: formatErrorsForReport(), screenshot: attach && screenshot ? dataUrlToBlob(screenshot) : null })`. Das Wurzelelement traegt `data-bug-report-ignore="true"` (defensiv, falls je waehrend offenem Dialog aufgenommen wuerde). 1. `bug-report-dialog.tsx` (`'use client'`; Props `open: boolean`, `screenshot: string | null`, `isAdmin: boolean`, `onClose: () => void`; `useTranslations('bugReport')`): Aufbau wie `ActivationDialog.tsx` (`fixed inset-0 z-50 flex items-center justify-center bg-black/50`, innen `w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg`, `role="dialog" aria-modal="true" aria-labelledby`), Escape -> `onClose` (nicht waehrend `sending`), Fokus beim Oeffnen auf das Textfeld; Zustand `status: 'ready' | 'sending' | 'sent' | 'failed'`, `failedStatus: number`, `description`, `attach` (Vorgabe `screenshot !== null`); Inhalt: Titel, `intro`-Absatz (`text-sm text-muted-foreground`), Bild `<img src={screenshot} alt={t('screenshotAlt')} className="max-h-48 w-auto rounded border border-border" />` oder `screenshotUnavailable`-Text, Checkbox (`id="bug-report-attach"`, `disabled={screenshot === null}`), Textarea (`id="bug-report-description"`, `rows={4}`, `maxLength={4000}`, Platzhalter), Knopfzeile „Abbrechen"/„Senden" (Stile der ActivationDialog-Knoepfe, Primaerknopf `bg-primary text-primary-foreground`), `disabled` waehrend `sending`; nach `sent`: Erfolgstext und ein Knopf „Schließen"; nach `failed`: Meldung je Status (409 -> `errorNotConfigured` + bei `isAdmin` `errorNotConfiguredAdminHint` und `next/link` auf `/admin/smtp` mit `errorNotConfiguredAdminLink`; 429 -> `errorTooMany`; 413 -> `errorTooLarge`; 502 -> `errorSendFailed`; sonst `errorGeneric`) in `text-sm text-destructive`, Knoepfe bleiben, erneutes Senden moeglich. `handleSend`: `sendBugReport({ description: description.trim(), page: window.location.pathname + window.location.search, webVersion: appVersion.version, webChannel: appVersion.channel, webCommit: appVersion.commit, userAgent: navigator.userAgent, viewport: `${window.innerWidth}x${window.innerHeight}`, clientTime: new Date().toISOString(), errors: formatErrorsForReport(), screenshot: attach && screenshot ? dataUrlToBlob(screenshot) : null })`. Das Wurzelelement traegt `data-bug-report-ignore="true"` (defensiv, falls je waehrend offenem Dialog aufgenommen wuerde).
2. `bug-report-button.tsx` (`'use client'`): `useTranslations('bugReport')`, `const user = useAuthStore((s) => s.user)`, `isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'`; Zustand `capturing`, `open`, `screenshot`; `handleClick`: wenn `capturing` zurueck; `setCapturing(true)`; `const shot = await captureScreenshot()` — ERST DANACH `setScreenshot(shot); setOpen(true); setCapturing(false)` (Kommentar: Reihenfolge ist die Kernanforderung — kein Dialog im Bild); Knopf `<button type="button" onClick className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50" aria-label={t('button')} title={t('button')} disabled={capturing} data-bug-report-ignore="true">` mit Inline-SVG 20x20 (Kaefer-Symbol: `stroke="currentColor" strokeWidth="2"`, Pfade nach dem lucide-Symbol `bug`: Koerper als abgerundetes Rechteck mit Beinen — die genaue Pfadwahl ist Ermessen, Aussehen wie die Nachbarsymbole); daneben `<BugReportDialog open={open} screenshot={screenshot} isAdmin={isAdmin} onClose={() => setOpen(false)} />`. 2. `bug-report-button.tsx` (`'use client'`): `useTranslations('bugReport')`, `const user = useAuthStore((s) => s.user)`, `isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'`; Zustand `capturing`, `open`, `screenshot`; `handleClick`: wenn `capturing` zurueck; `setCapturing(true)`; `const shot = await captureScreenshot()` — ERST DANACH `setScreenshot(shot); setOpen(true); setCapturing(false)` (Kommentar: Reihenfolge ist die Kernanforderung — kein Dialog im Bild); Knopf `<button type="button" onClick className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50" aria-label={t('button')} title={t('button')} disabled={capturing} data-bug-report-ignore="true">` mit Inline-SVG 20x20 (Kaefer-Symbol: `stroke="currentColor" strokeWidth="2"`, Pfade nach dem lucide-Symbol `bug`: Koerper als abgerundetes Rechteck mit Beinen — die genaue Pfadwahl ist Ermessen, Aussehen wie die Nachbarsymbole); daneben `<BugReportDialog open={open} screenshot={screenshot} isAdmin={isAdmin} onClose={() => setOpen(false)} />`.
3. `header.tsx`: Import `BugReportButton` aus `@/components/bug-report/bug-report-button`; in der Aktionsleiste (Zeile 111 `<div className="flex items-center gap-2">`) `<BugReportButton />` UNMITTELBAR VOR `<ThemeToggle />`. 3. `header.tsx`: Import `BugReportButton` aus `@/components/bug-report/bug-report-button`; in der Aktionsleiste (Zeile 111 `<div className="flex items-center gap-2">`) `<BugReportButton />` UNMITTELBAR VOR `<ThemeToggle />`.
Schritt G — SMTP-Formular: `settings-api.ts` `SmtpConfig` um `bugReportRecipient: string | null`, `SaveSmtpPayload` um `bugReportRecipient?: string | null` (Kommentar: `null` loescht, fehlend bewahrt — Vertrag mit `saveSmtpConfig`). `smtp-settings-form.tsx`: `FormState` um `bugReportRecipient: string` (Vorgabe `''`), beim Laden `config.bugReportRecipient ?? ''`, in `buildPayload` IMMER `payload.bugReportRecipient = form.bugReportRecipient.trim() || null` (das Formular ist der einzige Klient; leer bedeutet loeschen), neuer Block hinter der Absenderadresse und VOR „Test-E-Mail an": Label `t('smtp.bugReportRecipient')` (`htmlFor="smtp-bug-report-recipient"`), `<input id="smtp-bug-report-recipient" type="email" className={inputClass} placeholder="fehler@example.com" …>`, Hinweis `t('smtp.bugReportRecipientHelp')` in `mt-1 text-xs text-muted-foreground`. Schritt F2 — Gate und Commit Teil 2a (revidiert Runde 1, Commit-Grenze): `pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report src/messages` gruen (`Test Files 4 passed (4)`: error-buffer 4, bug-report-button 11, die zwei Waechter unveraendert), `pnpm -C apps/web exec tsc --noEmit` Exit 0. Commit 2a: `feat(quick-260914-m97): Fehler-melden-Knopf in der Kopfzeile — Bildschirmfoto vor dem Dialog (html-to-image 1.11.13), Fehlerpuffer, Dialog mit Vorschau, i18n bugReport` mit genau diesen 13 Dateien: `apps/web/package.json`, `pnpm-lock.yaml`, `error-buffer.ts`, `error-buffer.test.ts`, `bug-report-api.ts`, `bug-report-button.tsx`, `bug-report-dialog.tsx`, `bug-report-button.test.tsx`, `header.tsx`, `app-shell.tsx`, `de.json`, `en.json`, `umlaut-dictionary.ts` (`git show --stat HEAD` zeigt 13). Wiederaufsetzpunkt: wird die Ausfuehrung danach unterbrochen, beginnt sie bei Schritt G, ohne Teil 2a zu wiederholen.
Schritt H — GREEN: `pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report src/components/settings/smtp-settings-form.test.tsx src/messages` gruen; volle Suite `Test Files 43 passed (43)` / `Tests 255 passed (255)`; `pnpm -C apps/web exec tsc --noEmit` Exit 0; `pnpm -C apps/web build` NICHT noetig (der CI-Lauf baut; lokal reicht tsc). Abweichende Zahlen sind ein Befund fuer das SUMMARY. Schritt G — Teil 2b, SMTP-Formular. RED zuerst: `smtp-settings-form.test.tsx` aus `<behavior>` anlegen, `pnpm -C apps/web exec vitest run src/components/settings/smtp-settings-form.test.tsx` rot (Ausgabe ins SUMMARY). Dann `de.json`/`en.json` um die zwei Schluessel `settings.smtp.bugReportRecipient` / `bugReportRecipientHelp` hinter `testFailed` (Wortlaut in Schritt E; Umlaut-Waechter danach erneut gruen); `settings-api.ts` `SmtpConfig` um `bugReportRecipient: string | null`, `SaveSmtpPayload` um `bugReportRecipient?: string | null` (Kommentar: `null` loescht, fehlend bewahrt — Vertrag mit `saveSmtpConfig`). `smtp-settings-form.tsx`: `FormState` um `bugReportRecipient: string` (Vorgabe `''`), beim Laden `config.bugReportRecipient ?? ''`, in `buildPayload` IMMER `payload.bugReportRecipient = form.bugReportRecipient.trim() || null` (das Formular ist der einzige Klient; leer bedeutet loeschen), neuer Block hinter der Absenderadresse und VOR „Test-E-Mail an": Label `t('smtp.bugReportRecipient')` (`htmlFor="smtp-bug-report-recipient"`), `<input id="smtp-bug-report-recipient" type="email" className={inputClass} placeholder="fehler@example.com" …>`, Hinweis `t('smtp.bugReportRecipientHelp')` in `mt-1 text-xs text-muted-foreground`.
Commit: `feat(quick-260914-m97): Fehler-melden-Knopf in der Kopfzeile — Bildschirmfoto vor dem Dialog (html-to-image 1.11.13), Fehlerpuffer, Dialog mit Vorschau, Feld Fehlermeldungen an im SMTP-Formular, i18n de/en` mit genau den 16 Dateien dieser Aufgabe (inkl. `pnpm-lock.yaml`, `umlaut-dictionary.ts`). Schritt H — GREEN Teil 2b und Gesamt: `pnpm -C apps/web exec vitest run src/components/settings/smtp-settings-form.test.tsx src/messages` gruen; volle Suite `Test Files 43 passed (43)` / `Tests 260 passed (260)` (243 + 4 + 11 + 2, revidiert Runde 1); `pnpm -C apps/web exec tsc --noEmit` Exit 0; `pnpm -C apps/web build` NICHT noetig (der CI-Lauf baut; lokal reicht tsc). Abweichende Zahlen sind ein Befund fuer das SUMMARY.
Commit 2b: `feat(quick-260914-m97): Feld Fehlermeldungen an im SMTP-Formular — settings-api, Formular, i18n settings.smtp` mit genau diesen 5 Dateien: `settings-api.ts`, `smtp-settings-form.tsx`, `smtp-settings-form.test.tsx`, `de.json`, `en.json` (`git show --stat HEAD` zeigt 5). Beide Commits zusammen decken die 16 Dateien dieser Aufgabe (`de.json`/`en.json` in beiden).
</action> </action>
<verify> <verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm install --frozen-lockfile >/dev/null 2>&1; echo FROZEN=$? ; node -e "const p=require('./apps/web/package.json');console.log('h2i='+p.dependencies['html-to-image'])" ; pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report src/components/settings/smtp-settings-form.test.tsx src/messages 2>&1 | grep -E "^\s+(Test Files|Tests)" ; grep -c "<BugReportButton />" apps/web/src/components/layout/header.tsx ; awk '/<BugReportButton \/>/{b=NR} /<ThemeToggle \/>/{t=NR} END{print (b>0 && t>b) ? "ORDER=ok" : "ORDER=falsch"}' apps/web/src/components/layout/header.tsx ; grep -c "installErrorBuffer()" apps/web/src/components/layout/app-shell.tsx ; grep -c "skipFonts: true" apps/web/src/lib/bug-report-api.ts ; grep -c "smtp-bug-report-recipient" apps/web/src/components/settings/smtp-settings-form.tsx ; node -e "const d=require('./apps/web/src/messages/de.json'),e=require('./apps/web/src/messages/en.json');console.log(d.bugReport.button,'|',d.bugReport.descriptionLabel,'|',d.settings.smtp.bugReportRecipient,'|',e.bugReport.button,'|',Object.keys(d.bugReport).length===Object.keys(e.bugReport).length)" ; U=$(git diff --stat 5c42c55 -- apps/api/package.json packages/shared/package.json package.json); test -z "$U"; echo U_EMPTY=$? ; pnpm -C apps/web exec tsc --noEmit; echo TSC_web=$?</automated> <automated>cd /home/vicolab/projects/tessera-ctl && pnpm install --frozen-lockfile >/dev/null 2>&1; echo FROZEN=$? ; node -e "const p=require('./apps/web/package.json');console.log('h2i='+p.dependencies['html-to-image'])" ; pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report src/components/settings/smtp-settings-form.test.tsx src/messages 2>&1 | grep -E "^\s+(Test Files|Tests)" ; grep -c "<BugReportButton />" apps/web/src/components/layout/header.tsx ; awk '/<BugReportButton \/>/{b=NR} /<ThemeToggle \/>/{t=NR} END{print (b>0 && t>b) ? "ORDER=ok" : "ORDER=falsch"}' apps/web/src/components/layout/header.tsx ; grep -c "installErrorBuffer()" apps/web/src/components/layout/app-shell.tsx ; grep -c "skipFonts: true" apps/web/src/lib/bug-report-api.ts ; grep -c "export function computeCaptureSize" apps/web/src/lib/bug-report-api.ts ; grep -c "smtp-bug-report-recipient" apps/web/src/components/settings/smtp-settings-form.tsx ; node -e "const d=require('./apps/web/src/messages/de.json'),e=require('./apps/web/src/messages/en.json');console.log(d.bugReport.button,'|',d.bugReport.descriptionLabel,'|',d.settings.smtp.bugReportRecipient,'|',e.bugReport.button,'|',Object.keys(d.bugReport).length===Object.keys(e.bugReport).length)" ; U=$(git diff --stat 5c42c55 -- apps/api/package.json packages/shared/package.json package.json); test -z "$U"; echo U_EMPTY=$? ; pnpm -C apps/web exec tsc --noEmit; echo TSC_web=$?</automated>
</verify> </verify>
<done> <done>
`FROZEN=0`; `h2i=1.11.13`; Vitest-Zeilen `Test Files 5 passed (5)` (error-buffer, bug-report-button, smtp-settings-form, umlaut-guard, tenderRadar-parity) und `Tests <bisherige Tests der beiden Waechter + 12 neue>` — die volle Suite danach `43 passed (43)` / `255 passed (255)`; Greps `1`, `ORDER=ok`, `1`, `1`, mindestens `2`; die Node-Zeile lautet `Fehler melden | Was ist passiert? | Fehlermeldungen an | Report a problem | true`; `U_EMPTY=0`; `TSC_web=0`. RED-Lauf aus Schritt B im SUMMARY; das SUMMARY nennt die vom Umlaut-Waechter geforderten Allowlist-Woerter und die Lockfile-Aenderung (Docker-deps-Stufe beider Abbilder wird beim naechsten CI-Bau neu laufen — erwartet). Commit existiert mit genau 16 Dateien. `FROZEN=0`; `h2i=1.11.13`; Vitest-Zeilen `Test Files 5 passed (5)` (error-buffer, bug-report-button, smtp-settings-form, umlaut-guard, tenderRadar-parity) und `Tests <bisherige Tests der beiden Waechter + 17 neue>` (4 + 11 + 2, revidiert Runde 1) — die volle Suite danach `43 passed (43)` / `260 passed (260)`; Greps `1`, `ORDER=ok`, `1`, `1`, `1` (computeCaptureSize exportiert), mindestens `2`; die Node-Zeile lautet `Fehler melden | Was ist passiert? | Fehlermeldungen an | Report a problem | true`; `U_EMPTY=0`; `TSC_web=0`. RED-Lauf aus Schritt B im SUMMARY; das SUMMARY nennt die vom Umlaut-Waechter geforderten Allowlist-Woerter und die Lockfile-Aenderung (Docker-deps-Stufe beider Abbilder wird beim naechsten CI-Bau neu laufen — erwartet). Zwei Commits existieren: 2a mit genau 13 Dateien, 2b mit genau 5 Dateien (revidiert Runde 1).
</done> </done>
</task> </task>
@@ -302,7 +317,7 @@ Zur Planungszeit (2026-09-14, HEAD `5c42c55`, Arbeitsbaum sauber, main == origin
Schritt D — Gates, Commit, Push, Beobachtung: Schritt D — Gates, Commit, Push, Beobachtung:
1. `grep -c "^## Einen Fehler melden" docs/anleitung-anwender.md` -> 1; `grep -c "drei Bedienelemente" docs/anleitung-anwender.md` -> 1; `grep -c "Fehlermeldungen an" docs/anleitung-administration.md` -> mindestens 3; `grep -c "TESSERA_BUGREPORT_TO" docs/anleitung-betrieb.md` -> mindestens 2; `grep -c "TESSERA_BUGREPORT_TO" docs/anleitung-administration.md` -> mindestens 1. 1. `grep -c "^## Einen Fehler melden" docs/anleitung-anwender.md` -> 1; `grep -c "drei Bedienelemente" docs/anleitung-anwender.md` -> 1; `grep -c "Fehlermeldungen an" docs/anleitung-administration.md` -> mindestens 3; `grep -c "TESSERA_BUGREPORT_TO" docs/anleitung-betrieb.md` -> mindestens 2; `grep -c "TESSERA_BUGREPORT_TO" docs/anleitung-administration.md` -> mindestens 1.
2. Volle Suiten und tsc erneut: API `67 passed (67)` / `1076 passed (1076)`, Web `43 passed (43)` / `255 passed (255)`, `tsc` dreimal 0; `pnpm install --frozen-lockfile` Exit 0. 2. Volle Suiten und tsc erneut: API `67 passed (67)` / `1076 passed (1076)`, Web `43 passed (43)` / `260 passed (260)`, `tsc` dreimal 0; `pnpm install --frozen-lockfile` Exit 0.
3. `D=$(git diff --stat 5c42c55 -- . ':!.planning'); echo GIT_EXIT=$?; tail -n1 <<< "$D"` -> `35 files changed`; Unangetastet-Stichprobe `git diff --stat 5c42c55 -- apps/api/src/main.ts biome.json '.env*' apps/api/package.json packages/shared package.json apps/api/prisma/migrations/20260914120000_rls_system_context_read` -> leer. 3. `D=$(git diff --stat 5c42c55 -- . ':!.planning'); echo GIT_EXIT=$?; tail -n1 <<< "$D"` -> `35 files changed`; Unangetastet-Stichprobe `git diff --stat 5c42c55 -- apps/api/src/main.ts biome.json '.env*' apps/api/package.json packages/shared package.json apps/api/prisma/migrations/20260914120000_rls_system_context_read` -> leer.
4. Commit: `docs(quick-260914-m97): Handbuecher — Einen Fehler melden (Anwender), Feld Fehlermeldungen an (Administration), TESSERA_BUGREPORT_TO als Rueckfall (Betrieb)` (nur die 3 Dateien). Danach `git push` (schlichter Aufruf; die Push-URL zeigt auf localhost:3002); `git status -sb | head -n1` ohne `[ahead`. 4. Commit: `docs(quick-260914-m97): Handbuecher — Einen Fehler melden (Anwender), Feld Fehlermeldungen an (Administration), TESSERA_BUGREPORT_TO als Rueckfall (Betrieb)` (nur die 3 Dateien). Danach `git push` (schlichter Aufruf; die Push-URL zeigt auf localhost:3002); `git status -sb | head -n1` ohne `[ahead`.
5. Beobachtung des echten CI-Laufs (Token NIE ausgeben — nur in einer Shell-Variablen verwenden; Verfahren wie 260914-ku1 Task 3): `PUSHED=$(git rev-parse HEAD); TOK=$(git config --get remote.origin.pushurl | sed -E 's#.*schalli:([^@]+)@.*#\1#')`; bis zu 12 Minuten alle 20 s `curl -s -H "Authorization: token $TOK" "http://localhost:3002/api/v1/repos/schalli/tessera-ctl/actions/runs?limit=5"` abfragen, Eintrag mit `head_sha == PUSHED`, auf `status == completed` warten (Hintergrundbefehl, falls `sleep` im Vordergrund blockiert ist); erwartete Dauer eher 6-8 Minuten (deps-Stufe beider Abbilder laeuft wegen des Lockfiles neu). Erwartung `conclusion == success`. Danach `docker run --rm --entrypoint node localhost:3002/schalli/tessera-ctl/api:beta -e 'console.log(process.env.APP_VERSION)'` -> kurzer SHA von `PUSHED`, und `docker run --rm --entrypoint sh localhost:3002/schalli/tessera-ctl/web:beta -c 'ls /app/apps/web/node_modules/html-to-image/package.json 2>/dev/null || ls /app/node_modules/.pnpm | grep -c html-to-image'` -> Paket im Abbild vorhanden. Lauf-ID, Dauer, Ergebnis ins SUMMARY. Ist `conclusion` nicht `success`: Job-Log ueber `.../actions/runs/<id>/jobs` lesen, Ursache benennen, Korrektur als `fix(quick-260914-m97)`-Commit, erneut pushen und beobachten. 5. Beobachtung des echten CI-Laufs (Token NIE ausgeben — nur in einer Shell-Variablen verwenden; Verfahren wie 260914-ku1 Task 3): `PUSHED=$(git rev-parse HEAD); TOK=$(git config --get remote.origin.pushurl | sed -E 's#.*schalli:([^@]+)@.*#\1#')`; bis zu 12 Minuten alle 20 s `curl -s -H "Authorization: token $TOK" "http://localhost:3002/api/v1/repos/schalli/tessera-ctl/actions/runs?limit=5"` abfragen, Eintrag mit `head_sha == PUSHED`, auf `status == completed` warten (Hintergrundbefehl, falls `sleep` im Vordergrund blockiert ist); erwartete Dauer eher 6-8 Minuten (deps-Stufe beider Abbilder laeuft wegen des Lockfiles neu). Erwartung `conclusion == success`. Danach `docker run --rm --entrypoint node localhost:3002/schalli/tessera-ctl/api:beta -e 'console.log(process.env.APP_VERSION)'` -> kurzer SHA von `PUSHED`, und `docker run --rm --entrypoint sh localhost:3002/schalli/tessera-ctl/web:beta -c 'ls /app/apps/web/node_modules/html-to-image/package.json 2>/dev/null || ls /app/node_modules/.pnpm | grep -c html-to-image'` -> Paket im Abbild vorhanden. Lauf-ID, Dauer, Ergebnis ins SUMMARY. Ist `conclusion` nicht `success`: Job-Log ueber `.../actions/runs/<id>/jobs` lesen, Ursache benennen, Korrektur als `fix(quick-260914-m97)`-Commit, erneut pushen und beobachten.
@@ -348,7 +363,7 @@ Zur Planungszeit (2026-09-14, HEAD `5c42c55`, Arbeitsbaum sauber, main == origin
<verification> <verification>
Nach Task 3, alles aus `/home/vicolab/projects/tessera-ctl`: Nach Task 3, alles aus `/home/vicolab/projects/tessera-ctl`:
- `pnpm -C apps/api exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)"` -> `Test Files 67 passed (67)` / `Tests 1076 passed (1076)` - `pnpm -C apps/api exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)"` -> `Test Files 67 passed (67)` / `Tests 1076 passed (1076)`
- `pnpm -C apps/web exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)"` -> `Test Files 43 passed (43)` / `Tests 255 passed (255)` - `pnpm -C apps/web exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)"` -> `Test Files 43 passed (43)` / `Tests 260 passed (260)`
- `for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit; echo "TSC_$p=$?"; done` -> dreimal `=0` - `for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit; echo "TSC_$p=$?"; done` -> dreimal `=0`
- `pnpm install --frozen-lockfile; echo $?` -> `0` - `pnpm install --frozen-lockfile; echo $?` -> `0`
- `D=$(git diff --stat 5c42c55 -- . ':!.planning'); tail -n1 <<< "$D"` -> `35 files changed` - `D=$(git diff --stat 5c42c55 -- . ':!.planning'); tail -n1 <<< "$D"` -> `35 files changed`
@@ -365,7 +380,7 @@ Nach Task 3, alles aus `/home/vicolab/projects/tessera-ctl`:
- `SmtpConfig.bugReportRecipient` per additiver Migration (lokal eingespielt, `migrate status`/`diff` sauber), Feld „Fehlermeldungen an" unter Administrator -> SMTP, Rueckfall `TESSERA_BUGREPORT_TO` in `docker-compose.prod.yml` und im Betriebshandbuch. - `SmtpConfig.bugReportRecipient` per additiver Migration (lokal eingespielt, `migrate status`/`diff` sauber), Feld „Fehlermeldungen an" unter Administrator -> SMTP, Rueckfall `TESSERA_BUGREPORT_TO` in `docker-compose.prod.yml` und im Betriebshandbuch.
- Fehlerpuffer ohne Kennwoerter/Tokens/Anfrage-Ruempfe (Tests pinnen es), idempotent, SSR-sicher. - Fehlerpuffer ohne Kennwoerter/Tokens/Anfrage-Ruempfe (Tests pinnen es), idempotent, SSR-sicher.
- html-to-image 1.11.13 exakt gepinnt, Lockfile aktualisiert, `--frozen-lockfile` gruen; Umlaut-Waechter gruen mit begruendeten Allowlist-Eintraegen; Handbuecher in Alltagssprache mit Datenschutz-Hinweis. - html-to-image 1.11.13 exakt gepinnt, Lockfile aktualisiert, `--frozen-lockfile` gruen; Umlaut-Waechter gruen mit begruendeten Allowlist-Eintraegen; Handbuecher in Alltagssprache mit Datenschutz-Hinweis.
- API 67/1076, Web 43/255, tsc dreimal 0, genau 35 Dateien ausserhalb `.planning`, drei Commits mit Scope `quick-260914-m97`, gepusht, CI-Lauf `success` beobachtet und Abbild-Proben notiert. - API 67/1076, Web 43/260, tsc dreimal 0, genau 35 Dateien ausserhalb `.planning`, vier Commits (Task 2 in zwei Teilen 2a/2b) mit Scope `quick-260914-m97`, gepusht, CI-Lauf `success` beobachtet und Abbild-Proben notiert.
</success_criteria> </success_criteria>
<output> <output>