Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
96 KiB
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 |
|
|
|
|
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_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); WebTest Files 40 passed (40)/Tests 243 passed (243);tsc --noEmitinpackages/shared,apps/api,apps/webje Exit 0. Lokal laeuft nurtessera-ctl-db-1(IP172.19.0.2);prisma migrate statusmitpostgresql://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 fuer5c42c55. - Bibliothek:
pnpm view html-to-image version->1.11.13(dist-taglatest, veroeffentlicht 2025-02-14, MIT,dependencies: {},peerDependencies: {},lib/index.d.ts, Repogithub.com/bubkoo/html-to-image, 4.822.813 Downloads in der Woche 2026-09-05..11 lautapi.npmjs.org). Optionen intypes.d.tsbestaetigt:pixelRatio,canvasWidth,canvasHeight,skipFonts,cacheBust,filter,fetchRequestInit. Skalierung bestaetigt inlib/index.js88-102:canvas.width = canvasWidth * ratio,drawImage(img, 0, 0, canvas.width, canvas.height)—canvasWidth/canvasHeightskalieren das Bild.util.jsnutztforeignObject. Wegwerf-Skript im Scratchpad (h2i/, jsdom 29.1.1 des Projekts):toPngscheitert in jsdom (HTMLVideoElement is not defined, danachElement is not defined; ohne Canvas-Backend ohnehin kein Rastern) -> im Komponententest istvi.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.27installiert und inuser.controller.ts(Avatar,limits.fileSize) produktiv — Multipart ist der bewaehrte Weg fuer Binaerdaten mit Limit je Route.platform-express/multer/multer.utils.jsbildetLIMIT_FILE_SIZEaufPayloadTooLargeException(413) ab.append-field@1.0.0(multer):errorseinmal ->\"a\"(String), dreimal ->[\"a\",\"b\",\"c\"]. Alternative gemessen:app.useBodyParser('json', { limit: '8mb' })funktioniert in Nest 11/Express 5 ohnebodyParser: false(NestApplication.useBodyParser->ExpressAdapter.useBodyParser->this.use(express.json(...));registerParserMiddlewarefiltert perisMiddlewareApplied('jsonParser'), undexpress.json()heisst tatsaechlichjsonParser) — aber global fuer jede JSON-Route inkl. der oeffentlichen/auth/login. Entscheidung: Multipart,main.tsunangetastet. - Nest-Ausnahmen: es gibt KEINE
TooManyRequestsExceptionin@nestjs/common->new HttpException('...', HttpStatus.TOO_MANY_REQUESTS). Keine Drossel-Bibliothek im Projekt (@nestjs/throttlernicht installiert) -> In-Memory-Map im Dienst. @CurrentUser()liefert{ id, username, role, tenantId }(jwt.strategy.ts 27-33) — KEIN Anzeigename, keine E-Mail. Deshalb eine gebundeneuser.findUnique-Zeile im Dienst (Userhatusername,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 indocs/mandantentrennung-zugriffsklassifikation.md, sonst rot.PrismaModuleist@Global().- Muster „nur angemeldet":
user.controller.ts273-275 — kein@Roles(),RolesGuard.canActivate()liefert true bei leerer Rollenliste,JwtAuthGuardglobal (app.module.ts52-56).Roles-Metadatum ueberROLES_KEYausauth/decorators/roles.decorator. MailService.sendViaTenantTransportverschluckt Fehler bewusst (T-02-12); fuer den Bericht darf das nicht gelten -> Kerndeliverwirft, der Mantel bleibt.mail.service.spec.tsmocktnodemailer(createTransport->{ sendMail, close }), 4 Tests,mockSendMailje Test neu.settings.service.spec.ts: Fake-Prisma mit__makeBoundClient(tenantId), gebundener Klient bietet nurfindUnique/upsert(mitapplySelect), Test „genau EIN gebundener Klient je Aufruf" (Zeile 433) —getBugReportRecipienthaelt das. Test Zeile 206 prueft, dassupdateKEINEN SchluesselencryptedPasswordtraegt, wenn kein Kennwort kam — dieselbe Form (bedingtes Spreading) fuerbugReportRecipient.settings.controller.tsbraucht 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.tsxhat keinen Test. - Web:
auth-actions.tsundmodule-access-actions.tssind Server Actions ('use server', laufen auf dem Next-Server) — ein Browser-fetch-Wrapper sieht sie NICHT; alle uebrigen 28 Dateien mitfetch(rufen${NEXT_PUBLIC_API_URL}/…direkt aus dem Browser (credentials: 'include') -> der Wrapper inerror-buffer.tserfasst genau diese. Kein Test rendertHeaderoderAppShell(kein Mock-Nachziehen noetig). Avatar-Bild/api-proxy/users/me/avatarist 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.tsx16-41.useAuthStoreist ein Zustand-Store (useAuthStore((s) => s.user)moeglich); RollenSUPER_ADMIN | ADMIN | USER. - i18n:
de.json/en.jsonje 963 Zeilen, Namensraeumecommon, auth, header, sidebar, dashboard, settings, widgets, admin, adminModules, theme, locale, modules, …;settings.smtptraegt 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.tsprueft nurtenderRadar. - Handbuecher:
anleitung-anwender.md166 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.md240 Zeilen (96 mit Umlauten; Kapitel 6 SMTP Zeilen 202-213, Fehlersuche-Tabelle ab 227, letzte Zeile 240);anleitung-betrieb.md521 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.ymlapi.environmentZeilen 36-59 (TESSERA_SMTP_FROMZeile 49).docker-compose.dev.ymlfuehrtmailhog(Ports 1025/8025,backend-net); Abbildmailhog/mailhog:latestlokal vorhanden;docker compose -f docker-compose.yml -f docker-compose.dev.yml config --services->phpldapadmin db api web mailhog openldap. Lokale Abbildertessera-ctl-api:latest/tessera-ctl-web:latestvorhanden (Cache-Waerme fuerdocker 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-gateFEUERT (schema.prisma+ neue Migration) -> [BLOCKING]-Schrittmigrate deployin Task 1. Konfiguration:tdd_mode=false(Task 1 und 2 tragen trotzdemtdd=\"true\"),security_enforcement=true, ASVS 1, Blocking-Schwellehigh,human_verify_mode=end-of-phase,branching_strategy: none(Commits aufmain, 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, GitHubbubkoo/html-to-image, ein Maintainer, keine Abhaengigkeiten -> [VERIFIED] durch Registry-Nachweis zur Planungszeit; kein blockierender Mensch-Checkpoint noetig (Auftrag: kein Nachfragen).T-M97-SCim Threat-Register. - Biome ist im Bestand nicht lauffaehig (WINDOWS #35) — kein Biome-Gate;
biome.jsonunangetastet. </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:
- 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, i18nsettings.smtp; Schritte G/H, 5 Dateien). Wiederaufsetzpunkt nach Commit 2a ist Schritt G. - 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 antoPnguebergebenencanvasWidth/canvasHeightbei gestubbten Body-Massen; das serverseitige 4-MiB-Limit bekommt ein Grep-Gate in Task 1. - 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(Mustertessera-logo.test.tsx), Erwartungen zitierende.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>
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, `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> |
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.tsunveraendert): 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.bugReportRecipientper additiver Migration (lokal eingespielt,migrate status/diffsauber), Feld „Fehlermeldungen an" unter Administrator -> SMTP, RueckfallTESSERA_BUGREPORT_TOindocker-compose.prod.ymlund 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-lockfilegruen; 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 Scopequick-260914-m97, gepusht, CI-Laufsuccessbeobachtet und Abbild-Proben notiert. </success_criteria>