Files

96 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260914-m97 01 execute 1
true
QUICK-260914-M97
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql
apps/api/src/settings/settings.service.ts
apps/api/src/settings/settings.service.spec.ts
apps/api/src/settings/dto/smtp-config.dto.ts
apps/api/src/mail/mail.service.ts
apps/api/src/mail/mail.service.spec.ts
apps/api/src/bug-reports/bug-reports.module.ts
apps/api/src/bug-reports/bug-reports.controller.ts
apps/api/src/bug-reports/bug-reports.service.ts
apps/api/src/bug-reports/dto/bug-report.dto.ts
apps/api/src/bug-reports/bug-reports.service.spec.ts
apps/api/src/bug-reports/bug-reports.controller.spec.ts
apps/api/src/app.module.ts
docs/mandantentrennung-zugriffsklassifikation.md
docker-compose.prod.yml
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
docs/anleitung-administration.md
docs/anleitung-anwender.md
docs/anleitung-betrieb.md
tokens raw_tokens tasks confidence
150000 150000 3 low
truths artifacts key_links
Jeder angemeldete Anwender sieht in der Kopfzeile rechts, VOR dem Erscheinungsbild-Schalter, einen Symbol-Knopf mit `aria-label`/`title` „Fehler melden“ (gleicher Stil wie `ThemeToggle`). Ein Klick nimmt SOFORT ein Bild der aktuellen Seite auf (html-to-image `toPng(document.body, ...)`, laengste Kante hoechstens 1600 px, `pixelRatio: 1`, `skipFonts: true`) — zu diesem Zeitpunkt existiert im DOM noch KEIN `role="dialog"` (Komponententest pinnt das im Mock von `toPng`). Erst danach oeffnet sich der Dialog mit Bildvorschau (`<img src="data:image/png;base64,...">`, `max-h-48`), Haekchen „Bildschirmfoto beifügen“ (vorbelegt an), optionalem Feld „Was ist passiert?“ (maxLength 4000) und den Knoepfen „Abbrechen“/„Senden“; Escape schliesst, der Fokus liegt im Dialog. Schlaegt die Aufnahme fehl, oeffnet sich der Dialog trotzdem, mit dem Hinweis „Kein Bildschirmfoto möglich“ und abgeschaltetem Haekchen.
„Senden“ schickt `multipart/form-data` an `POST /bug-reports` (mit Cookie, `credentials: 'include'`): Felder `description`, `page` (Pfad + Suchteil, ohne Host), `webVersion`/`webChannel`/`webCommit` (aus `apps/web/src/lib/app-version.ts`, 260914-ku1), `userAgent`, `viewport` (`<Breite>x<Hoehe>`), `clientTime` (ISO), wiederholtes Feld `errors` (je Eintrag `[<ISO-Zeit>] <Art>: <Meldung>`, hoechstens 20 aus dem Ringpuffer) und — nur bei gesetztem Haekchen — die Datei `screenshot` (PNG-Blob). Erfolg zeigt „Vielen Dank, die Meldung wurde gesendet.“; Fehler zeigen je Statuscode eine deutsche/englische Meldung: 409 „kein Postfach eingerichtet“ (fuer ADMIN/SUPER_ADMIN zusaetzlich der Hinweis mit Link auf `/admin/smtp`, Feld „Fehlermeldungen an“), 429 „zu viele Meldungen“, 413 „Bild zu gross“, 502 „E-Mail konnte nicht gesendet werden“, sonst allgemein. Der Anwender erfaehrt IMMER, ob sein Bericht ankam (kein stilles Verschlucken wie beim Kennwort-Reset).
Die API-Route `POST /bug-reports` (Modul `apps/api/src/bug-reports/`) steht JEDEM angemeldeten Benutzer offen (kein `@Roles`, globaler JwtAuthGuard), nimmt das Bild per `FileInterceptor('screenshot', { limits: { fileSize: 4 MiB, files: 1 } })` entgegen (multer 2.1.1 ueber `@nestjs/platform-express` — dasselbe Muster wie der Avatar-Upload in `user.controller.ts`; `main.ts` bleibt UNVERAENDERT, kein globales Body-Limit), nimmt Mandant und Benutzer AUSSCHLIESSLICH aus `@CurrentUser()`, prueft die PNG-Signatur `89 50 4E 47 0D 0A 1A 0A` (sonst 400), drosselt auf 5 Berichte je Benutzer je 10 Minuten (sechster -> 429), begrenzt im DTO (`description` <= 4000, `errors` <= 30 Eintraege je <= 1000 Zeichen, `page` <= 2000, `userAgent` <= 1000) und antwortet 409 mit klarer deutscher Meldung, wenn weder `SmtpConfig.bugReportRecipient` des Sitzungs-Mandanten noch `TESSERA_BUGREPORT_TO` gesetzt ist. Versandfehler -> 502 „E-Mail konnte nicht gesendet werden“. Kein Speichern in der Datenbank; EINE Protokollzeile (Benutzer, Mandant, Seite, Empfaenger, Bildgroesse in Bytes — nie Bild, nie Beschreibung).
Die E-Mail geht ueber den Transport des Sitzungs-Mandanten (`MailService.resolveTransport`, 260914-eym) an den eingestellten Empfaenger: Betreff `[Tessera Fehlermeldung] <webVersion> <webChannel> - <page>`, Text-Rumpf in Alltagssprache mit Beschreibung, Seite, Server- und Browserzeit, Benutzer (Anzeigename, Benutzername, Rolle, E-Mail aus der gebundenen Datenbankzeile — nicht aus dem Rumpf), Mandant, Web-Version/Kanal/Commit, API-Version (`formatAppVersionLine()` aus `apps/api/src/health/app-version.ts`), Browser, Fenstergroesse, Liste der letzten Fehlermeldungen, Hinweis auf den Anhang; Anhang `fehlermeldung-<yyyymmdd-hhmm>.png` (`contentType: image/png`, Inhalt = der hochgeladene Buffer). Spec pinnt die `sendMail`-Argumente mit einem echten 1x1-PNG-Buffer bei gemocktem nodemailer.
Administratoren stellen den Empfaenger unter **Administrator -> SMTP** im neuen Feld „Fehlermeldungen an“ (optional, `type="email"`, Hinweistext) ein; `PUT /settings/smtp` validiert es per `@IsOptional() @IsEmail()` (`null` loescht, fehlendes Feld bewahrt den gespeicherten Wert), `GET /settings/smtp` liefert es ueber `SMTP_SAFE_SELECT`. Spalte `SmtpConfig.bugReportRecipient String?` per neuer additiver Migration `20260914170000_smtp_config_bug_report_recipient` (lokal per `apps/api/node_modules/.bin/prisma migrate deploy` gegen `tessera-ctl-db-1` eingespielt, `migrate status` „up to date“, `migrate diff` leer). Rueckfall-Variable `TESSERA_BUGREPORT_TO` in `docker-compose.prod.yml` als `${TESSERA_BUGREPORT_TO:-}` durchgereicht; Leerstring zaehlt wie ungesetzt.
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.
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 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.
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/src/settings/settings.service.ts — `SMTP_SAFE_SELECT` um `bugReportRecipient: true`; `saveSmtpConfig` schreibt das Feld nur, wenn es im DTO vorhanden ist (`null` -> NULL); NEU `getBugReportRecipient(tenantId): Promise<string | null>` (EIN gebundener Klient, `findUnique` mit `select: { bugReportRecipient: true }`)
apps/api/src/settings/dto/smtp-config.dto.ts — `@IsOptional() @IsEmail() bugReportRecipient?: string | null`
apps/api/src/mail/mail.service.ts — Typ `OutgoingMail { to, subject, text, html?, attachments? }` mit nodemailer-Anhangsform `{ filename, content: Buffer, contentType }`; privater Kern `deliver(tenantId, mail, kind)` WIRFT; `sendViaTenantTransport` bleibt der verschluckende Mantel um `deliver` (Verhalten fuer Kennwort-Reset/Willkommen identisch, bestehende 4 Tests gruen); NEU `sendBugReport(tenantId, to, report: { subject, text, attachments })` ruft `deliver` direkt und laesst Fehler durch
apps/api/src/bug-reports/bug-reports.module.ts — importiert `SettingsModule` und `MailModule` (PrismaModule ist `@Global()`); Controller + Service; in `app.module.ts` hinter `TendersModule` eingetragen
apps/api/src/bug-reports/dto/bug-report.dto.ts — `BugReportDto` mit class-validator-Grenzen und `@Transform` (class-transformer, Vorlage `tenders/dto/tender-query.dto.ts`) fuer `errors` (undefined -> [], Einzelwert -> [Einzelwert], Array -> Array); KEIN Mandanten- oder Benutzerfeld
apps/api/src/bug-reports/bug-reports.controller.ts — `@Controller('bug-reports')`, `@Post()` ohne `@Roles`, `@UseInterceptors(FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }))`, Parameter `@CurrentUser() user`, `@Body() dto: BugReportDto`, `@UploadedFile() file`; Rueckgabe `{ sent: true }`
apps/api/src/bug-reports/bug-reports.service.ts — `submit(user, dto, file)`: Drossel (`Map<userId, number[]>`, Fenster 600000 ms, max 5, `HttpException(..., HttpStatus.TOO_MANY_REQUESTS)`), Empfaenger (`getBugReportRecipient(user.tenantId)` sonst `ConfigService.get('TESSERA_BUGREPORT_TO')` mit `||`, sonst `ConflictException`), PNG-Signatur (`Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a])`, `BadRequestException`), Benutzerzeile ueber `const tenantPrisma = forTenant(this.prisma, user.tenantId) as any` + `user.findUnique({ where: { id: user.id }, select: { username, displayName, email, role } })` (null -> Sitzungswerte), Betreff/Text/Anhang bauen, `mailService.sendBugReport` (Fehler -> `BadGatewayException('E-Mail konnte nicht gesendet werden')`), eine Logger-Zeile
apps/api/src/bug-reports/bug-reports.service.spec.ts — NEU, 8 Tests (Happy Path mit echtem 1x1-PNG, ohne Bild, Drossel a, PNG b, Empfaenger c inkl. Leerstring-Variable, Umgebungs-Rueckfall, 502, Mandant aus Sitzung d)
apps/api/src/bug-reports/bug-reports.controller.spec.ts — NEU, 3 Tests (Pipe: whitelist entfernt Fremdfeld + `errors`-Normalisierung; Pipe: Grenzen 31 Eintraege / 4001 Zeichen -> BadRequestException; kein `ROLES_KEY`-Metadatum auf `submit`)
docs/mandantentrennung-zugriffsklassifikation.md — neue Zeile `| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | ... |` in der Bestandsaufnahme-Tabelle (Pflicht: `rls-access-inventory.spec.ts` prueft jede (Datei, Modell)-Fundstelle) und eine Zeile `bug-reports | 0 | 1 | 0` in der Bereichs-Tabelle
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/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 — `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-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, 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/components/layout/header.tsx — `<BugReportButton />` unmittelbar VOR `<ThemeToggle />` (Zeile 112)
apps/web/src/components/layout/app-shell.tsx — `useEffect(() => { installErrorBuffer(); }, [])`
apps/web/src/lib/settings-api.ts — `bugReportRecipient: string | null` in `SmtpConfig`, `bugReportRecipient?: string | null` in `SaveSmtpPayload`
apps/web/src/components/settings/smtp-settings-form.tsx — Feld „Fehlermeldungen an“ (`id="smtp-bug-report-recipient"`, `type="email"`, optional, Hinweistext) hinter der Absenderadresse; Payload traegt `bugReportRecipient: form.bugReportRecipient.trim() || null`
apps/web/src/components/settings/smtp-settings-form.test.tsx — NEU, 2 Tests (Feld vorbelegt aus GET; PUT-Payload traegt Wert bzw. `null`)
apps/web/src/messages/de.json + en.json — Namensraum `bugReport` (Knopf, Dialog, Meldungen) und `settings.smtp.bugReportRecipient` / `bugReportRecipientHelp`, in beiden Dateien an derselben Stelle; `umlaut-dictionary.ts` `UMLAUT_ALLOWLIST` um die vom Waechter genannten korrekten Woerter (erwartet mindestens `passiert`)
docs/anleitung-anwender.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md — Abschnitte wie in den truths
Reihenfolge im Knopf: `captureScreenshot()` MUSS abgeschlossen sein, bevor `open` auf true geht — sonst ist der Dialog im Bild. Der Test pinnt das, indem der `toPng`-Mock waehrend seines Aufrufs `screen.queryByRole('dialog')` auf `null` prueft.
Multipart statt JSON+Base64: `main.ts` bleibt unangetastet, das Limit gilt nur fuer diese Route (`fileSize` -> multer `LIMIT_FILE_SIZE` -> Nest `PayloadTooLargeException` 413, gemessen in `platform-express/multer/multer.utils.js`). Gemessen ebenfalls: `app.useBodyParser('json', { limit })` wuerde in Nest 11 + Express 5 funktionieren (`registerParserMiddleware` ueberspringt einen bereits registrierten `jsonParser`), ist aber eine globale DoS-Flaeche fuer JEDE JSON-Route inkl. `/auth/login` — deshalb verworfen.
multer + `append-field` (gemessen): ein einzelnes Feld `errors` kommt als STRING, zwei oder mehr als Array, keins als undefined — deshalb `@Transform` im DTO, sonst faellt `@IsArray()` bei genau einer Fehlermeldung. Der Controller-Spec pinnt die Normalisierung ueber `new ValidationPipe({ whitelist: true, transform: true }).transform(...)`.
Der Empfaenger lebt in `SmtpConfig` — ohne gespeicherte SMTP-Einstellungen gibt es das Feld nicht (dann greift NUR `TESSERA_BUGREPORT_TO` zusammen mit der Umgebungs-SMTP-Kette). Das ist Absicht: ohne Transport gibt es ohnehin keine E-Mail.
`rls-access-inventory.spec.ts` scheitert, sobald `bug-reports.service.ts` auf `user` zugreift und die Doku-Zeile fehlt; die Zuweisungsform `const tenantPrisma = forTenant(` ist Pflicht (Erkennungsform 2).
`umlaut-guard.spec.ts` flaggt in de.json jedes Wort mit `ae/oe/ue/ss` ausserhalb `UMLAUT_ALLOWLIST` — „Was ist passiert?“ (Pflichtlabel aus dem Auftrag) enthaelt `ss`; die Meldung des Tests nennt den Fix (Allowlist).
`html-to-image` 1.11.13 rendert ueber SVG `foreignObject` (der Browser rastert selbst) — deshalb funktionieren OKLCH-Farben von Tailwind 4, an denen `html2canvas` scheitert; kein Webfont im Projekt (`globals.css` nennt „Inter“ nur als System-Schriftfamilie, keine `@font-face`), deshalb `skipFonts: true` ohne sichtbaren Unterschied.
Lokaler Mailserver EXISTIERT: `docker-compose.dev.yml` fuehrt `mailhog` (Ports 1025/8025, Abbild `mailhog/mailhog:latest` liegt lokal vor) — der Auftrag nahm an, es gaebe keinen. Der Human-Check kann die echte E-Mail mit PNG-Anhang unter `http://localhost:8025` sehen.
Fehler-melden-Knopf in der Kopfzeile: Ein Klick nimmt SOFORT ein Bild der aktuellen Seite auf (bevor ein Dialog darueberliegt), dann oeffnet sich ein kleiner Dialog mit Vorschau, optionalem Feld „Was ist passiert?", Haekchen „Bildschirmfoto beifügen" (an) und „Senden". Senden schickt Bild, Beschreibung, Seite, Web-/API-Version mit Kanal und Commit, Browser, Fenstergroesse, Zeitpunkt, angemeldeten Benutzer und die letzten Fehlermeldungen des Browsers als E-Mail mit PNG-Anhang an ein Postfach, das der Administrator unter Administrator -> SMTP im neuen Feld „Fehlermeldungen an" einstellt (Rueckfall: `TESSERA_BUGREPORT_TO`).

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), 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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @.planning/quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/260914-ku1-SUMMARY.md @apps/web/src/components/layout/header.tsx @apps/web/src/components/theme-toggle.tsx @apps/web/src/components/layout/app-shell.tsx @apps/web/src/lib/app-version.ts @apps/web/src/lib/settings-api.ts @apps/web/src/components/settings/smtp-settings-form.tsx @apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx @apps/web/src/components/layout/sidebar.test.tsx @apps/web/src/messages/umlaut-guard.spec.ts @apps/api/src/mail/mail.service.ts @apps/api/src/mail/mail.service.spec.ts @apps/api/src/settings/settings.service.ts @apps/api/src/settings/settings.service.spec.ts @apps/api/src/settings/dto/smtp-config.dto.ts @apps/api/src/user/user.controller.ts @apps/api/src/tenders/dto/tender-query.dto.ts @apps/api/src/health/app-version.ts @apps/api/prisma/migrations/20260909120000_user_email_optional/migration.sql @apps/api/src/prisma/rls-access-inventory.spec.ts @docs/anleitung-anwender.md @docs/anleitung-administration.md @docs/anleitung-betrieb.md

<planning_measurements> Zur Planungszeit (2026-09-14, HEAD 5c42c55, Arbeitsbaum sauber, main == origin/main) gemessen — die Ausfuehrung misst erneut; diese Zahlen sind der Bezugspunkt der Gates:

  • Baseline frisch nachgemessen: API Test Files 65 passed (65) / Tests 1060 passed (1060); Web Test Files 40 passed (40) / Tests 243 passed (243); tsc --noEmit in packages/shared, apps/api, apps/web je Exit 0. Lokal laeuft nur tessera-ctl-db-1 (IP 172.19.0.2); prisma migrate status mit postgresql://tessera:tessera_dev@172.19.0.2:5432/tessera -> „36 migrations found … Database schema is up to date!"; prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script -> -- This is an empty migration. (Prisma 6.19.3). Kein Web-/API-Prozess auf 3000/3001. Ein CI-Lauf (Task 691, Build & Publish) lief gerade fuer 5c42c55.
  • Bibliothek: pnpm view html-to-image version -> 1.11.13 (dist-tag latest, veroeffentlicht 2025-02-14, MIT, dependencies: {}, peerDependencies: {}, lib/index.d.ts, Repo github.com/bubkoo/html-to-image, 4.822.813 Downloads in der Woche 2026-09-05..11 laut api.npmjs.org). Optionen in types.d.ts bestaetigt: pixelRatio, canvasWidth, canvasHeight, skipFonts, cacheBust, filter, fetchRequestInit. Skalierung bestaetigt in lib/index.js 88-102: canvas.width = canvasWidth * ratio, drawImage(img, 0, 0, canvas.width, canvas.height) — canvasWidth/canvasHeight skalieren das Bild. util.js nutzt foreignObject. Wegwerf-Skript im Scratchpad (h2i/, jsdom 29.1.1 des Projekts): toPng scheitert in jsdom (HTMLVideoElement is not defined, danach Element is not defined; ohne Canvas-Backend ohnehin kein Rastern) -> im Komponententest ist vi.mock('html-to-image') Pflicht, der Bildbeweis kommt aus dem Browser (Human-Check).
  • Body-Parser-Entscheidung (gemessen, nicht angenommen): FileInterceptor + multer 2.1.1 sind ueber @nestjs/platform-express@11.1.27 installiert und in user.controller.ts (Avatar, limits.fileSize) produktiv — Multipart ist der bewaehrte Weg fuer Binaerdaten mit Limit je Route. platform-express/multer/multer.utils.js bildet LIMIT_FILE_SIZE auf PayloadTooLargeException (413) ab. append-field@1.0.0 (multer): errors einmal -> \"a\" (String), dreimal -> [\"a\",\"b\",\"c\"]. Alternative gemessen: app.useBodyParser('json', { limit: '8mb' }) funktioniert in Nest 11/Express 5 ohne bodyParser: false (NestApplication.useBodyParser -> ExpressAdapter.useBodyParser -> this.use(express.json(...)); registerParserMiddleware filtert per isMiddlewareApplied('jsonParser'), und express.json() heisst tatsaechlich jsonParser) — aber global fuer jede JSON-Route inkl. der oeffentlichen /auth/login. Entscheidung: Multipart, main.ts unangetastet.
  • Nest-Ausnahmen: es gibt KEINE TooManyRequestsException in @nestjs/common -> new HttpException('...', HttpStatus.TOO_MANY_REQUESTS). Keine Drossel-Bibliothek im Projekt (@nestjs/throttler nicht installiert) -> In-Memory-Map im Dienst.
  • @CurrentUser() liefert { id, username, role, tenantId } (jwt.strategy.ts 27-33) — KEIN Anzeigename, keine E-Mail. Deshalb eine gebundene user.findUnique-Zeile im Dienst (User hat username, email String?, displayName String?, role). Folge: rls-access-inventory.spec.ts (Erkennung 2: const <Name> = forTenant(; Tabellenzeilen-Muster | apps/api/src/… | modell | klasse | stand |) verlangt eine neue Zeile in docs/mandantentrennung-zugriffsklassifikation.md, sonst rot. PrismaModule ist @Global().
  • Muster „nur angemeldet": user.controller.ts 273-275 — kein @Roles(), RolesGuard.canActivate() liefert true bei leerer Rollenliste, JwtAuthGuard global (app.module.ts 52-56). Roles-Metadatum ueber ROLES_KEY aus auth/decorators/roles.decorator.
  • MailService.sendViaTenantTransport verschluckt Fehler bewusst (T-02-12); fuer den Bericht darf das nicht gelten -> Kern deliver wirft, der Mantel bleibt. mail.service.spec.ts mockt nodemailer (createTransport -> { sendMail, close }), 4 Tests, mockSendMail je Test neu.
  • settings.service.spec.ts: Fake-Prisma mit __makeBoundClient(tenantId), gebundener Klient bietet nur findUnique/upsert (mit applySelect), Test „genau EIN gebundener Klient je Aufruf" (Zeile 433) — getBugReportRecipient haelt das. Test Zeile 206 prueft, dass update KEINEN Schluessel encryptedPassword traegt, wenn kein Kennwort kam — dieselbe Form (bedingtes Spreading) fuer bugReportRecipient. settings.controller.ts braucht KEINE Aenderung (DTO + SAFE_SELECT tragen das Feld).
  • SMTP-Formular liegt unter /admin/smtp (app/(portal)/admin/smtp/page.tsx, Header-Dropdown „Administrator" -> „SMTP", Handbuch „Administrator -> SMTP") — NICHT „Einstellungen -> E-Mail" wie im Auftrag; der Plan folgt dem Bestand. smtp-settings-form.tsx hat keinen Test.
  • Web: auth-actions.ts und module-access-actions.ts sind Server Actions ('use server', laufen auf dem Next-Server) — ein Browser-fetch-Wrapper sieht sie NICHT; alle uebrigen 28 Dateien mit fetch( rufen ${NEXT_PUBLIC_API_URL}/… direkt aus dem Browser (credentials: 'include') -> der Wrapper in error-buffer.ts erfasst genau diese. Kein Test rendert Header oder AppShell (kein Mock-Nachziehen noetig). Avatar-Bild /api-proxy/users/me/avatar ist same-origin.
  • Dialog-Muster im Bestand: marketplace/components/ActivationDialog.tsx (fixed inset-0 z-50 … bg-black/50, role=\"dialog\" aria-modal=\"true\", Escape -> onCancel, Fokus auf ersten Knopf, Tab-Falle). Uebersetzungs-Mock: sidebar.test.tsx 16-41. useAuthStore ist ein Zustand-Store (useAuthStore((s) => s.user) moeglich); Rollen SUPER_ADMIN | ADMIN | USER.
  • i18n: de.json/en.json je 963 Zeilen, Namensraeume common, auth, header, sidebar, dashboard, settings, widgets, admin, adminModules, theme, locale, modules, …; settings.smtp traegt heute 17 Schluessel (title … testFailed). umlaut-guard.spec.ts: Tokenizer /[A-Za-zÄÖÜäöüß]+/g, SUSPECT_RE = /(ae|oe|ue|ss)/i, UMLAUT_ALLOWLIST (139 Eintraege, u. a. Adresse, muss, lassen, aktuelle, erfasst) — NICHT enthalten: passiert, dass, wissen, Klasse, Prozess, Ausschnitt. Werte mit @ werden uebersprungen. tenderRadar-parity.spec.ts prueft nur tenderRadar.
  • Handbuecher: anleitung-anwender.md 166 Zeilen (63 mit Umlauten; Kopfleiste Zeilen 41-48 „zwei Bedienelemente"; Abschnitte „Persönliche Einstellungen" ab 141, „Häufige Stolpersteine" ab 158; Inhaltsverzeichnis 6-20); anleitung-administration.md 240 Zeilen (96 mit Umlauten; Kapitel 6 SMTP Zeilen 202-213, Fehlersuche-Tabelle ab 227, letzte Zeile 240); anleitung-betrieb.md 521 Zeilen (Konfigurationstabelle Kapitel 3 Zeilen 150-162, SMTP-Zeile 160, Kapitel 9 ab 355 mit dem Muster „Serverdatei von Hand ergaenzen").
  • Compose: docker-compose.prod.yml api.environment Zeilen 36-59 (TESSERA_SMTP_FROM Zeile 49). docker-compose.dev.yml fuehrt mailhog (Ports 1025/8025, backend-net); Abbild mailhog/mailhog:latest lokal vorhanden; docker compose -f docker-compose.yml -f docker-compose.dev.yml config --services -> phpldapadmin db api web mailhog openldap. Lokale Abbilder tessera-ctl-api:latest/tessera-ctl-web:latest vorhanden (Cache-Waerme fuer docker compose up -d --build api web).
  • Detektoren: api-coverage -> {\"detected\":false} (kein externer Dienst — nodemailer und html-to-image sind Bibliotheken); assumption-delta scan quick-260914-m97 -> {\"skipped\":true,\"reason\":\"phase_unresolved\"} (Quick-Task ohne ROADMAP-Abschnitt; inhaltlich keine Einzahl-/Mehrzahl-Verschiebung — ein Empfaenger je Mandant, wie eine SMTP-Konfiguration je Mandant); schema-gate FEUERT (schema.prisma + neue Migration) -> [BLOCKING]-Schritt migrate deploy in Task 1. Konfiguration: tdd_mode=false (Task 1 und 2 tragen trotzdem tdd=\"true\"), security_enforcement=true, ASVS 1, Blocking-Schwelle high, human_verify_mode=end-of-phase, branching_strategy: none (Commits auf main, wie 260914-ku1).
  • Paketlegitimitaet (kein RESEARCH.md im Quick-Modus, deshalb hier): html-to-image@1.11.13 — Registry-Metadaten wie oben, 4,8 Mio. Wochen-Downloads, GitHub bubkoo/html-to-image, ein Maintainer, keine Abhaengigkeiten -> [VERIFIED] durch Registry-Nachweis zur Planungszeit; kein blockierender Mensch-Checkpoint noetig (Auftrag: kein Nachfragen). T-M97-SC im Threat-Register.
  • Biome ist im Bestand nicht lauffaehig (WINDOWS #35) — kein Biome-Gate; biome.json unangetastet. </planning_measurements>

<package_legitimacy_audit>

Paket Version Quelle Nachweis Einstufung
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>

<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>
Task 1: API — Empfaenger-Spalte mit Migration, MailService mit Anhaengen, Modul bug-reports (Multipart, Drossel, PNG-Pruefung, Mandant aus der Sitzung) mit Specs und Falsifizierungen (a)-(d) apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql, apps/api/src/settings/settings.service.ts, apps/api/src/settings/settings.service.spec.ts, apps/api/src/settings/dto/smtp-config.dto.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/bug-reports/bug-reports.module.ts, apps/api/src/bug-reports/bug-reports.controller.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/bug-reports/dto/bug-report.dto.ts, apps/api/src/bug-reports/bug-reports.service.spec.ts, apps/api/src/bug-reports/bug-reports.controller.spec.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, docker-compose.prod.yml `docker ps --format '{{.Names}}' | grep -c '^tessera-ctl-db-1$'` liefert `1`, und `cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1):5432/tessera" ./node_modules/.bin/prisma migrate status` endet mit `Database schema is up to date!` (sonst zuerst die lokale Datenbank in Ordnung bringen — ohne sie ist der [BLOCKING]-Schritt nicht ausfuehrbar). `apps/api/src/settings/settings.service.spec.ts` (+3, im Stil der Datei, `FakeSmtpRow` bekommt `bugReportRecipient?: string | null`): - Test A (`getBugReportRecipient`): Zeile fuer `t1` mit `bugReportRecipient: 'fehler@a.example.invalid'` -> `await service.getBugReportRecipient('t1')` liefert genau diese Zeichenkette; `expectBoundCall(prisma, 't1', 'findUnique')`; fuer `t2` (keine Zeile) `null`; Zeile mit `bugReportRecipient: null` -> `null`. - Test B (`saveSmtpConfig` mit Feld): DTO mit `bugReportRecipient: 'fehler@a.example.invalid'` -> die gespeicherte Zeile (`prisma.__configs.get('t1')`) traegt den Wert; Rueckgabe (SAFE_SELECT) enthaelt `bugReportRecipient` und KEIN `encryptedPassword`. - Test C (`saveSmtpConfig` ohne Feld bewahrt, `null` loescht): erst mit Wert speichern, dann DTO OHNE `bugReportRecipient` -> Wert bleibt; dann DTO mit `bugReportRecipient: null` -> Wert ist `null`. `apps/api/src/mail/mail.service.spec.ts` (+2): - Test 5 (`sendBugReport` reicht Anhaenge durch): `sendBugReport('t1', 'fehler@a.example.invalid', { subject: 'S', text: 'T', attachments: [{ filename: 'x.png', content: Buffer.from([1,2,3]), contentType: 'image/png' }] })` -> `mockSendMail` genau einmal mit `to`, `subject`, `text` und `attachments[0]` (`filename`, `contentType`, `content` per `Buffer.equals`), `from` = fromAddress von configA, `mockClose` gerufen. - Test 6 (Fehler werden NICHT verschluckt): `mockSendMail` lehnt ab -> `await expect(service.sendBugReport(...)).rejects.toThrow()`, `mockClose` trotzdem gerufen; Gegenprobe im selben Test: `sendPasswordResetEmail` mit demselben ablehnenden Mock loest NICHT aus (T-02-12 unveraendert). `apps/api/src/bug-reports/bug-reports.service.spec.ts` (NEU, 8 Tests; `vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma, tenantId) => prisma.__makeBoundClient(tenantId)) }))` wie in `user.controller.spec.ts`; Fake-Prisma mit `user.findUnique` je gebundenem Klient, das nur Zeilen des eigenen Mandanten liefert; Attrappen `settingsService.getBugReportRecipient`, `mailService.sendBugReport`, `configService.get`; `vi.stubEnv('APP_VERSION', 'v9.9.9')` + `APP_CHANNEL=live` fuer die API-Zeile; Konstante `PNG_1x1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64')`; Sitzungsbenutzer `{ id: 'u1', username: 'anna', role: 'USER', tenantId: 't1' }`; Basis-DTO `{ page: '/admin/users?tab=x', description: 'Knopf tut nichts', webVersion: 'v1.2.3', webChannel: 'beta', webCommit: 'abc1234', userAgent: 'UA', viewport: '1920x1080', clientTime: '2026-09-14T10:00:00.000Z', errors: ['[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}'] }`): - Test 1 (Happy Path mit Bild): Empfaenger aus Settings -> `sendBugReport` genau einmal mit `('t1', 'fehler@a.example.invalid', report)`; `report.subject === '[Tessera Fehlermeldung] v1.2.3 beta - /admin/users?tab=x'`; `report.text` enthaelt `Knopf tut nichts`, `/admin/users?tab=x`, `Anna Muster (anna)` (displayName aus der Fake-Zeile), `USER`, `anna@a.example.invalid`, `t1`, `v1.2.3 (beta) abc1234`, `Tessera API v9.9.9 (live)`, `UA`, `1920x1080`, die Fehlerzeile und `Bildschirmfoto: im Anhang`; `report.attachments` hat genau einen Eintrag mit `filename` passend zu `/^fehlermeldung-\d{8}-\d{4}\.png$/`, `contentType: 'image/png'`, `content.equals(PNG_1x1)`; Rueckgabe `{ sent: true }`. - Test 2 (ohne Bild): `file` undefined -> `attachments` ist leer (Array der Laenge 0) und der Text enthaelt `Bildschirmfoto: nicht beigefügt`; ohne `description` steht `(keine Beschreibung)`. - Test 3 (Falsifizierung a, Drossel): `vi.useFakeTimers()`; fuenf Aufrufe fuer `u1` gelingen, der sechste wirft `HttpException` mit `getStatus() === 429` und `sendBugReport` wurde genau fuenfmal gerufen; ein anderer Benutzer `u2` im selben Moment gelingt; `vi.advanceTimersByTime(600001)` -> `u1` gelingt wieder; `vi.useRealTimers()` im `finally`. - Test 4 (Falsifizierung b, PNG-Signatur): `file = { buffer: Buffer.from('nicht png, aber lang genug'), size: 25, mimetype: 'image/png' }` -> `BadRequestException`, `sendBugReport` NICHT gerufen; auch ein Buffer aus den ersten 7 PNG-Bytes -> 400. - Test 5 (Falsifizierung c, kein Empfaenger): Settings liefert `null`, `configService.get('TESSERA_BUGREPORT_TO')` liefert `undefined` -> `ConflictException`; zweiter Fall im selben Test mit Leerstring `''` -> ebenfalls `ConflictException`; `sendBugReport` in beiden Faellen NICHT gerufen; die Meldung enthaelt `Fehlermeldungen an`. - Test 6 (Umgebungs-Rueckfall): Settings `null`, Variable `'ops@a.example.invalid'` -> `sendBugReport` mit `to === 'ops@a.example.invalid'`; Settings mit Wert UND Variable gesetzt -> das Feld gewinnt. - Test 7 (Versandfehler sichtbar): `sendBugReport` lehnt mit `new Error('ECONNREFUSED')` ab -> `BadGatewayException` mit Meldung `E-Mail konnte nicht gesendet werden`; der Drossel-Zaehler zaehlt den Versuch trotzdem (zweiter Aufruf danach: `sendBugReport` erneut gerufen — kein Sperren durch Fehlversuche verlangt, nur Zaehlen). - Test 8 (Falsifizierung d, Mandant aus der Sitzung): DTO-Objekt zusaetzlich mit `tenantId: 'fremd'` und `userId: 'u-fremd'` (als `any`) -> `getBugReportRecipient` mit `'t1'`, `forTenant` mit `('…', 't1')`, `sendBugReport` mit erstem Argument `'t1'`; die Fake-Zeile fuer `u1` liegt unter `t1`, unter `fremd` liegt eine Zeile mit anderem Anzeigenamen, die im Text NICHT auftaucht. `apps/api/src/bug-reports/bug-reports.controller.spec.ts` (NEU, 3 Tests; `import 'reflect-metadata'`; `ValidationPipe` aus `@nestjs/common`, `ROLES_KEY` aus `../auth/decorators/roles.decorator`): - Test 1 (Pipe: whitelist + errors-Normalisierung): `new ValidationPipe({ whitelist: true, transform: true }).transform({ page: '/x', webVersion: 'v1', webChannel: 'beta', webCommit: '', userAgent: 'UA', viewport: '1x1', clientTime: 't', errors: 'einzeln', tenantId: 'fremd' }, { type: 'body', metatype: BugReportDto })` -> Ergebnis hat KEINE Eigenschaft `tenantId`, `errors` ist `['einzeln']`; ohne `errors` -> `[]`; mit Array bleibt Array. - Test 2 (Pipe: Grenzen): 31 Eintraege in `errors` -> `rejects.toThrow(BadRequestException)`; `description` mit 4001 Zeichen -> BadRequestException; 30 Eintraege und 4000 Zeichen -> gelingt. - Test 3 (nur angemeldet): `Reflect.getMetadata(ROLES_KEY, BugReportsController.prototype.submit)` ist `undefined` (kein `@Roles`), und `Reflect.getMetadata('path', BugReportsController) === 'bug-reports'`. Schritt A — RED: die drei neuen/erweiterten Spec-Dateien aus `` anlegen bzw. ergaenzen (Kopfkommentar deutsch ASCII mit Bezug quick-260914-m97, Testnamen deutsch), BEVOR Produktionscode entsteht. `pnpm -C apps/api exec vitest run src/bug-reports src/mail/mail.service.spec.ts src/settings/settings.service.spec.ts` muss rot sein (Modul nicht gefunden bzw. Erwartungen verfehlt) — die Ausgabezeilen ins SUMMARY.
Schritt B — Schema und Migration (danach [BLOCKING]):
1. `schema.prisma`, `model SmtpConfig`: hinter `fromAddress` die Zeile `bugReportRecipient String?` mit Zeilenkommentar (Postfach fuer den Fehler-melden-Knopf, quick-260914-m97; leer = Rueckfall `TESSERA_BUGREPORT_TO`).
2. Neuer Ordner `apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/` mit `migration.sql`: Kopfkommentar in der ASCII-Form von `20260909120000_user_email_optional` (Anlass: Fehler-melden-Knopf; warum in `SmtpConfig` und nicht in einer eigenen Tabelle — der Empfaenger gehoert zum Mailversand des Mandanten, `tenant_isolation_policy` aus 20260909140000 gilt automatisch, keine Systemleseregel noetig, weil die Route mit angemeldetem Benutzer laeuft; additiv, nullable, Bestandszeilen unangetastet; keine bestehende Migration angefasst), dann genau `ALTER TABLE "SmtpConfig" ADD COLUMN "bugReportRecipient" TEXT;`.
3. **[BLOCKING] Schema-Push:** `cd apps/api && DBIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate deploy` (NICHT `npx prisma`, NICHT `db push`); danach `migrate status` -> `Database schema is up to date!` und `migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script` -> `-- This is an empty migration.`; dann `./node_modules/.bin/prisma generate` (sonst kennt `tsc` das Feld nicht). Alle drei Ausgaben ins SUMMARY. `DATABASE_URL` wird NUR in dieser Shell-Zeile gesetzt — keine `.env`-Datei anfassen.

Schritt C — Settings:
4. `smtp-config.dto.ts`: `@IsOptional() @IsEmail() bugReportRecipient?: string | null;` mit Kommentar (optional; `null` loescht; Absicht: nur ein Administrator kann das Ziel setzen — T-M97-05).
5. `settings.service.ts`: `SMTP_SAFE_SELECT` um `bugReportRecipient: true`; in `saveSmtpConfig` das `data`-Objekt um `...(dto.bugReportRecipient !== undefined ? { bugReportRecipient: dto.bugReportRecipient || null } : {})` (fehlend = bewahren, `null`/leer = loeschen); NEUE Methode `getBugReportRecipient(tenantId: string): Promise<string | null>` mit `const tenantPrisma = forTenant(this.prisma, tenantId) as any;` und `findUnique({ where: { tenantId }, select: { bugReportRecipient: true } })` -> `row?.bugReportRecipient ?? null`; JSDoc: Mandantengebunden, ein Klient, Verwender `BugReportsService`.

Schritt D — MailService:
6. `mail.service.ts`: exportierten Typ `OutgoingMail` (`to`, `subject`, `text`, optional `html`, optional `attachments: { filename: string; content: Buffer; contentType: string }[]`) und `BugReportMail = Pick<OutgoingMail, 'subject' | 'text' | 'attachments'>` anlegen. Den Rumpf von `sendViaTenantTransport` in `private async deliver(tenantId, mail: OutgoingMail, kind): Promise<void>` verschieben (resolveTransport, createTransport, `sendMail({ from, to, subject, text, html, attachments })`, Erfolgs-Log, `close()` im `finally`) — `deliver` WIRFT. `sendViaTenantTransport` wird zum Mantel: `try { await this.deliver(...) } catch (error) { bisheriges error-Log }` — Verhalten fuer Kennwort-Reset/Willkommen unveraendert (Kommentar: T-02-12 bleibt fuer diese beiden Wege). NEU `async sendBugReport(tenantId: string, to: string, report: BugReportMail): Promise<void>` -> `await this.deliver(tenantId, { to, ...report }, 'Bug report')` mit JSDoc: Fehler gehen bewusst nach aussen — der Anwender soll wissen, ob sein Bericht ankam (Gegenteil von T-02-12, begruendet). Kopfkommentar der Datei um zwei Saetze ergaenzen.

Schritt E — Modul bug-reports (Verzeichnis `apps/api/src/bug-reports/`):
7. `dto/bug-report.dto.ts`: Klasse `BugReportDto` — `description?: string` (`@IsOptional() @IsString() @MaxLength(4000)`), `page: string` (`@IsString() @MaxLength(2000)`), `webVersion` (`@IsString() @MaxLength(100)`), `webChannel` (`@IsString() @MaxLength(20)`), `webCommit` (`@IsString() @MaxLength(64)`; Leerstring erlaubt), `userAgent` (`@IsString() @MaxLength(1000)`), `viewport` (`@IsString() @MaxLength(50)`), `clientTime` (`@IsString() @MaxLength(50)`), `errors: string[]` mit `@Transform(({ value }) => value === undefined || value === null ? [] : Array.isArray(value) ? value : [value])` (Import aus `class-transformer`, Vorlage `tender-query.dto.ts` Zeile 160) gefolgt von `@IsArray() @ArrayMaxSize(30) @IsString({ each: true }) @MaxLength(1000, { each: true })`. Kopfkommentar: Multipart-Felder kommen als Strings; ein einzelnes `errors`-Feld kommt als String, mehrere als Array (append-field, gemessen) — daher die Normalisierung; Mandant und Benutzer stehen bewusst NICHT im DTO (T-M97-06), `whitelist: true` der globalen Pipe entfernt Fremdfelder.
8. `bug-reports.service.ts`: `@Injectable() BugReportsService` mit Konstruktor `(settingsService: SettingsService, mailService: MailService, configService: ConfigService, prisma: PrismaService)`; Konstanten `WINDOW_MS = 10 * 60 * 1000`, `MAX_PER_WINDOW = 5`, `PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])`; privates `Map<string, number[]>` fuer die Drossel. Oeffentlich `async submit(user: { id: string; username: string; role: string; tenantId: string }, dto: BugReportDto, file?: { buffer: Buffer; size: number; mimetype?: string }): Promise<{ sent: true }>` in dieser Reihenfolge: (1) Drossel pruefen und zaehlen (Zeitstempel aelter als das Fenster verwerfen; bei `>= MAX_PER_WINDOW` `throw new HttpException('Zu viele Fehlermeldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut.', HttpStatus.TOO_MANY_REQUESTS)`; sonst `Date.now()` anhaengen — der Versuch zaehlt auch, wenn spaeter der Versand scheitert); (2) Bild pruefen: wenn `file` vorhanden und (`file.buffer.length < 8` oder die ersten 8 Bytes ungleich `PNG_SIGNATURE`) -> `BadRequestException('Das Bildschirmfoto ist keine gültige PNG-Datei.')`; (3) Empfaenger: `(await this.settingsService.getBugReportRecipient(user.tenantId)) || (this.configService.get<string>('TESSERA_BUGREPORT_TO') || '').trim() || null`; fehlt er -> `ConflictException('Für Fehlermeldungen ist noch kein Postfach eingerichtet. Ein Administrator legt es unter Administrator → SMTP im Feld „Fehlermeldungen an" fest.')`; (4) Benutzerzeile: `const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;` dann `tenantPrisma.user.findUnique({ where: { id: user.id }, select: { username: true, displayName: true, email: true, role: true } })` — `null` -> Sitzungswerte (Kommentar: gebunden an den Sitzungs-Mandanten, nie an Rumpfdaten; Zeile in `docs/mandantentrennung-zugriffsklassifikation.md`); (5) Betreff `[Tessera Fehlermeldung] ${dto.webVersion} ${dto.webChannel} - ${dto.page.slice(0, 120)}`; (6) Text als Zeilen-Array mit `join('\n')`: Einleitung „Ein Anwender hat über den Knopf „Fehler melden" eine Meldung geschickt.", Leerzeile, „Was ist passiert?", Beschreibung oder `(keine Beschreibung)`, Leerzeile, dann je eine Zeile `Seite:`, `Zeitpunkt (Server):` (`new Date().toISOString()`), `Zeitpunkt (Browser):`, `Benutzer:` (`<displayName oder username> (<username>), Rolle <role>, E-Mail <email oder ->`), `Mandant:`, `Web:` (`<webVersion> (<webChannel>) <webCommit>`), `API:` (`formatAppVersionLine()` aus `../health/app-version`), `Browser:`, `Fenster:`, Leerzeile, `Letzte Fehlermeldungen im Browser (<n>):` und je Eintrag `- <eintrag>` oder `- keine`, Leerzeile, `Bildschirmfoto: im Anhang (<bytes> Bytes)` bzw. `Bildschirmfoto: nicht beigefügt`; (7) Anhang: bei Bild `[{ filename: 'fehlermeldung-<yyyymmdd-hhmm>.png' (UTC-Zeit, mit `padStart`), content: file.buffer, contentType: 'image/png' }]`, sonst `[]`; (8) `try { await this.mailService.sendBugReport(user.tenantId, to, { subject, text, attachments }) } catch (error) { this.logger.error('Bug report mail failed', error instanceof Error ? error.stack : String(error)); throw new BadGatewayException('E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator.'); }`; (9) genau EINE Logger-Zeile `Bug report from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${dto.page.slice(0,120)}, screenshot ${bytes} bytes` (nie Beschreibung, nie Bild); Rueckgabe `{ sent: true }`. Kopfkommentar der Datei (deutsch, ASCII): Zweck, warum Multipart, warum kein Speichern, Drossel-Semantik, Sicherheitsbezuege T-M97-03/04/06.
9. `bug-reports.controller.ts`: `@Controller('bug-reports')`, Methode `submit` mit `@Post()`, `@UseInterceptors(FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }))` (Import aus `@nestjs/platform-express`, wie `user.controller.ts`), Parameter `@CurrentUser() user: any`, `@Body() dto: BugReportDto`, `@UploadedFile() file?: any` -> `return this.service.submit(user, dto, file)`. Kommentar ueber der Klasse: alle angemeldeten Rollen — bewusst KEIN Rollen-Dekorator (Muster `user.controller.ts` Zeile 273-275); Limit je Route statt global (T-M97-03); Mandant nur aus dem Sitzungsnachweis (T-M97-06).
<!-- planner-discipline-allow: @Roles, tenantId -->
10. `bug-reports.module.ts`: `@Module({ imports: [SettingsModule, MailModule], controllers: [BugReportsController], providers: [BugReportsService] })`; in `app.module.ts` Import + Eintrag hinter `TendersModule`.
11. `docs/mandantentrennung-zugriffsklassifikation.md`: in der Bestandsaufnahme-Tabelle (Kopf Zeile 659) eine Zeile `| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |` alphabetisch hinter den `auth/`-Zeilen; in der Bereichs-Tabelle (Kopf Zeile 163) eine Zeile `| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |`. Danach `pnpm -C apps/api exec vitest run src/prisma/rls-access-inventory.spec.ts` gruen.
12. `docker-compose.prod.yml`: im `environment`-Block von `api` hinter `TESSERA_SMTP_FROM` (Zeile 49) die Zeile `TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}` mit Kommentar im Ton der Datei (englisch wie die Nachbarkommentare): fallback mailbox for the in-app bug report button, empty = only the per-tenant setting in Administrator -> SMTP applies. Sonst nichts an der Datei.

Schritt F — GREEN: `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` gruen; volle Suite `Test Files  67 passed (67)` / `Tests  1076 passed (1076)`; `pnpm -C apps/api exec tsc --noEmit` Exit 0. Weicht eine Zahl ab, ist das ein Befund fuer das SUMMARY — erst die Ursache benennen, dann korrigieren.

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).
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=$? Vitest-Zeilen `Test Files 5 passed (5)` und `Tests ` — 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. 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) 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 `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): - Test 1 (Ringpuffer): 25-mal `recordError('error', 'm')` -> `getRecentErrors().length === 20`, erster Eintrag `m5`, letzter `m24`; jeder Eintrag hat `at` (ISO), `kind`, `message`. - 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 4 (idempotent): `installErrorBuffer()` zweimal, dann eine fehlgeschlagene Antwort -> genau EIN Eintrag (kein doppeltes Wrapping); `formatErrorsForReport()` liefert Zeilen der Form `[] fetch: …`. `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.`, 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`, 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, `` 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 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 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 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): - 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`. 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 (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).
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); `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`, 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/`):
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)} />`.
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 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 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`.

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).
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 "" apps/web/src/components/layout/header.tsx ; awk '//{b=NR} //{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=$? `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 ` (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).
Task 3: Handbuecher (Anwender, Administration, Betrieb), Abschluss-Gates, Push und Beobachtung des echten CI-Laufs docs/anleitung-anwender.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md Gitea antwortet lokal: `curl -s --max-time 5 http://localhost:3002/api/v1/version` liefert `{"version":"1.26.2"}`, und `docker ps --format '{{.Names}}' | grep -c '^gitea-runner$'` liefert `1` (sonst Push trotzdem, Beobachtung als offenen Punkt ins SUMMARY). Schritt A — `docs/anleitung-anwender.md` (echte Umlaute, Sie-Form, Alltagssprache, Ton der Datei): 1. Inhaltsverzeichnis (Zeilen 6-20): neuer Eintrag `8. [Einen Fehler melden](#einen-fehler-melden)` vor „Häufige Stolpersteine" (dieser wird 9.). 2. Abschnitt „Aufbau der Oberfläche", Kopfleiste (Zeilen 41-48): „zwei Bedienelemente" wird „drei Bedienelemente"; als ersten Aufzaehlungspunkt: „Einen Knopf **Fehler melden** (Käfer-Symbol) — siehe [Einen Fehler melden](#einen-fehler-melden)." 3. Neuer Abschnitt `## Einen Fehler melden` VOR „## Häufige Stolpersteine", vier bis sieben Absaetze: Was passiert beim Klick (Tessera nimmt sofort ein Bild der aktuellen Seite auf — genau das, was Sie gerade sehen — und öffnet dann ein kleines Fenster mit Vorschau); was Sie eintragen können (optional „Was ist passiert?" — je konkreter, desto schneller kann geholfen werden: was Sie getan haben, was Sie erwartet haben, was stattdessen geschah); das Häkchen „Bildschirmfoto beifügen" (vorbelegt; abwählen, wenn auf der Seite etwas zu sehen ist, das nicht in der E-Mail landen soll — **Datenschutz-Hinweis** als eigener fetter Satz: das Bild zeigt alles, was auf der Seite sichtbar ist, auch Namen und Zahlen anderer); was mitgeschickt wird (Bild, Ihre Beschreibung, die Adresse der Seite, Versionsnummer und Kanal von Tessera, Browser und Fenstergröße, Zeitpunkt, Ihr Name, Benutzername und Rolle, die letzten Fehlermeldungen, die der Browser im Hintergrund gesehen hat — keine Passwörter, keine Eingaben in Formularen ausser dem, was im Bild sichtbar ist); wohin es geht (per E-Mail an das Postfach, das Ihr Administrator eingerichtet hat; nichts wird in Tessera gespeichert); die Rückmeldungen („Vielen Dank, die Meldung wurde gesendet." — oder ein Hinweis, warum nicht: kein Postfach eingerichtet (dann Administrator ansprechen), zu viele Meldungen kurz hintereinander (höchstens fünf in zehn Minuten), E-Mail konnte nicht gesendet werden (später erneut versuchen)). 4. „Häufige Stolpersteine": neuer Punkt „**Der Knopf „Fehler melden" antwortet, es sei kein Postfach eingerichtet.** Ihr Administrator hat unter Administrator → SMTP noch keine Adresse im Feld „Fehlermeldungen an" hinterlegt. Sprechen Sie ihn an – die Meldung selbst geht dabei nicht verloren, Sie können sie danach erneut senden."
Schritt B — `docs/anleitung-administration.md` (echte Umlaute):
1. Kapitel 6 SMTP (Zeilen 202-213): nach dem Absatz über „Test-E-Mail an" ein Absatz zum neuen Feld: **Fehlermeldungen an** — optionale Adresse; sobald sie gesetzt ist, sehen alle Anwender in der Kopfleiste den Knopf „Fehler melden" wirken: ein Klick schickt ein Bildschirmfoto der aktuellen Seite samt Beschreibung, Seite, Version, Browser, angemeldetem Benutzer und den letzten Fehlermeldungen des Browsers als E-Mail an diese Adresse (Betreff beginnt mit „[Tessera Fehlermeldung]", Bild als PNG im Anhang). Der Knopf ist immer sichtbar; ohne Adresse erhalten Anwender beim Senden den Hinweis, dass noch kein Postfach eingerichtet ist (Administratoren zusätzlich einen Link hierher). Höchstens fünf Meldungen je Benutzer in zehn Minuten; Bilder über 4 MB werden abgewiesen. Der Versand nutzt dieselben SMTP-Zugangsdaten wie alle anderen Mails des Mandanten. Hinweis auf den Betriebs-Rückfall `TESSERA_BUGREPORT_TO` (Betriebshandbuch Kapitel 3) für Installationen ohne gespeicherte SMTP-Einstellungen. Datenschutz-Satz: das Bild zeigt alles, was der Anwender gerade sieht — das Postfach entsprechend wählen.
2. Fehlersuche-Tabelle (Kapitel 8): zwei neue Zeilen: „Anwender melden, der Knopf „Fehler melden" sage, es sei kein Postfach eingerichtet." -> „Feld „Fehlermeldungen an" unter Administrator → SMTP ausfüllen und speichern (SMTP-Einstellungen müssen vollständig sein, das Feld gehört zu ihnen)."; „Eine Fehlermeldung meldet „E-Mail konnte nicht gesendet werden"." -> „Der SMTP-Versand des Mandanten scheitert; „Verbindung testen" unter Administrator → SMTP, Serverprotokoll der API prüfen (Zeile „Bug report mail failed")."

Schritt C — `docs/anleitung-betrieb.md` (echte Umlaute): Kapitel 3, Konfigurationstabelle (Zeilen 150-162): neue Zeile nach der `TESSERA_SMTP_*`-Zeile (160): `| \`TESSERA_BUGREPORT_TO\` | nein | leer | Rückfall-Postfach für den Knopf „Fehler melden" in der Kopfleiste, falls unter Administrator → SMTP kein Feld „Fehlermeldungen an" gesetzt ist. Leer = nur die Einstellung in der Oberfläche gilt. Wie \`IMAGE_TAG\` (Kapitel 9): die Serverdatei \`/opt/tessera/docker-compose.prod.yml\` bekommt die Zeile \`TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}\` nur von Hand. |`. Kapitel 7, Tabelle der Symptome (ab Zeile 338): eine Zeile „Fehlermeldungen der Anwender kommen nicht an" -> „Feld „Fehlermeldungen an" (Administrator → SMTP) oder `TESSERA_BUGREPORT_TO` prüfen; API-Log nach `Bug report` durchsuchen (eine Zeile je gesendeter Meldung, `Bug report mail failed` bei Versandfehler)."

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.
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.
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.
6. Wird das SUMMARY erst nach dem Push committet, den Push danach wiederholen (weiterer CI-Lauf erwartet, in Ordnung).
cd /home/vicolab/projects/tessera-ctl && grep -c "^## Einen Fehler melden" docs/anleitung-anwender.md ; grep -c "drei Bedienelemente" docs/anleitung-anwender.md ; grep -c "Fehlermeldungen an" docs/anleitung-administration.md ; grep -c "TESSERA_BUGREPORT_TO" docs/anleitung-betrieb.md ; grep -c "TESSERA_BUGREPORT_TO" docs/anleitung-administration.md ; D=$(git diff --stat 5c42c55 -- . ':!.planning'); echo GIT_EXIT=$? ; tail -n1 <<< "$D" ; U=$(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); echo U_EXIT=$? ; test -z "$U"; echo U_EMPTY=$? ; S=$(git status -sb); head -n1 <<< "$S" Greps liefern `1`, `1`, `>= 3`, `>= 2`, `>= 1`; `GIT_EXIT=0` und die Summenzeile nennt `35 files changed`; `U_EXIT=0`, `U_EMPTY=0`; die Status-Zeile enthaelt kein `[ahead`. Das SUMMARY traegt unter „CI-Lauf nach dem Push" Lauf-ID, `conclusion`, Dauer und die zwei Abbild-Proben (Stempel, html-to-image im Web-Abbild) — oder, falls Gitea/Runner nicht erreichbar waren, den Grund und den offenen Punkt.

<threat_model>

Trust Boundaries

Boundary Description
Browser (angemeldeter Anwender) -> POST /bug-reports Vom Anwender kontrollierte Multipart-Daten (Beschreibung, Fehlerliste, Bilddatei bis 4 MiB, Seite, Versionsangaben) ueberqueren die Grenze; Identitaet nur aus dem Sitzungs-Cookie
Seite -> Bildschirmfoto -> E-Mail -> Postfach des Administrators Alles Sichtbare auf der Seite (auch Daten Dritter) verlaesst Tessera per SMTP in ein Postfach ausserhalb der Anwendung
Browser-Fehlerpuffer Beobachtet console.error, Fehlerereignisse und fehlgeschlagene API-Antworten im Browser
ADMIN -> PUT /settings/smtp (bugReportRecipient) Ein Administrator des Mandanten bestimmt das Ziel aller Fehlermeldungen seines Mandanten
API -> SMTP-Server des Mandanten Transport je Versand mit den gespeicherten Zugangsdaten des Sitzungs-Mandanten (260914-eym)

STRIDE Threat Register (ASVS Level 1, Blocking-Schwelle high)

Threat ID Category Component Severity Disposition Mitigation Plan
T-M97-01 Information Disclosure Bildschirmfoto zeigt alles Sichtbare (Namen, Zahlen Dritter, Modulinhalte) und geht per E-Mail an das eingestellte Postfach medium mitigate Der Anwender sieht VOR dem Senden die Vorschau und den Hinweis bugReport.intro; Haekchen „Bildschirmfoto beifügen" abwaehlbar; Empfaenger ist das Postfach des eigenen Mandanten-Administrators (nur ADMIN/SUPER_ADMIN setzen es, T-M97-05), Transport des eigenen Mandanten; Handbuecher (Anwender + Administration) tragen den Datenschutz-Satz; nichts wird in Tessera gespeichert
T-M97-02 Information Disclosure Fehlerpuffer (error-buffer.ts) koennte Kennwoerter/Tokens aufzeichnen medium mitigate Wrapper notiert NUR bei !response.ok: Methode, Pfad OHNE Suchteil, Status, 200 Zeichen des ANTWORT-Rumpfs; nie init.body, nie Kopfzeilen, nie Cookies (Sitzungs-Cookie ist httpOnly und fuer JS unsichtbar); Test 2 pinnt geheim/password NICHT im Puffer; Ringpuffer 20, je Eintrag 1000 Zeichen, DTO-Grenze 30 x 1000
T-M97-03 Denial of Service Upload-Groesse und Haeufigkeit auf POST /bug-reports medium mitigate Limit NUR auf dieser Route (FileInterceptor fileSize 4 MiB, files: 1 -> 413), main.ts unangetastet (kein globales JSON-Limit, gemessen und verworfen); nur angemeldete Benutzer (globaler JwtAuthGuard); Drossel 5 je Benutzer je 10 Minuten (Spec a); DTO-Grenzen fuer alle Textfelder; keine serverseitige Bildverarbeitung (kein Dekoder -> keine Dekompressionsbombe im Prozess)
T-M97-04 Tampering Falsche Datei als „PNG" (Skript, Archiv, HTML) im Anhang an den Administrator low mitigate PNG-Signatur 89 50 4E 47 0D 0A 1A 0A wird geprueft (Spec b, 400); Anhang traegt festen Dateinamen fehlermeldung-<Zeit>.png und contentType: image/png — der Client bestimmt weder Name noch Typ
T-M97-05 Tampering bugReportRecipient als Umleitungsziel fuer Bildschirmfotos eines ganzen Mandanten medium mitigate Nur PUT /settings/smtp mit @Roles(ADMIN, SUPER_ADMIN) setzt das Feld; @IsEmail() im DTO; Speichern mandantengebunden (forTenant, tenant_isolation_policy); Lesen mandantengebunden (getBugReportRecipient, Spec A/d); Umgebungs-Rueckfall nur vom Betreiber setzbar
T-M97-06 Elevation of Privilege Bericht im Namen eines anderen Mandanten/Benutzers ueber Rumpffelder medium mitigate DTO kennt kein Mandanten-/Benutzerfeld; whitelist: true entfernt Fremdfelder (Controller-Spec 1); Dienst nimmt tenantId/id/username/role ausschliesslich aus @CurrentUser() und liest die Benutzerzeile gebunden (Spec 8); Doku-Zeile in mandantentrennung-zugriffsklassifikation.md, vom Inventar-Spec erzwungen
T-M97-07 Repudiation Wer hat wann was gemeldet? low mitigate Eine Protokollzeile je Bericht (Benutzer, Mandant, Seite, Empfaenger, Bildgroesse) und eine bei Versandfehler; E-Mail traegt Benutzer, Mandant, Server- und Browserzeit
T-M97-08 Information Disclosure Fehlermeldungen der API an den Anwender (409/502) low accept Meldungen nennen nur den Zustand (kein Postfach / Versand gescheitert), keine Transportdetails, keine Adressen; der Admin-Hinweis erscheint nur bei ADMIN/SUPER_ADMIN (clientseitig, rein informativ)
T-M97-09 Spoofing Anwender schreibt irrefuehrende Inhalte in Beschreibung/Fehlerliste (z. B. gefaelschte „Fehlerzeilen") low accept E-Mail ist reiner Text (kein HTML, kein Rendern im Mailclient); Beschreibung und Fehlerliste stehen unter eigenen Ueberschriften; Benutzer/Mandant/Version stammen serverseitig aus Sitzung und Umgebung, nicht aus dem Rumpf; Empfaenger ist ein Administrator des eigenen Mandanten
T-M97-SC Tampering npm-Installation html-to-image@1.11.13 low mitigate Paketlegitimitaet zur Planungszeit ueber die Registry belegt (siehe <package_legitimacy_audit>: 0 Abhaengigkeiten, MIT, 4,8 Mio. Downloads/Woche, Repo bubkoo/html-to-image) -> [VERIFIED]; Version exakt gepinnt; pnpm install --frozen-lockfile als Gate; kein weiteres Paket
</threat_model>
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/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` - `pnpm install --frozen-lockfile; echo $?` -> `0` - `D=$(git diff --stat 5c42c55 -- . ':!.planning'); tail -n1 <<< "$D"` -> `35 files changed` - `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 - `cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1):5432/tessera" ./node_modules/.bin/prisma migrate status | tail -1` -> `Database schema is up to date!` - Falsifizierungen im SUMMARY benannt: (a) 429 nach dem sechsten Bericht und Erholung nach 10 Minuten, (b) 400 bei falschem Kopf, (c) 409 ohne Empfaenger inkl. Leerstring-Variable, (d) Fremdfeld entfernt und Sitzungs-Mandant in allen drei Aufrufen; Reihenfolge Bild-vor-Dialog per `toPng`-Mock gepinnt. - SUMMARY enthaelt: RED-Laeufe (Task 1 und 2), die drei Prisma-Ausgaben, die Allowlist-Woerter, den Hinweis auf die Lockfile-/deps-Stufen-Aenderung, „CI-Lauf nach dem Push" mit Lauf-ID/Dauer/Proben, und die Entscheidungen „Multipart statt JSON+Base64" und „keine `GET /bug-reports/status`-Route (409 reicht, kein Aufruf je Seitenladung)" mit ihren Messungen. - Human-Check (end-of-phase, nicht blockierend, durch den Orchestrator mit Playwright MCP): `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mailhog` (Mail-Senke, `http://localhost:8025`), dann `docker compose up -d --build api web`; im Browser anmelden, unter Administrator -> SMTP Host `mailhog`, Port `1025`, Verschluesselung „Keine", Absender `tessera@tessera.local`, „Fehlermeldungen an" `fehler@example.invalid` speichern; auf einer Seite mit Inhalt (z. B. Benutzerverwaltung, dunkles Erscheinungsbild) den Kaefer-Knopf klicken -> Dialog mit Vorschau, die NICHT den Dialog zeigt und die OKLCH-Farben korrekt wiedergibt; Beschreibung eintragen, senden -> „Vielen Dank …"; unter `http://localhost:8025` die E-Mail mit Betreff `[Tessera Fehlermeldung] dev dev - /admin/users` und PNG-Anhang oeffnen (Groesse des Anhangs notieren — Erwartung unter 2 MB fuer eine typische Seite; sonst Befund); zweite Probe: Feld „Fehlermeldungen an" leeren und speichern -> Senden zeigt „kein Postfach eingerichtet" mit Admin-Link; dritte Probe: sechs Meldungen hintereinander -> die sechste zeigt „Zu viele Meldungen". `docker compose logs api | grep "Bug report"` zeigt je gesendeter Meldung eine Zeile ohne Beschreibung und ohne Bild.

<success_criteria>

  • Knopf in der Kopfzeile vor dem Erscheinungsbild-Schalter; Bild wird VOR dem Dialog aufgenommen (Test pinnt es), Dialog mit Vorschau, Haekchen (an), optionaler Beschreibung, Senden/Abbrechen, Escape, Erfolgs- und Fehlermeldungen je Status; Anwender erfaehrt immer, ob der Bericht ankam.
  • POST /bug-reports (Multipart, 4 MiB je Route, main.ts unveraendert): jeder angemeldete Benutzer, Mandant/Benutzer nur aus der Sitzung, PNG-Signatur, Drossel 5/10 min, DTO-Grenzen, 409 ohne Empfaenger, 502 bei Versandfehler, eine Protokollzeile, kein Speichern; E-Mail mit Betreff [Tessera Fehlermeldung] …, allen Kontextfeldern und PNG-Anhang ueber den Transport des Mandanten — sendMail-Argumente per Spec mit echtem PNG gepinnt; Falsifizierungen (a)-(d) rot-gruen.
  • 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.
  • 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/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>
Create `.planning/quick/260914-m97-fehler-melden-knopf-bildschirmfoto-der-a/260914-m97-SUMMARY.md` when done