14 Commits

Author SHA1 Message Date
schalli e5098603b4 docs(quick-260914-m97): Fehler-melden-Knopf abgeschlossen, verifiziert 9/9 und im Browser bewiesen — Zusammenfassung, Verifikation, Aktenstand, Ledger #38 fixed
Tessera CI/CD / Build & Publish Images (push) Successful in 2m42s
Tessera CI/CD / Lint & Type Check (push) Successful in 46s
Tessera CI/CD / Tests (push) Successful in 53s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 17:18:28 +02:00
schalli 77117de3d0 docs(quick-260914-m97): Handbuecher — Einen Fehler melden (Anwender), Feld Fehlermeldungen an (Administration), TESSERA_BUGREPORT_TO als Rueckfall (Betrieb)
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 55s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m14s
- Anwender: neuer Abschnitt 8 mit Ablauf, Datenschutz-Hinweis (Bild zeigt die aktuelle Seite), was mitgeschickt wird, Rueckmeldungen; Kopfleiste nennt drei Bedienelemente; Stolperstein "kein Postfach"
- Administration: Kapitel 6 SMTP beschreibt das Feld Fehlermeldungen an (Drossel, 4 MB, Rueckfall, Datenschutz); zwei neue Zeilen in der Fehlersuche-Tabelle
- Betrieb: TESSERA_BUGREPORT_TO in der Konfigurationstabelle (Serverdatei von Hand ergaenzen, wie IMAGE_TAG) und in der Symptomtabelle

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 16:53:40 +02:00
schalli b41be21190 feat(quick-260914-m97): Feld Fehlermeldungen an im SMTP-Formular — settings-api, Formular, i18n settings.smtp
- SmtpConfig.bugReportRecipient (string | null) und SaveSmtpPayload.bugReportRecipient (null loescht, fehlend bewahrt)
- Eingabefeld type=email hinter der Absenderadresse mit Hinweistext; Payload traegt immer trim() || null
- Zwei Komponententests: Vorbelegung aus GET, PUT-Payload mit Wert bzw. null
- i18n settings.smtp.bugReportRecipient / bugReportRecipientHelp in de und en

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 16:51:36 +02:00
schalli 60b0ee8409 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
- Knopf (Kaefer-Symbol) unmittelbar vor dem Erscheinungsbild-Schalter; captureScreenshot() laeuft VOR dem Oeffnen, Test pinnt es im toPng-Mock
- computeCaptureSize (laengste Kante 1600 px, reine Funktion, getestet), dataUrlToBlob, sendBugReport als Multipart mit credentials und ohne Content-Type-Header
- error-buffer: Ringpuffer 20, window error/unhandledrejection, console.error (Original bleibt), fetch-Wrapper nur bei !ok ohne Anfrage-Rumpf/Suchteil/Kopfzeilen (T-M97-02), idempotent, SSR-sicher; installiert in app-shell
- Dialog mit Vorschau, Haekchen, Beschreibung, Meldungen je Status 409/413/429/502/allgemein, Admin-Link auf /admin/smtp
- i18n bugReport de/en, Allowlist um "passiert"; html-to-image exakt 1.11.13, Lockfile aktualisiert

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 16:50:01 +02:00
schalli 54121c1721 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
- SmtpConfig.bugReportRecipient (nullable, additive Migration 20260914170000), DTO @IsOptional @IsEmail, SAFE_SELECT, getBugReportRecipient gebunden
- MailService: Versandkern deliver (wirft, Anhaenge), sendViaTenantTransport bleibt verschluckender Mantel (T-02-12), sendBugReport laesst Fehler durch
- POST /bug-reports: Multipart 4 MiB je Route, alle angemeldeten Rollen, Drossel 5/10 min -> 429, PNG-Signatur -> 400, kein Empfaenger -> 409, Versandfehler -> 502, eine Protokollzeile
- Falsifizierungen (a)-(d) als Specs; @Expose() im DTO, damit errors auch bei fehlendem Feld zu [] wird
- Doku-Zeile fuer rls-access-inventory, TESSERA_BUGREPORT_TO in docker-compose.prod.yml

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 16:45:09 +02:00
schalli 17a7e5ef9b docs(quick-260914-m97): Plan revidiert (Runde 1) — Task 2 mit Commit-Grenze 2a/2b, computeCaptureSize automatisiert getestet, Dialog-Tests fuer 413/429/502/allgemein aus de.json
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 16:34:41 +02:00
schalli 04f933c972 docs(quick-260914-m97): Plan fuer den Fehler-melden-Knopf — Bildschirmfoto vor dem Dialog, E-Mail mit PNG-Anhang, Empfaenger in SmtpConfig, Drossel und Falsifizierungen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 16:26:25 +02:00
schalli 5c42c558c4 docs(quick-260914-ku1): Kanaele und Versionsstempel abgeschlossen und verifiziert 8/8 — Zusammenfassung, Verifikation, Aktenstand
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 56s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 16:01:13 +02:00
schalli ea6aa995b2 docs(quick-260914-ku1): Betriebshandbuch — Zwei Kanäle Live und Beta, Freigabe, Hotfix ohne Datenbankänderung, neuer Live-Server; ci-cd-setup auf gemessenen Stand
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 52s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m34s
- anleitung-betrieb.md: neues Kapitel 9 (Kanal, IMAGE_TAG je Server, Freigabe,
  Hotfix-Ablauf mit Regel "Keine Datenbankaenderung als Hotfix", drei Kontrollwege,
  Einrichtung des Live-Servers, Erstfreigabe v1.0.0); Inhaltsverzeichnis, Tabelle in
  Kapitel 1, IMAGE_TAG in Kapitel 3, Etiketten in Kapitel 4, Startzeile in Kapitel 7
- ci-cd-setup.md: REGISTRY_TOKEN und Push ueber localhost:3002, Trigger main/live/v*,
  Jobs quality -> test -> publish, Etiketten- und Build-Arg-Tabellen, D-13 ueberholt,
  Tag-/Branch-Schutz-Empfehlung (T-KU1-04), Fehlerbehebung fuer den Stempel

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 15:44:16 +02:00
schalli 9731501718 ci(quick-260914-ku1): zwei Kanaele — main -> beta+latest, Tag v* -> live+vX.Y.Z, Versionsstempel als Build-Args in beide Dockerfiles, IMAGE_TAG in docker-compose.prod.yml
- Dockerfiles: globale ARG APP_VERSION/APP_CHANNEL/APP_COMMIT/APP_BUILD_TIME (Vorgabe dev),
  web-builder setzt NEXT_PUBLIC_APP_* vor pnpm build (Bauzeit-Einbettung), runner-Stufen
  setzen ENV APP_* fuer die Laufzeit
- .gitea/scripts/publish-images.sh: Kanal und Etiketten allein aus GITHUB_REF, --print-plan
  ohne Docker, Zweig live ohne Tag = nichts zu tun (T-KU1-07)
- ci.yml: Trigger main, live und Tags v*; publish mit fetch-depth: 0 und Skriptaufruf
- docker-compose.prod.yml: image ...:${IMAGE_TAG:-beta} fuer web und api
- Falsifizierung lokal: mit Build-Args v9.9.9-test in API-Env, dist-Zeile und Web-Bundle
  (1 Datei), ohne Build-Args dev/dev und 0 Treffer im Bundle

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 15:42:01 +02:00
schalli cdb571c509 feat(quick-260914-ku1): Versionsstempel — GET /health/version aus APP_*, VersionResponse, app-version.ts und Abzeichen v<Version> · <Kanal> in der Seitenleiste
- packages/shared: VersionResponse { name, version, channel, commit, buildTime }
- apps/api: health/app-version.ts (getAppVersion mit ||-Vorgaben dev/dev, formatAppVersionLine),
  HealthController.getVersion delegiert (bleibt @Public, T-KU1-03), main.ts protokolliert
  "Tessera API <version> (<channel>) <commit>" beim Start; neuer Spec mit 6 Tests
- apps/web: lib/app-version.ts (NEXT_PUBLIC_APP_* mit vollem Literalnamen, loadApiVersion
  memoisiert und still bei Fehler), AppVersionBadge unten in der Seitenleiste (auch mobil,
  nicht eingeklappt), sidebar.channel.{beta,live,dev} in de/en; 5 + 4 + 1 neue Tests
- Suiten: API 65 Dateien / 1060 Tests, Web 40 / 243, tsc in shared/api/web Exit 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 15:32:50 +02:00
schalli 1cd4212df0 docs(quick-260914-ku1): Plan fuer zwei Auslieferungskanaele (Beta/Live), Versionsstempel in Abbilder, API und Seitenleiste, Hotfix-Ablauf im Betriebshandbuch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 15:23:25 +02:00
schalli 6c19451be9 docs: Mandantenfaehigkeit ruht auf Entscheidung des Users — 3a/Etappe 4 nicht weiterverfolgen; Lizenzmodell-Wunsch festgehalten
Tessera CI/CD / Lint & Type Check (push) Successful in 46s
Tessera CI/CD / Tests (push) Successful in 50s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 13:51:23 +02:00
schalli 5f80582a37 docs(quick-260914-eym): Etappe 3c abgeschlossen und verifiziert 9/9 — Zusammenfassung, Verifikation, Aktenstand
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 58s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 12:06:13 +02:00
62 changed files with 5253 additions and 78 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# publish-images.sh -- Abbilder je Auslieferungskanal bauen und veroeffentlichen
# (quick-260914-ku1).
#
# Kanalmodell:
# refs/heads/main -> Kanal beta, Etiketten beta + latest (latest = Alias, entfaellt spaeter)
# refs/tags/v* -> Kanal live, Etiketten live + vX.Y.Z
# alles andere -> nichts zu tun (Exit 0, kein Bau, kein Push)
#
# Der Zweig `live` OHNE Tag wird von der Pipeline geprueft, aber nicht veroeffentlicht:
# auf `live` ist jeder auslieferbare Stand ein Tag. Ein ungetaggter Merge darf das
# `live`-Etikett nicht ueberschreiben, sonst waere der Tag nicht mehr die Wahrheit.
#
# Die Entscheidung haengt allein an GITHUB_REF, damit sie lokal ohne Runner pruefbar ist:
# GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan
#
# Versionsstempel: APP_VERSION aus `git describe --tags --always` (ohne Tag: kurzer SHA),
# APP_COMMIT, APP_BUILD_TIME -- als --build-arg in beide Dockerfiles. Braucht im Checkout
# die volle Historie samt Tags (fetch-depth: 0 im Workflow).
#
# Dieses Skript kennt kein Secret und gibt keines aus; der Registry-Login bleibt im Workflow.
set -eu
REGISTRY="${REGISTRY:-localhost:3002/schalli/tessera-ctl}"
REF="${GITHUB_REF:-}"
case "$REF" in
refs/tags/v*)
APP_CHANNEL=live
TAGS="live ${REF#refs/tags/}"
;;
refs/heads/main)
APP_CHANNEL=beta
TAGS="beta latest"
;;
*)
echo "Kein Veroeffentlichungs-Anlass fuer '$REF' (nur main und Tags v*): nichts zu tun."
exit 0
;;
esac
APP_VERSION="$(git describe --tags --always)"
APP_COMMIT="$(git rev-parse --short HEAD)"
APP_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Tessera $APP_VERSION ($APP_CHANNEL) $APP_COMMIT $APP_BUILD_TIME -> Etiketten: $TAGS"
if [ "${1:-}" = "--print-plan" ]; then
for IMG in web api; do
for TAG in $TAGS; do
echo "push $REGISTRY/$IMG:$TAG"
done
done
exit 0
fi
for IMG in web api; do
docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" \
--build-arg APP_VERSION="$APP_VERSION" \
--build-arg APP_CHANNEL="$APP_CHANNEL" \
--build-arg APP_COMMIT="$APP_COMMIT" \
--build-arg APP_BUILD_TIME="$APP_BUILD_TIME" \
-f "apps/$IMG/Dockerfile" .
for TAG in $TAGS; do
docker tag "$REGISTRY/$IMG:$APP_CHANNEL" "$REGISTRY/$IMG:$TAG"
docker push "$REGISTRY/$IMG:$TAG"
done
done
+10 -11
View File
@@ -1,8 +1,12 @@
# Kanalmodell (quick-260914-ku1): main -> Kanal beta (Etiketten beta + latest);
# Tag v* -> Kanal live (Etiketten live + vX.Y.Z); Zweig live ohne Tag wird nur geprueft.
# Die Entscheidung trifft .gitea/scripts/publish-images.sh anhand GITHUB_REF.
name: Tessera CI/CD name: Tessera CI/CD
on: on:
push: push:
branches: [main] branches: [main, live]
tags: ['v*']
jobs: jobs:
quality: quality:
@@ -52,18 +56,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test needs: test
steps: steps:
# Ohne volle Historie und Tags liefert `git describe` nichts -- Pflicht fuer den Stempel.
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to Gitea Container Registry - name: Log in to Gitea Container Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login localhost:3002 -u ${{ gitea.actor }} --password-stdin run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login localhost:3002 -u ${{ gitea.actor }} --password-stdin
- name: Build web image - name: Versionsstempel berechnen, Abbilder bauen und veroeffentlichen
run: docker build -t localhost:3002/schalli/tessera-ctl/web:latest -f apps/web/Dockerfile . run: sh .gitea/scripts/publish-images.sh
- name: Build api image
run: docker build -t localhost:3002/schalli/tessera-ctl/api:latest -f apps/api/Dockerfile .
- name: Push images
run: |
docker push localhost:3002/schalli/tessera-ctl/web:latest
docker push localhost:3002/schalli/tessera-ctl/api:latest
+16 -9
View File
@@ -4,11 +4,11 @@ milestone: v1.2
current_phase: 17 current_phase: 17
current_phase_name: eigene-ausschreibungs-quellen-je-nutzer current_phase_name: eigene-ausschreibungs-quellen-je-nutzer
status: verified status: verified
stopped_at: "Quick 260914-ebg abgeschlossen: WINDOWS #29 geschlossen — Zielrollen-Riegel in UserController.update/remove, 16 Spec-Tests, Falsifizierung bestanden, gepusht" stopped_at: "2026-09-14: Quick 260914-m97 Fehler-melden-Knopf ausgefuehrt (4 Commits 54121c1/60b0ee8/b41be21/77117de gepusht, CI-Lauf 299 nach Rerun success, :beta-Abbilder mit 77117de beta); offen: Browser-Check mit mailhog durch den Verifizierer, danach Erstfreigabe v1.0.0"
last_updated: "2026-09-14T08:41:17.656Z" last_updated: "2026-09-14T15:08:28.980Z"
last_activity: 2026-09-11 last_activity: 2026-09-14
last_activity_desc: Quick 260910-jab — drei zu kurz greifende RLS-Regeln geschlossen (GroupMembership beide Seiten, ModuleGrant beide Ziele, TenderRssFeedSource Lese-/Schreibsplit), listForUser gebunden, Aktenstand kohaerent last_activity_desc: Quick 260910-jab — drei zu kurz greifende RLS-Regeln geschlossen (GroupMembership beide Seiten, ModuleGrant beide Ziele, TenderRssFeedSource Lese-/Schreibsplit), listForUser gebunden, Aktenstand kohaerent
state_head: 70d007bb47a2e39db54da47297dda162044dc79e state_head: 77117de3d0f1bb82df7b659f46fe26244ca6f164
progress: progress:
total_phases: 17 total_phases: 17
completed_phases: 15 completed_phases: 15
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 17 (eigene-ausschreibungs-quellen-je-nutzer) — VERIFIED / passed Phase: 17 (eigene-ausschreibungs-quellen-je-nutzer) — VERIFIED / passed
Plan: 3 of 3 Plan: 3 of 3
Status: Phase abgeschlossen und im Browser gegengeprueft — bereit fuer /gsd-ship Status: Phase abgeschlossen und im Browser gegengeprueft — bereit fuer /gsd-ship
Last activity: 2026-09-14 - WINDOWS #29 geschlossen (260914-ebg, verifiziert 6/6); als naechstes Etappe 3c (Systemkontext), dann 3a, Etappe 4 nur nach Rueckfrage Last activity: 2026-09-14 - Fehler-melden-Knopf (260914-m97, verifiziert 9/9 + Browser), Zwei Kanaele + Versionsstempel (260914-ku1, 8/8), Etappe 3c (260914-eym, 9/9), WINDOWS #29 (260914-ebg, 6/6). Naechster Schritt: Zweig live + Tag v1.0.0
Progress: [██████████] 100% Progress: [██████████] 100%
@@ -122,6 +122,7 @@ Progress: [██████████] 100%
| Phase quick-260911-cwh P01 | 21min | 3 tasks | 7 files | | Phase quick-260911-cwh P01 | 21min | 3 tasks | 7 files |
| Phase quick-260911-nke P01 | 1 Sitzung | 3 tasks | 27 files | | Phase quick-260911-nke P01 | 1 Sitzung | 3 tasks | 27 files |
| Phase quick-260914-ebg P01 | 6min | 3 tasks | 4 files | | Phase quick-260914-ebg P01 | 6min | 3 tasks | 4 files |
| Phase quick-260914-eym P01 | 1 Sitzung | 3 tasks | 29 files |
## Accumulated Context ## Accumulated Context
@@ -303,6 +304,7 @@ Recent decisions affecting current work:
- [Phase 17]: [quick-260911-cwh]: Bereich calendar Etappe 2 gebunden — Cache-Schluessel bleibt ohne Mandantenanteil (User.id ist plattformweit eindeutige UUID, Etappe-3-Entscheidung (1) betrifft nur username/email); keine neue Fehleruebersetzung fuer Besitzpruefungen noetig (Wettlauf-Fall wirft P2025, strukturell unerreichbar); refreshCacheInBackground zaehlt nicht als sechster Hintergrunddienst-Fall - [Phase 17]: [quick-260911-cwh]: Bereich calendar Etappe 2 gebunden — Cache-Schluessel bleibt ohne Mandantenanteil (User.id ist plattformweit eindeutige UUID, Etappe-3-Entscheidung (1) betrifft nur username/email); keine neue Fehleruebersetzung fuer Besitzpruefungen noetig (Wettlauf-Fall wirft P2025, strukturell unerreichbar); refreshCacheInBackground zaehlt nicht als sechster Hintergrunddienst-Fall
- [Phase 17]: 260911-nke: forTenant(prisma, tenantId, userId?) — optionaler dritter Parameter statt Schwesterhelfer, IS-NULL-OR-Form in den Regeln der zehn persoenlichen Tabellen, sechs Loch-Pruefungen umgedreht - [Phase 17]: 260911-nke: forTenant(prisma, tenantId, userId?) — optionaler dritter Parameter statt Schwesterhelfer, IS-NULL-OR-Form in den Regeln der zehn persoenlichen Tabellen, sechs Loch-Pruefungen umgedreht
- [Phase 17]: [quick-260914-ebg]: Zielrollen-Riegel als eigenstaendige Pruefung nach der Mandantengrenze in UserController.update()/remove() eingezogen (Vorlage AuthService.adminResetPassword, T-FH9-04) — WINDOWS #29 geschlossen - [Phase 17]: [quick-260914-ebg]: Zielrollen-Riegel als eigenstaendige Pruefung nach der Mandantengrenze in UserController.update()/remove() eingezogen (Vorlage AuthService.adminResetPassword, T-FH9-04) — WINDOWS #29 geschlossen
- [Phase 17]: [quick-260914-eym]: forSystem(prisma) als Schwesterhelfer (eigene Detektor-Erkennungsform, Umkehrung der 3b-Begruendung); system_read_policy FOR SELECT auf fuenf Tabellen, SmtpConfig nicht (Mail-Startpfad entfernt, Transport je Versand nach Mandant); DKV-Planer Auftrag je Mandant (promote); Single-Flight-Riegel bleibt prozessweit -> WINDOWS #37
### Pitfalls & Anti-Patterns ### Pitfalls & Anti-Patterns
@@ -399,6 +401,9 @@ None yet.
| 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) | | 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) |
| 260910-jab | Die drei zu kurz greifenden Datenbankregeln geschlossen — T-JTS-02, T-JTS-03, WINDOWS #19 (bewusste Reihenfolge-Abweichung, vorgezogen auf Nutzerwunsch, statt wie geplant nach Etappe 2). Neue, handgeschriebene, lokal angewandte Migration `20260910120000_rls_widen_membership_grant_and_platform_read`: `GroupMembership` prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer), `ModuleGrant` prueft zusaetzlich beide moeglichen Ziele mit Leer-Zulassung (D-04), `TenderRssFeedSource` bekommt vier nach Befehl getrennte Regeln (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt weiterhin einen Mandanten — die Trennung ist noetig, weil ein einzelner USING-Ausdruck sonst auch UPDATE/DELETE mitregelt). `SearchProvider` bewusst NICHT angefasst: die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile). Drei loch-behauptende Pruefungen im Wegwerf-Werkzeug UMGEKEHRT statt geloescht (66→74 Pruefungen), mit Verweis auf die alten Pruefungsnamen und Befundkennungen im Meldetext. Genau EIN Anwendungspfad musste mitgebunden werden (`TenderRssFeedSourceService.listForUser`) — sonst haette die Reparatur ihn still von 'liefert nach dem Scharfschalten nichts' auf 'liefert nur die plattformweiten Zeilen, taeuscht Vollstaendigkeit vor' verschlechtert; Falsifizierungsnachweis gefuehrt (Bindung zurueckgenommen, genau ein Test rot, zurueckgesetzt). WINDOWS #19 geschlossen mit Beleg, WINDOWS #24 neu angelegt (Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit #19). Aktenstand kohaerent: Klassifikation, Kritikschrift (neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit Signaltabelle beider Fehlerrichtungen je Regel), Betriebsanleitung, WINDOWS.md — fuenf ueberholte Bestandsstellen mit Nachtraegen versehen, alte Messprotokolle bleiben woertlich stehen. Selbst gemessen statt uebernommen: Baseline 833/56 Tests, 66/66 Live-Pruefungen; Endstand 839/56, 74/74; keine zweite Sitzungsvariable fuer den Benutzer gefunden (nur `app.current_tenant`). Rule-1-Fix: implizites `any` in `tenders.controller.ts` nach der Bindung behoben. `npx prisma` versuchte ungefragt Prisma 8 herunterzuladen — abgebrochen, lokale gepinnte 6.19.3 verwendet | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) | | 260910-jab | Die drei zu kurz greifenden Datenbankregeln geschlossen — T-JTS-02, T-JTS-03, WINDOWS #19 (bewusste Reihenfolge-Abweichung, vorgezogen auf Nutzerwunsch, statt wie geplant nach Etappe 2). Neue, handgeschriebene, lokal angewandte Migration `20260910120000_rls_widen_membership_grant_and_platform_read`: `GroupMembership` prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer), `ModuleGrant` prueft zusaetzlich beide moeglichen Ziele mit Leer-Zulassung (D-04), `TenderRssFeedSource` bekommt vier nach Befehl getrennte Regeln (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt weiterhin einen Mandanten — die Trennung ist noetig, weil ein einzelner USING-Ausdruck sonst auch UPDATE/DELETE mitregelt). `SearchProvider` bewusst NICHT angefasst: die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile). Drei loch-behauptende Pruefungen im Wegwerf-Werkzeug UMGEKEHRT statt geloescht (66→74 Pruefungen), mit Verweis auf die alten Pruefungsnamen und Befundkennungen im Meldetext. Genau EIN Anwendungspfad musste mitgebunden werden (`TenderRssFeedSourceService.listForUser`) — sonst haette die Reparatur ihn still von 'liefert nach dem Scharfschalten nichts' auf 'liefert nur die plattformweiten Zeilen, taeuscht Vollstaendigkeit vor' verschlechtert; Falsifizierungsnachweis gefuehrt (Bindung zurueckgenommen, genau ein Test rot, zurueckgesetzt). WINDOWS #19 geschlossen mit Beleg, WINDOWS #24 neu angelegt (Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit #19). Aktenstand kohaerent: Klassifikation, Kritikschrift (neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit Signaltabelle beider Fehlerrichtungen je Regel), Betriebsanleitung, WINDOWS.md — fuenf ueberholte Bestandsstellen mit Nachtraegen versehen, alte Messprotokolle bleiben woertlich stehen. Selbst gemessen statt uebernommen: Baseline 833/56 Tests, 66/66 Live-Pruefungen; Endstand 839/56, 74/74; keine zweite Sitzungsvariable fuer den Benutzer gefunden (nur `app.current_tenant`). Rule-1-Fix: implizites `any` in `tenders.controller.ts` nach der Bindung behoben. `npx prisma` versuchte ungefragt Prisma 8 herunterzuladen — abgebrochen, lokale gepinnte 6.19.3 verwendet | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
| 260914-ebg | **WINDOWS #29 geschlossen — Zielrollen-Riegel in `UserController.update()`/`remove()`.** Ein ADMIN kann den SUPER_ADMIN seines Mandanten nicht mehr aendern (Kennwort, isActive, Rolle, Anmeldename, E-Mail) oder loeschen; Riegel nach der Mandantengrenze, vor der Rollenzuweisungs-Pruefung (Vorlage `AuthService.adminResetPassword`, T-FH9-04). Acht neue Spec-Tests (8 -> 16), Baseline 1020 -> 1028 Tests / 62 Dateien, Falsifizierung durch Rueckbau `Tests 2 failed | 14 passed (16)` (Test 9/13), unabhaengig vom Verifizierer wiederholt. Kopfkommentar `adminResetPassword` nachgezogen (T-FH9-05 nicht mehr offen). Ledger 16 offen / 1 zurueckgestellt / 19 geschlossen / 36 gesamt: #29 fixed, NEU #35 (Biome-Konfiguration im Bestand nicht lauffaehig, `pnpm lint` Leerlauf) und #36 (Admin-Frontend verschluckt 403 still). Verifiziert 6/6, gepusht. | 2026-09-14 | 759ea3b,63f9df0,70d007b | [260914-ebg-windows-29-schliessen-rechteausweitung-a](./quick/260914-ebg-windows-29-schliessen-rechteausweitung-a/) | | 260914-ebg | **WINDOWS #29 geschlossen — Zielrollen-Riegel in `UserController.update()`/`remove()`.** Ein ADMIN kann den SUPER_ADMIN seines Mandanten nicht mehr aendern (Kennwort, isActive, Rolle, Anmeldename, E-Mail) oder loeschen; Riegel nach der Mandantengrenze, vor der Rollenzuweisungs-Pruefung (Vorlage `AuthService.adminResetPassword`, T-FH9-04). Acht neue Spec-Tests (8 -> 16), Baseline 1020 -> 1028 Tests / 62 Dateien, Falsifizierung durch Rueckbau `Tests 2 failed | 14 passed (16)` (Test 9/13), unabhaengig vom Verifizierer wiederholt. Kopfkommentar `adminResetPassword` nachgezogen (T-FH9-05 nicht mehr offen). Ledger 16 offen / 1 zurueckgestellt / 19 geschlossen / 36 gesamt: #29 fixed, NEU #35 (Biome-Konfiguration im Bestand nicht lauffaehig, `pnpm lint` Leerlauf) und #36 (Admin-Frontend verschluckt 403 still). Verifiziert 6/6, gepusht. | 2026-09-14 | 759ea3b,63f9df0,70d007b | [260914-ebg-windows-29-schliessen-rechteausweitung-a](./quick/260914-ebg-windows-29-schliessen-rechteausweitung-a/) |
| 260914-eym | **Etappe 3c — Systemkontext fuer die Hintergrunddienste.** Migration `20260914120000_rls_system_context_read`: `is_system_context()`, fuenf permissive `system_read_policy ... FOR SELECT` (DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch); `forSystem(prisma)` in Array-Form mit ausdruecklichem Zuruecksetzen von Mandant/Benutzer, `forTenant()` setzt `app.system_context` zurueck (kein Erben, gemessen). Sechs Faelle: DKV-Planer einmal-abfragen-viele-bedienen (Auftrag je Mandant, WINDOWS #21 fixed); Mail-Transport je Versand aus der SmtpConfig des Empfaenger-Mandanten mit unveraenderter Umgebungs-Rueckfallkette, Startpfad und Mailer-Fabrik entfallen (WINDOWS #30 fixed, SmtpConfig ohne Systemregel); ldap `getAllActiveConfigs()` und Boot-Nachverschluesselung lesen ueber Systemkontext, schreiben je Mandant gebunden; tender-digest Kandidaten und tender-matching Suchprofile ueber Systemkontext, Schleifen gebunden; admin-seed nur dokumentiert (Tenant ohne Regel). Detektor mit fuenfter Erkennungsform `forSystem(` und exakter Erlaubnisliste (falsifiziert: Fremddatei 1 rot, Zweitaufruf 2 rot). Werkzeug 203 -> 253 (`runSystemContextChecks`: ungebunden 0 / System beide Mandanten / Schreiben abgewiesen 42501 bzw. count 0 / kein Erben / pg_policies 34, 5x SELECT). Rueckbau (a) 5 rot mit gelungenem Insert, (b) 1 rot, (c1) 253 gruen + (c2) 5 rot, (d) 2/3 rot. Tests 1028 -> 1054 / 62 -> 64 Dateien, tsc 0, 29 Dateien gegen 5e0e408, Schalter AUS (Compose/.env/Schema/Lockfile unveraendert). Klassifikation 61/179/5, 72 Paare, sechs Zeilen `system-gebunden`; Kritikschrift (y1)-(y5); Auftrag 3c Erledigt. Ledger 15 offen / 1 zurueckgestellt / 21 geschlossen / 37 gesamt; NEU #37 (prozessweiter Single-Flight-Riegel `processInbox`). Verifiziert 9/9, gepusht. | 2026-09-14 | 3d64567,6e2a641,939c812 | [260914-eym-mandantentrennung-etappe-3c-systemkontex](./quick/260914-eym-mandantentrennung-etappe-3c-systemkontex/) |
| 260914-ku1 | **Zwei Auslieferungskanaele und Versionsstempel.** `main` = Beta (Etiketten `beta` + `latest`), Tag `vX.Y.Z` = Live (Etiketten `live` + `vX.Y.Z`), Zweig `live` ohne Tag nur geprueft — Entscheidung in `.gitea/scripts/publish-images.sh` (`--print-plan`), CI-Trigger `branches: [main, live]` + `tags: [v*]`, `fetch-depth: 0`. Versionsstempel `APP_VERSION/APP_CHANNEL/APP_COMMIT/APP_BUILD_TIME` als Build-Args in beide Dockerfiles (web zur Bauzeit als `NEXT_PUBLIC_APP_*`, api als Laufzeit-ENV; Vorgabe `dev`). `GET /health/version` liefert name/version/channel/commit/buildTime, Startlog `Tessera API vX (channel) commit`. Web: `app-version.ts`, `AppVersionBadge` in `sidebar.tsx` (sidebar-footer.tsx ist seit ba02b25 toter Code). `docker-compose.prod.yml`: `image: ...:${IMAGE_TAG:-beta}`. Betriebshandbuch Kapitel 9 (Zwei Kanaele, Freigabe, Hotfix ohne Datenbankaenderung, neuer Live-Server), ci-cd-setup.md auf gemessenen Stand. Falsifiziert: Build mit `v9.9.9-test live` -> Stempel in dist und Web-Bundle, ohne Args `dev`. Echter CI-Lauf 297 gruen (5:18 min), Abbilder `beta`/`latest` tragen `ea6aa99 beta`. Tests API 1054 -> 1060 / 64 -> 65 Dateien, Web 233 -> 243 / 38 -> 40, tsc 0, 20 Dateien gegen 6c19451. Offen: Zweig `live` + Tag `v1.0.0` nach dem Fehler-melden-Knopf anlegen; Handgriffe fuer den User (IMAGE_TAG je Server) im SUMMARY. Verifiziert 8/8, gepusht. | 2026-09-14 | cdb571c,9731501,ea6aa99 | [260914-ku1-zwei-auslieferungskanaele-beta-auf-main-](./quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/) |
| 260914-m97 | **Fehler-melden-Knopf.** Kaefer-Knopf in der Kopfzeile: Bildschirmfoto VOR dem Dialog (`html-to-image` 1.11.13, laengste Kante 1600 px, `computeCaptureSize`), Dialog mit Vorschau, Haekchen und "Was ist passiert?"; Fehlerpuffer (Ringpuffer 20: window.onerror, unhandledrejection, console.error, fehlgeschlagene fetch-Antworten — keine Ruempfe/Cookies/Tokens); `POST /bug-reports` als Multipart (FileInterceptor 4 MiB -> 413, PNG-Signatur -> 400, kein Empfaenger -> 409, Drossel 5/10 min -> 429, Versandfehler -> 502; Mandant/Benutzer nur aus der Sitzung); E-Mail mit PNG-Anhang und Kontext (URL, Web-/API-Version+Kanal+Commit, Browser, Fenster, Zeitpunkt, Benutzer, letzte Fehler) ueber `MailService.sendBugReport` (Anhaenge; Kennwort-Reset bleibt verschluckend). Empfaenger: neue nullable Spalte `SmtpConfig.bugReportRecipient` (Migration `20260914170000`), Feld "Fehlermeldungen an" unter Administrator -> SMTP, Rueckfall `TESSERA_BUGREPORT_TO` (docker-compose.prod.yml). Handbuecher Anwender/Administration/Betrieb. Tests API 1060 -> 1076 / 67 Dateien, Web 243 -> 260 / 43 Dateien, tsc 0, `--frozen-lockfile` 0, 35 Dateien gegen 5c42c55, vier Commits. CI-Lauf 299 gruen (zweiter Versuch, erster scheiterte an Gitea-DB). Browser-Beweis durch den Orchestrator: E-Mail mit 85-KB-PNG (ohne Dialog, OKLCH korrekt) in mailhog, 409-Pfad im Dialog. Ledger #38 (Rule-1-Fix `@Expose()`) als fixed. Verifiziert 9/9 + Browser, gepusht. | 2026-09-14 | 54121c1,60b0ee8,b41be21,77117de | [260914-m97-fehler-melden-knopf-bildschirmfoto-der-a](./quick/260914-m97-fehler-melden-knopf-bildschirmfoto-der-a/) |
## Deferred Items ## Deferred Items
@@ -422,6 +427,8 @@ vergleicht, was er gespeichert hat, mit dem, was im Verzeichnis steht. Genau so
wurden WINDOWS #4/A1 und #6b am 2026-09-07 geschlossen — Verzeichnis wurden WINDOWS #4/A1 und #6b am 2026-09-07 geschlossen — Verzeichnis
ausschliesslich gelesen. Siehe `16-LIVETEST-2026-09-07.md`. ausschliesslich gelesen. Siehe `16-LIVETEST-2026-09-07.md`.
**Entscheidung des Users vom 2026-09-14 zur Mandantenfaehigkeit (ERSETZT die Lesart vom 2026-09-07):** Der User will "vorerst von der Mandantenfaehigkeit nichts mehr wissen" — das Thema hat ihn viel Zeit gekostet und er ist darueber veraergert. Gebaut und gepusht sind Etappe 1, 2, 3b und 3c; Tessera laeuft als Ein-Firmen-System vollstaendig (alpha, BYPASSRLS, Schalter AUS), und das reicht ihm. **Etappe 3a (Anmeldenamen pro Mandant) und Etappe 4 (Scharfschalten) RUHEN auf unbestimmte Zeit** — nicht vorschlagen, nicht als "naechsten Schritt" auflisten, nicht in Zusammenfassungen als offen fuehren; die zugehoerigen Ledger-Eintraege (#18, #22, #23, #25, #26, #28, #31, #32, #33, #34, #37) bleiben stehen, werden aber nicht vorgelegt. Der Schalter bleibt AUS. Neue Funktionen werden weiterhin mandantensicher gebaut (forTenant(), wie bisher), aber ohne das Thema zu benennen. Der User erwaegt, die Mandantenfaehigkeit ganz zu streichen und stattdessen je Kunde einen eigenen Docker-Container zu betreiben, in dem er als Betreiber Module mit Lizenzanzahl freigibt — festgehalten in `.planning/todos/pending/2026-09-14-lizenzmodell-freigabe-je-server-mit-lizenzanzahl.md`. Diese Entscheidung faellt der User, wenn er sie faellen will; wir stossen sie nicht an.
**Entscheidung des Users vom 2026-09-07 zur Mandantenfaehigkeit:** Tessera wird **Entscheidung des Users vom 2026-09-07 zur Mandantenfaehigkeit:** Tessera wird
zunaechst **nur intern** eingesetzt. Die Mandantentrennung ist damit vorerst zunaechst **nur intern** eingesetzt. Die Mandantentrennung ist damit vorerst
zweitrangig — sie bleibt in der Architektur verankert und wird nicht zurueckgebaut, zweitrangig — sie bleibt in der Architektur verankert und wird nicht zurueckgebaut,
@@ -438,8 +445,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity ## Session Continuity
Last session: 2026-09-14T08:41:09.727Z Last session: 2026-09-14T15:08:28.744Z
Resumed: 2026-09-14 — Sitzung ueber /gsd-resume-work fortgesetzt; HANDOFF.json und .continue-here.md verbraucht und entfernt. Entscheidung des Users: WINDOWS #29 VOR Etappe 3c (Live-Gehen am 2026-09-15), danach 3c, dann 3a, Etappe 4 nur nach Rueckfrage. Resumed: 2026-09-14 — Sitzung ueber /gsd-resume-work fortgesetzt; #29 und 3c als /gsd-quick --validate mit voller Kette durchgefuehrt.
Stopped at: Quick 260914-ebg abgeschlossen: WINDOWS #29 geschlossen — Zielrollen-Riegel in UserController.update/remove, 16 Spec-Tests, Falsifizierung bestanden, gepusht Stopped at: 2026-09-14: Quick 260914-m97 Fehler-melden-Knopf ausgefuehrt (4 Commits 54121c1/60b0ee8/b41be21/77117de gepusht, CI-Lauf 299 nach Rerun success, :beta-Abbilder mit 77117de beta); offen: Browser-Check mit mailhog durch den Verifizierer, danach Erstfreigabe v1.0.0
Resume file: None Resume file: None
Last activity: 2026-09-14 - Completed quick task 260914-ebg: WINDOWS #29 geschlossen, Zielrollen-Riegel in UserController.update/remove Last activity: 2026-09-14 - Completed quick task 260914-m97: Fehler-melden-Knopf mit Bildschirmfoto per E-Mail, Empfaenger unter Administrator -> SMTP
+17 -3
View File
@@ -2,9 +2,9 @@
schema_version: 1 schema_version: 1
open_count: 15 open_count: 15
waived_count: 1 waived_count: 1
fixed_count: 21 fixed_count: 22
total_count: 37 total_count: 38
last_updated: 2026-09-14T09:51:24.295Z last_updated: 2026-09-14T15:17:38.808Z
--- ---
# Broken Windows Ledger # Broken Windows Ledger
@@ -52,6 +52,7 @@ last_updated: 2026-09-14T09:51:24.295Z
| 35 | quick-260914-ebg | deviation | biome.json | | Biome ist im Bestand nicht lauffaehig: biome.json traegt den in Biome 2.5.0 unbekannten Schluessel organizeImports (gehoert unter assist), Biome bricht bei jedem Aufruf mit Konfigurationsfehler ab; zusaetzlich fehlt javascript.parser.unsafeParameterDecoratorsEnabled, ohne den jeder NestJS-Parameter-Dekorator ein Parse-Fehler ist (17 allein in user.controller.ts). Der CI-Schritt Lint ruft pnpm lint = turbo lint, keine App hat ein lint-Skript - der Schritt ist ein Leerlauf, der gruen meldet. CLAUDE.md und docs/anleitung-entwicklung.md beschreiben Biome als aktives Werkzeug. Gemessen 260914-ebg; das dortige Gate lief mit einer Ersatzkonfiguration im Scratchpad, relativ zur Baseline (0 Fehler, Warnungen je Datei 22/25/20, alle noExplicitAny-Familie; biome format ebenfalls unsauber, Anfuehrungszeichen-Stil). Zu entscheiden: biome.json reparieren (organizeImports nach assist, Parser-Schalter, quoteStyle single) und ein lint-Skript je App anlegen, dann die Warnungen in einem eigenen Durchlauf abbauen oder als Regelabschaltung begruenden. | open | | 2026-09-14T08:38:04.079Z | | | 35 | quick-260914-ebg | deviation | biome.json | | Biome ist im Bestand nicht lauffaehig: biome.json traegt den in Biome 2.5.0 unbekannten Schluessel organizeImports (gehoert unter assist), Biome bricht bei jedem Aufruf mit Konfigurationsfehler ab; zusaetzlich fehlt javascript.parser.unsafeParameterDecoratorsEnabled, ohne den jeder NestJS-Parameter-Dekorator ein Parse-Fehler ist (17 allein in user.controller.ts). Der CI-Schritt Lint ruft pnpm lint = turbo lint, keine App hat ein lint-Skript - der Schritt ist ein Leerlauf, der gruen meldet. CLAUDE.md und docs/anleitung-entwicklung.md beschreiben Biome als aktives Werkzeug. Gemessen 260914-ebg; das dortige Gate lief mit einer Ersatzkonfiguration im Scratchpad, relativ zur Baseline (0 Fehler, Warnungen je Datei 22/25/20, alle noExplicitAny-Familie; biome format ebenfalls unsauber, Anfuehrungszeichen-Stil). Zu entscheiden: biome.json reparieren (organizeImports nach assist, Parser-Schalter, quoteStyle single) und ein lint-Skript je App anlegen, dann die Warnungen in einem eigenen Durchlauf abbauen oder als Regelabschaltung begruenden. | open | | 2026-09-14T08:38:04.079Z | |
| 36 | quick-260914-ebg | deviation | apps/web/src/app/(portal)/admin/users/page.tsx | | handleSubmit und handleDelete pruefen nur res.ok ohne else-Zweig und fangen mit leerem catch - ein 403 der API fuehrt zu keiner sichtbaren Reaktion (Formular bleibt offen, Loeschdialog bleibt stehen, keine Meldung). Bestehendes Verhalten fuer alle 403-Wege (fremder Mandant, Selbstloeschung); seit 260914-ebg (WINDOWS #29) ist der Fall fuer einen ADMIN im Alltag erreichbar, weil die SUPER_ADMIN-Zeile in der eigenen Benutzerliste steht und Aendern/Loeschen darauf jetzt 403 liefert. Familie der still verschluckten Antworten (#28, #32). Frontend von 260914-ebg NICHT geaendert (ausserhalb der Erlaubnisliste). Zu schliessen: Fehlermeldung aus dem Antwortrumpf anzeigen und die Aktionsknoepfe fuer SUPER_ADMIN-Zeilen einem ADMIN gar nicht erst anbieten. | open | | 2026-09-14T08:38:12.619Z | | | 36 | quick-260914-ebg | deviation | apps/web/src/app/(portal)/admin/users/page.tsx | | handleSubmit und handleDelete pruefen nur res.ok ohne else-Zweig und fangen mit leerem catch - ein 403 der API fuehrt zu keiner sichtbaren Reaktion (Formular bleibt offen, Loeschdialog bleibt stehen, keine Meldung). Bestehendes Verhalten fuer alle 403-Wege (fremder Mandant, Selbstloeschung); seit 260914-ebg (WINDOWS #29) ist der Fall fuer einen ADMIN im Alltag erreichbar, weil die SUPER_ADMIN-Zeile in der eigenen Benutzerliste steht und Aendern/Loeschen darauf jetzt 403 liefert. Familie der still verschluckten Antworten (#28, #32). Frontend von 260914-ebg NICHT geaendert (ausserhalb der Erlaubnisliste). Zu schliessen: Fehlermeldung aus dem Antwortrumpf anzeigen und die Aktionsknoepfe fuer SUPER_ADMIN-Zeilen einem ADMIN gar nicht erst anbieten. | open | | 2026-09-14T08:38:12.619Z | |
| 37 | quick-260914-eym | deviation | apps/api/src/dkv/dkv.service.ts | | Der Single-Flight-Riegel processing in DkvService.processInbox ist EIN prozessweites Boolean, nicht je Mandant. Seit 260914-eym laeuft je aktivem Mandanten ein eigener Cron-Auftrag (dkv-inbox-poll:<tenantId>); ueberschneiden sich zwei Ticks verschiedener Mandanten, bricht der zweite still ab (Warnzeile 'already processing') und der Mandant wartet bis zum naechsten Intervall - kein Datenverlust, Verzoegerung; mit EINEM Mandanten unveraendert. Der Tick blieb in 3c laut Auftrag unangetastet (T-EYM-09, accept mit Aufzeichnung). Zu schliessen: Riegel je Mandant (Set<tenantId>) mit Test 'zwei Mandanten gleichzeitig, beide werden bedient'. | open | | 2026-09-14T09:51:24.295Z | | | 37 | quick-260914-eym | deviation | apps/api/src/dkv/dkv.service.ts | | Der Single-Flight-Riegel processing in DkvService.processInbox ist EIN prozessweites Boolean, nicht je Mandant. Seit 260914-eym laeuft je aktivem Mandanten ein eigener Cron-Auftrag (dkv-inbox-poll:<tenantId>); ueberschneiden sich zwei Ticks verschiedener Mandanten, bricht der zweite still ab (Warnzeile 'already processing') und der Mandant wartet bis zum naechsten Intervall - kein Datenverlust, Verzoegerung; mit EINEM Mandanten unveraendert. Der Tick blieb in 3c laut Auftrag unangetastet (T-EYM-09, accept mit Aufzeichnung). Zu schliessen: Riegel je Mandant (Set<tenantId>) mit Test 'zwei Mandanten gleichzeitig, beide werden bedient'. | open | | 2026-09-14T09:51:24.295Z | |
| 38 | quick-260914-m97 | deviation | apps/api/src/bug-reports/dto/bug-report.dto.ts | 71 | Rule 1: @Expose() auf errors ergaenzt, damit die @Transform-Normalisierung auch bei ganz fehlendem Multipart-Feld greift (class-transformer transformiert nur vorhandene Schluessel) | fixed | | 2026-09-14T15:04:31.846Z | 2026-09-14T15:17:38.808Z |
````json ````json
[ [
@@ -501,6 +502,19 @@ last_updated: 2026-09-14T09:51:24.295Z
"recorded_at": "2026-09-14T09:51:24.295Z", "recorded_at": "2026-09-14T09:51:24.295Z",
"resolved_at": null, "resolved_at": null,
"milestone": "v1.2" "milestone": "v1.2"
},
{
"id": 38,
"kind": "deviation",
"phase": "quick-260914-m97",
"file": "apps/api/src/bug-reports/dto/bug-report.dto.ts",
"line": 71,
"description": "Rule 1: @Expose() auf errors ergaenzt, damit die @Transform-Normalisierung auch bei ganz fehlendem Multipart-Feld greift (class-transformer transformiert nur vorhandene Schluessel)",
"status": "fixed",
"reason": "",
"recorded_at": "2026-09-14T15:04:31.846Z",
"resolved_at": "2026-09-14T15:17:38.808Z",
"milestone": "v1.2"
} }
] ]
```` ````
@@ -0,0 +1,275 @@
---
phase: quick-260914-eym
plan: 01
subsystem: mandantentrennung
tags: [rls, systemkontext, forSystem, dkv, mail, ldap, tenders, windows-21, windows-30]
status: complete
requires: [quick-260911-nke]
provides: [forSystem, is_system_context, system_read_policy, dkv-auftrag-je-mandant, mail-transport-je-versand]
affects: [etappe-4]
tech-stack:
added: []
patterns: [Systemkontext-Schwesterhelfer forSystem(prisma), FOR-SELECT-Systemleseregel, Erlaubnisliste mit exakter Zahl je Datei, Transport je Versand nach Mandant]
key-files:
created:
- apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql
- apps/api/src/dkv/dkv-scheduler.service.spec.ts
- apps/api/src/mail/mail.service.spec.ts
modified:
- apps/api/src/prisma/prisma-tenant.extension.ts
- apps/api/src/prisma/prisma-tenant.extension.spec.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- apps/api/src/groups/migration-sql.spec.ts
- apps/api/scripts/rls-scratch-check.mjs
- apps/api/src/dkv/dkv.service.ts
- apps/api/src/dkv/dkv.service.spec.ts
- apps/api/src/dkv/dkv-scheduler.service.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/api/src/mail/mail.module.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/settings/settings.service.ts
- apps/api/src/settings/settings.service.spec.ts
- apps/api/src/auth/auth.service.ts
- apps/api/src/auth/auth.service.spec.ts
- apps/api/src/ldap/ldap-config.service.ts
- apps/api/src/ldap/ldap-config.service.spec.ts
- apps/api/src/tenders/tender-digest.scheduler.ts
- apps/api/src/tenders/tender-digest.scheduler.spec.ts
- apps/api/src/tenders/tender-matching.service.ts
- apps/api/src/tenders/tender-matching.service.spec.ts
- apps/api/src/tenders/tender-notifications.integration.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/mandantentrennung-etappe2-fehlerrichtung.md
- docs/mandantentrennung-etappe3-auftrag.md
- docs/mandantentrennung-datenbankrolle.md
- .planning/WINDOWS.md
decisions:
- "forSystem(prisma) als Schwesterhelfer statt viertem Parameter — eigene Zugriffsklasse, eigene Erkennungsform im Detektor (Umkehrung der 3b-Begruendung)"
- "system_read_policy FOR SELECT auf genau fuenf Tabellen; SmtpConfig bekommt keine, weil der Mail-Startpfad entfernt statt umgestellt wurde"
- "DKV-Planer: Auftrag je Mandant (promote, nicht add-alongside) — Einzahl-Feld activeTenantId ersatzlos entfernt"
- "Mail: Transport je Versand nach Mandant des Empfaengers; Umgebungs-Kette nur Rueckfall fuer Mandanten ohne SmtpConfig"
- "admin-seed nur dokumentiert (liest ausserhalb der Schleife nur Tenant, keine Regel) — Datei unveraendert"
- "Single-Flight-Riegel processInbox bleibt prozessweit — WINDOWS #37 statt Umbau (Auftrag: Tick unangetastet)"
metrics:
duration: "1 Sitzung (2026-09-14, ca. 11:10-12:05)"
completed: 2026-09-14
actuals:
tokens: 58868
tasks: 3
commits: 3
plan_head_before: 02016e19ebdbdf703465fa55baa945bf71f0b334
---
# Quick 260914-eym: Mandantentrennung Etappe 3c — Systemkontext fuer die Hintergrunddienste — Summary
Benannter Systemkontext `forSystem(prisma)` mit `is_system_context()` und je einer nur lesenden `system_read_policy FOR SELECT` auf fuenf Tabellen; alle sechs Hintergrunddienst-Faelle behandelt (DKV-Planer je Mandant, Mail-Transport je Versand mit entferntem Startpfad, ldap/digest/matching ueber den Systemkontext, admin-seed dokumentiert); Detektor mit fuenfter Erkennungsform und falsifizierbarer Erlaubnisliste; Werkzeug 203 -> 253; WINDOWS #21 und #30 geschlossen, #37 neu. Der Schalter bleibt AUS.
## Commits
```
939c812 docs(quick-260914-eym): Etappe 3c abgeschlossen — Kritikschrift, Klassifikation, Auftrag, Datenbankrolle, WINDOWS #21/#30 geschlossen, Single-Flight-Riegel als Eintrag
6e2a641 feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig
3d64567 feat(quick-260914-eym): forSystem(), is_system_context(), Systemleseregel auf fuenf Tabellen, DKV-Planer je Mandant — ein Pfad (WINDOWS #21)
```
`git log --oneline 02016e1..HEAD` (oben, drei Commits). `git rev-list --count 02016e1..HEAD` = 3. Gepusht: `git push` -> `5e0e408..939c812 main -> main`; `git fetch -q && git status -sb | head -1` -> `## main...origin/main`; `git rev-parse HEAD` == `git rev-parse origin/main`.
`git status --porcelain` vor dem Schreiben dieser SUMMARY: leer (keine Ausgabe).
Hinweis zum Branch: das Projekt committet seit jeher direkt auf `main` (alle Quick-Tasks, Plan-Gate `HEAD == origin/main`, Auftrag "plain `git push`") — dem Projekt-Workflow gefolgt; `git.allow_default_branch_commits` ist in `.planning/config.json` nicht gesetzt.
## Gemessene Zahlen (beobachtet, nicht abgeschrieben)
| Messpunkt | Baseline (HEAD 5e0e408/02016e1) | nach Aufgabe 1 (3d64567) | nach Aufgabe 2 (6e2a641) | Ende (939c812) |
|---|---|---|---|---|
| `npm --prefix apps/api run test` — Test Files | 62 | 63 | 64 | 64 |
| Tests | 1028 | 1051 | 1054 | 1054 |
| `npm --prefix apps/api run type-check` Exit | 0 | 0 | 0 | 0 |
| `rls-scratch-check.mjs` Schlusszeile | Alle 203 Pruefungen bestanden. | Alle 216 Pruefungen bestanden. | Alle 253 Pruefungen bestanden. | 253 (kein apps-Diff seit Aufgabe 2, Gate `git diff --stat HEAD~1 -- apps` leer) |
| `prisma migrate status` | 35 Migrationen, up to date | 36 Migrationen, up to date | 36 | 36 |
| `pg_proc` `is_system_context` | 0 | 1 | 1 | 1 |
| `pg_policies` public gesamt / `system_read_policy` | 29 / 0 | 34 / 5 | 34 / 5 | 34 / 5 |
| `git diff --stat 5e0e408 -- . ':!.planning'` Dateien | — | — | — | 29 (`29 files changed, 2499 insertions(+), 491 deletions(-)`) |
| Ledger (aus Zeilen gezaehlt) | open 16 / waived 1 / fixed 19 / total 36 | — | — | open 15 / waived 1 / fixed 21 / total 37 (Frontmatter identisch) |
Plan-Erwartung vs. beobachtet: Tests erwartet >= 1040 / >= 1044, beobachtet 1051 / 1054; Werkzeug erwartet >= 216 / >= 250 (abgeleitet 216 / 253), beobachtet exakt 216 / 253; Uebersichtstabelle erwartet 61/179/5 (tenders 33/27/2, ldap 1/27/2, dkv 0/22/1, settings 0/3/0), mit der Gate-Schleife nachgerechnet: identisch.
Umgebung: Container `tessera-ctl-db-1` lief beim Einstieg bereits (`Up 25 minutes (healthy)`, vom Planer gestartet); IP per `docker inspect` 172.19.0.2; DB-Zugang `tessera:tessera_dev`; Prisma-Binary `apps/api/node_modules/.bin/prisma`. Schalter-Gate: `git diff --name-only 5e0e408` nennt keine Compose-, `.env`-, `schema.prisma`-, `package.json`-, Lockfile-, `rls-preflight.mjs`- oder `admin-seed.service.ts`-Datei (in jedem der drei Gates geprueft).
## [BLOCKING] Migration lokal angewendet — woertliche Ausgabe
`cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/tessera" ./node_modules/.bin/prisma migrate deploy`:
```
The following migration(s) have been applied:
migrations/
└─ 20260914120000_rls_system_context_read/
└─ migration.sql
All migrations have been successfully applied.
```
`migrate status`: `36 migrations found in prisma/migrations` / `Database schema is up to date!`
`pg_proc` / `pg_policies` (Tabelle#Regelname#Befehl#PERMISSIV#USING#WITH CHECK):
```
pg_proc is_system_context = 1
DkvModuleConfig#system_read_policy#SELECT#PERMISSIVE#is_system_context()#
DkvModuleConfig#tenant_isolation_policy#ALL#PERMISSIVE#("tenantId" = current_tenant_id())#
LdapConfig#system_read_policy#SELECT#PERMISSIVE#is_system_context()#
LdapConfig#tenant_isolation_policy#ALL#PERMISSIVE#("tenantId" = current_tenant_id())#
LdapFieldMapping#system_read_policy#SELECT#PERMISSIVE#is_system_context()#
LdapFieldMapping#tenant_isolation_policy#ALL#PERMISSIVE#("ldapConfigId" IN ( SELECT "LdapConfig".id FROM "LdapConfig" WHERE ("LdapConfig"."tenantId" = current_tenant_id())))#
SmtpConfig#tenant_isolation_policy#ALL#PERMISSIVE#("tenantId" = current_tenant_id())#
TenderMatch#system_read_policy#SELECT#PERMISSIVE#is_system_context()#
TenderMatch#tenant_isolation_policy#ALL#PERMISSIVE#("tenantId" = current_tenant_id())#
TenderSavedSearch#system_read_policy#SELECT#PERMISSIVE#is_system_context()#
TenderSavedSearch#tenant_isolation_policy#ALL#PERMISSIVE#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
system_read_policy gesamt = 5
policies public gesamt = 34
```
## Werkzeug — die vier Funktionsfaelle (woertlich, Lauf nach Aufgabe 2)
```
is-system-context-ungesetzt-false: bestanden — ohne gesetzte Variable: is_system_context() = false (Rohwert null) — die Vorher-Pruefung ohne-kontext-leer in rls-preflight.mjs bleibt gueltig
is-system-context-leer-false: bestanden — nach set_config('app.system_context', '', true): false
is-system-context-true-true: bestanden — nach set_config('app.system_context', 'true', true): true
is-system-context-fremdwert-false: bestanden — nach set_config('app.system_context', 'yes', true): false
```
Je Tabelle (dkvmoduleconfig, ldapconfig, ldapfieldmapping, tendermatch, tendersavedsearch) neun Kennungen gruen, plus `ldapconfig-systemkontext-include-fieldmappings-beider-mandanten: bestanden — ... liefert 2 Zeile(n): ["TENANT-A:1","TENANT-B:1"]`. Die vollstaendigen Zeilen stehen in `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt (y1).
## Falsifizierung durch Rueckbau — woertliche Ausgaben
Jeder Rueckbau wurde ausgefuehrt, das rote Ergebnis protokolliert, die Datei restauriert (`git checkout -- <Datei>` fuer committete Dateien; fuer die in Aufgabe 2 noch uncommitteten Dateien Werkzeug/Detektor per Kopie mit identischem SHA-256-Praefix `a1f845ba787cac52` bzw. `d9595ef09df57fce`) und der gruene Zustand erneut gemessen (Werkzeug 253, Detektor 30/30, `git status --short` danach nur die gewollten Aufgabe-2-Dateien).
**(a) `FOR SELECT` bei `"TenderMatch"` in der Migrationsdatei entfernt** (Regel wird ALL):
```
tendermatch-systemkontext-insert-abgewiesen-42501: FEHLGESCHLAGEN — system.tenderMatch.create({"id":"tm-system-schreibversuch","tenderId":"tender-2","savedSearchId":"ss-a","userId":"user-a","tenantId":"TENANT-A"}) ist NICHT fehlgeschlagen — angelegt: "tm-system-schreibversuch"
tendermatch-systemkontext-updatemany-count-0: FEHLGESCHLAGEN — system.tenderMatch.updateMany({ where: {}, data: {"notifiedChannel":"SYSTEM-SCHREIBVERSUCH"} }) liefert count=3
tendermatch-systemkontext-deletemany-count-0: FEHLGESCHLAGEN — system.tenderMatch.deleteMany({}) liefert count=3; Zeilen danach (Wartungsrolle): 0
tendermatch-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).tenderMatch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 0 Zeile(n) aus []
tendermatch-pg-policies-genau-eine-system-read-policy-select: FEHLGESCHLAGEN — pg_policies fuer "TenderMatch" (system_read_policy): [{"policyname":"system_read_policy","cmd":"ALL","permissive":"PERMISSIVE","qual":"is_system_context()"}]
5 von 253 Pruefungen fehlgeschlagen.
```
Plan erwartete: `…-insert-abgewiesen-42501` rot. Beobachtet: 5 rot — der Insert gelingt, danach auch updateMany/deleteMany (count 3, Zeilen 0), deshalb liefert der Folgeschritt `…-nur-a` 0 Zeilen, und `pg_policies` zeigt `ALL`. Die Kernaussage (Insert GELINGT ohne `FOR SELECT`) ist woertlich belegt.
**(b) `system_read_policy` fuer `"TenderSavedSearch"` aus der Migrationsdatei entfernt:**
```
tendersavedsearch-system-read-policy-aus-migration-gefunden: FEHLGESCHLAGEN — CREATE POLICY system_read_policy ON "TenderSavedSearch" nicht in der Systemkontext-Migration (20260914120000) gefunden
1 von 245 Pruefungen fehlgeschlagen.
```
Lebende Datenbank waehrend des Rueckbaus (per `pg_policies`): `policies public gesamt = 34 | system_read_policy auf TenderSavedSearch = 1`. Plan erwartete: `…-sieht-beide-mandanten` und `…-pg-policies-genau-eine-…` rot. Beobachtet: die innere Routine bricht fuer diese Tabelle mit einer eigenen roten Extraktions-Kennung ab (Muster `runSingleRulePersonalTableCheck`: nicht raten, wenn die Regel in der Migration fehlt), die neun Kennungen der Tabelle laufen nicht (253 -> 245). Die Aussage des Plans — das Werkzeug misst die geschnittene Regel, nicht die lebende Datenbank, und die Datenbank bleibt bei 34 — ist belegt; die Form des Rotwerdens ist strenger als erwartet, nicht lockerer. Beide Zahlen (erwartet 2 rote Kennungen von 253; beobachtet 1 rote von 245) stehen hier und in (y2).
**(c) `local=false` in `forSystemQuery`/`buildInlineSystemClient` (Werkzeug):** `Alle 253 Pruefungen bestanden.` — alle fuenf `…-fortenant-a-nach-systemkontext-nur-a` bleiben gruen, der Reset in `buildInlineExtendedClient` traegt. **Zusaetzlich den Reset dort entfernt:**
```
dkvmoduleconfig-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).dkvModuleConfig.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
ldapconfig-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).ldapConfig.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
ldapfieldmapping-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).ldapFieldMapping.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
tendermatch-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).tenderMatch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
tendersavedsearch-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).tenderSavedSearch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
5 von 253 Pruefungen fehlgeschlagen.
```
(`…-is-system-context-unter-fortenant-false` blieb gruen, weil diese Kennung ihre Transaktion mit eigenem Reset-Literal baut, nicht ueber `buildInlineExtendedClient`.)
**(d) Detektor — Zahl fuer `tender-matching.service.ts` auf 0:**
```
AssertionError: apps/api/src/tenders/tender-matching.service.ts: gemessen 1 forSystem(-Aufruf(e), erlaubt sind genau 0: expected [ Array(1) ] to deeply equal []
AssertionError: apps/api/src/tenders/tender-matching.service.ts: Erlaubnisliste nennt 0, gemessen 1 — der Eintrag ist ueberholt: expected [ Array(1) ] to deeply equal []
Tests 2 failed | 28 passed (30)
```
**Fremddatei `admin-seed.service.ts` voruebergehend mit `forSystem(` versehen:**
```
AssertionError: apps/api/src/user/admin-seed.service.ts: 2 forSystem(-Aufruf(e), Datei steht NICHT in FORSYSTEM_ALLOWED_CALL_SITES — ein Anfrageweg darf den Systemkontext nie rufen: expected [ Array(1) ] to deeply equal []
AssertionError: apps/api/src/user/admin-seed.service.ts: 1 forSystem(-Aufruf(e) ausserhalb der Zuweisungsform: expected [ Array(1) ] to deeply equal []
AssertionError: apps/api/src/user/admin-seed.service.ts: 1 include:/select:/_count:-Angabe(n) ausserhalb eines erkannten Modellaufrufs: expected [ Array(1) ] to deeply equal []
Tests 3 failed | 27 passed (30)
```
Danach `git checkout -- apps/api/src/user/admin-seed.service.ts`, `git diff --quiet 5e0e408 -- apps/api/src/user/admin-seed.service.ts` -> unveraendert.
Ausserdem ein nicht geplanter Beleg fuer den Veraltet-Wachhund: in Aufgabe 1 war die Erlaubnisliste bereits mit `dkv.service.ts: 1` gefuellt, bevor `dkv.service.ts` umgestellt war — die Spec wurde rot (`Erlaubnisliste nennt 1, gemessen 0 — der Eintrag ist ueberholt`) und erst mit der Umstellung gruen.
## Identitaet mit einem Mandanten (morgen alpha, BYPASSRLS) — je Pfad als Test
- dkv (`dkv-scheduler.service.spec.ts`, 7 Tests): ein aktiver Mandant, pollIntervalMin 15 -> genau ein Auftrag `dkv-inbox-poll:t1`, `cronTime.source === '*/15 * * * *'`, `isActive` true; 120 -> `0 */2 * * *`; `fireOnTick()` ruft `processInbox('t1')` genau einmal; inaktive/keine Config -> kein Auftrag, Protokollzeile `DKV scheduler: no active config found — cron job not registered`; zwei Mandanten -> zwei Auftraege, `setInterval(30,'t2')` laesst das t1-Objekt identisch, `stopJob('t1')` entfernt nur t1; werfender Startpfad -> `DKV scheduler init failed: db down`, kein Auftrag; `stopJob` unbekannt -> No-Op.
- mail (`mail.service.spec.ts`, 4 Tests): Mandant MIT SmtpConfig -> `getDecryptedSmtpConfig('t1')` genau einmal, `createTransport({host:'smtp-a.example.invalid',port:465,secure:true,requireTLS:false,auth:{user:'user-a',pass:'geheim-a'}})`, `from` = `noreply@a.example.invalid`, `close()` einmal; ohne SmtpConfig -> MAIL_* vor TESSERA_SMTP_* vor `localhost:1025`, `from` aus TESSERA_SMTP_FROM bzw. `Tessera <tessera@tessera.local>`; zwei Mandanten -> zwei Transporte, keiner enthaelt das Kennwort des anderen; `sendMail` wirft -> kein Throw, Protokoll ohne Kennwort, `close()` trotzdem.
- ldap/digest/matching: bestehende Verhaltenstests unveraendert gruen (ldap 92, tenders 413 Tests in den Bereichen), plus je eine Zusicherung `forSystem` genau einmal und `forTenant` genauso oft wie bisher (ldap: `getAllActiveConfigs` forSystem 1 / forTenant 0; Bootstrap leer: forSystem 1 / forTenant 0; Bootstrap mit Altzeile t1: forTenant genau einmal mit `t1`, `update` traegt `aa11:bb22:<hex>`; digest: `__systemCallLog` genau `[tenderMatch.findMany]`, forTenant weiter genau einmal; matching: `__systemCallLog` genau `[tenderSavedSearch.findMany]`, `tender.findMany` weiter auf dem rohen Client).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Gate-Zaehlung `const systemPrisma = forSystem(this.prisma)` traf die Proben der Detektor-Spec**
- **Found during:** Aufgabe 2, Gate-Lauf
- **Issue:** Das Gate zaehlt `grep -rh … | grep -v spec` — `-h` laesst den Dateinamen weg, `spec` steht nicht im Zeilentext, deshalb zaehlten die drei Proben C/D1/D2 mit (8 statt 5). Der Plan verlangt die Probe C mit genau diesem Text UND das Gate mit genau 5 — in sich widerspruechlich.
- **Fix:** Empfaengername in den drei Proben auf `sysPrisma` geaendert (Regex des Detektors ist `const\s+(\w+)\s*=\s*forSystem\(` — die Probe prueft weiterhin dieselbe Form und belegt zusaetzlich, dass der Name nicht hartkodiert ist); Gate unveraendert, Zahl unveraendert (5).
- **Files modified:** `apps/api/src/prisma/rls-access-inventory.spec.ts`
- **Commit:** 6e2a641. Als Falle in `docs/mandantentrennung-etappe3-auftrag.md` ("Werkzeuge und Fallen") eingetragen.
**2. [Rule 3 - Blocking] Kopfkommentar `dkv-scheduler.service.ts` nannte `forSystem()` als Text**
- **Found during:** Aufgabe 2, Gate `test 4 -eq <Dateien mit forSystem(>`
- **Issue:** Das Gate zaehlt Dateien mit dem Text `forSystem(` auch in Kommentaren; der in Aufgabe 1 geschriebene Kopfkommentar nannte den Helfer.
- **Fix:** Kommentar umformuliert ("systemgebunden ueber den Systemkontext-Helfer"). Datei steht in `files_modified` des Plans (Aufgabe-1-Liste), Aenderung im Aufgabe-2-Commit.
- **Files modified:** `apps/api/src/dkv/dkv-scheduler.service.ts`
- **Commit:** 6e2a641
**3. [Rule 3 - Blocking] Spec-Kopfkommentar nannte den geloeschten Methodennamen**
- **Found during:** Aufgabe 2 (eigener Assert vor dem Gate)
- **Issue:** Das Gate verlangt null Treffer `loadAnySmtpConfigForStartupTransport` in vier Dateien, auch in Kommentaren; mein erster Entwurf des Spec-Kopfkommentars nannte ihn.
- **Fix:** Umschrieben ("der ungebundene Startpfad des Mailmoduls").
- **Files modified:** `apps/api/src/settings/settings.service.spec.ts`
- **Commit:** 6e2a641
**4. [Rule 1 - Bug] `pg_policies`-Form in (y1)**
- **Found during:** Aufgabe 3, Gate
- **Issue:** Ich hatte die Zeilen mit sechs Spalten (inkl. PERMISSIV) geschrieben; das Gate erwartet die 3b-Form `Tabelle#Regelname#Befehl#USING#WITH CHECK`.
- **Fix:** Zeilen auf die 3b-Form gebracht (die PERMISSIV-Eigenschaft steht im Satz davor).
- **Files modified:** `docs/mandantentrennung-etappe2-fehlerrichtung.md`
- **Commit:** 939c812
### Abweichungen zum Auftrag (bewusst, im Plan so vorgesehen)
- **Fuenf statt sechs Tabellen:** SmtpConfig traegt keine `system_read_policy`, weil der Mail-Startpfad ENTFERNT wurde (Transport je Versand nach Mandant des Empfaengers), nicht auf den Systemkontext umgestellt.
- **ldap hat ZWEI Systemkontext-Leser:** `getAllActiveConfigs()` und die Nachverschluesselung in `onApplicationBootstrap()` (je eigene Zuweisung, der Detektor zaehlt 2); die Schreibzeile je Altzeile laeuft ueber `forTenant(this.prisma, config.tenantId)`.
- **Mail-Startpfad entfernt statt umgestellt:** `MailerModule.forRootAsync` und `loadAnySmtpConfigForStartupTransport()` samt vier Spec-Tests geloescht; `@nestjs-modules/mailer` bleibt in `package.json`/Lockfile installiert, ist aber unbenutzt (kein Lockfile-Eingriff in diesem Durchlauf).
- **Container:** musste NICHT gestartet werden — er lief beim Einstieg bereits (vom Planer gestartet, `Up 25 minutes (healthy)`).
- **Rueckbau (b):** rot in strengerer Form als im Plan beschrieben (Extraktions-Abbruch statt zwei rote Messkennungen), siehe oben.
- **Doppelte Kennung im Werkzeug:** `dkvmoduleconfig-ungebunden-null-zeilen` gibt es jetzt zweimal (einmal aus `runDkvAreaChecks`, einmal aus dem neuen Abschnitt) — der Plan schreibt den Namen vor; beide gruen, das Gate greift per `^…: bestanden`.
## Was bewusst offen bleibt
- **WINDOWS #37 (neu, open):** Der Single-Flight-Riegel `processing` in `DkvService.processInbox` ist EIN prozessweites Boolean. Seit je aktivem Mandanten ein eigener Cron-Auftrag laeuft, bricht bei Ueberschneidung zweier Ticks verschiedener Mandanten der zweite still ab (Warnzeile `already processing`) und wartet bis zum naechsten Intervall — kein Datenverlust, Verzoegerung; mit einem Mandanten unveraendert (T-EYM-09, accept mit Aufzeichnung). Loesungsweg: Riegel je Mandant (`Set<tenantId>`) mit Test "zwei Mandanten gleichzeitig, beide werden bedient".
- `sendWelcomeEmail` hat weiterhin null Aufrufer; `@nestjs-modules/mailer` unbenutzt in `package.json` — Aufraeumen, kein Defekt.
- Digest-Sonderfall "Nutzer mit Treffern unter zwei Mandanten" (`distinct: ['userId']`) bleibt wie in (t4) beschrieben.
- `rls-preflight.mjs` bekommt in Etappe 4 die Pruefung `mit-systemkontext-sichtbar`; `ohne-kontext-leer` bleibt gueltig (Beleg `is-system-context-ungesetzt-false`, Rohwert `null` -> `false`).
- Etappe 3a (Anmeldenamen pro Mandant) und Etappe 4 (Scharfschalten) — unveraendert offen.
## Was ohne den User nicht geht
Nichts Neues. Wie im Auftrag: 3a Weg (i) vs. (ii) (wie der Mandant beim Login bestimmt wird) und Etappe 4 (Scharfschalten, `DATABASE_URL` auf `tessera_app`) bleiben Rueckfragen. Dieser Durchlauf hat den Schalter nicht angefasst: keine Compose-Datei, keine `.env`, nichts auf einem Server, nichts in Active Directory.
## Threat Flags
Keine neue Angriffsflaeche ausserhalb des `<threat_model>` des Plans: kein neuer Netzwerk-Endpunkt, kein neuer Auth-Pfad, keine Schemaaenderung an Vertrauensgrenzen (nur zusaetzliche, nur lesende Regeln plus eine Funktion ohne SECURITY DEFINER). T-EYM-01 bis T-EYM-08 mitigiert wie geplant (Belege oben), T-EYM-09 accept mit Ledger-Eintrag #37, T-EYM-SC: keine Paketinstallation, `package.json`/Lockfile unveraendert gegen 5e0e408.
## Known Stubs
Keine. `sendWelcomeEmail` ist kein Stub (vollstaendig implementiert, nur ohne Aufrufer — seit vor diesem Durchlauf).
## Self-Check: PASSED
- Dateien: `apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql`, `apps/api/src/dkv/dkv-scheduler.service.spec.ts`, `apps/api/src/mail/mail.service.spec.ts` — FOUND (im Commit-Baum, `git diff --stat 5e0e408` nennt 29 Dateien).
- Commits 3d64567, 6e2a641, 939c812 — FOUND (`git log --oneline 02016e1..HEAD`), gepusht (`HEAD == origin/main`).
@@ -0,0 +1,199 @@
---
phase: quick-260914-eym
verified: 2026-09-14T12:40:00Z
status: passed
score: 9/9 must-haves verified
covered_files:
- .planning/WINDOWS.md
- .planning/quick/260914-eym-mandantentrennung-etappe-3c-systemkontex/260914-eym-PLAN.md
- .planning/quick/260914-eym-mandantentrennung-etappe-3c-systemkontex/260914-eym-SUMMARY.md
- apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql
- apps/api/scripts/rls-scratch-check.mjs
- apps/api/src/auth/auth.service.spec.ts
- apps/api/src/auth/auth.service.ts
- apps/api/src/dkv/dkv-scheduler.service.spec.ts
- apps/api/src/dkv/dkv-scheduler.service.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/api/src/dkv/dkv.service.spec.ts
- apps/api/src/dkv/dkv.service.ts
- apps/api/src/groups/migration-sql.spec.ts
- apps/api/src/ldap/ldap-config.service.spec.ts
- apps/api/src/ldap/ldap-config.service.ts
- apps/api/src/mail/mail.module.ts
- apps/api/src/mail/mail.service.spec.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/prisma/prisma-tenant.extension.spec.ts
- apps/api/src/prisma/prisma-tenant.extension.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- apps/api/src/settings/settings.service.spec.ts
- apps/api/src/settings/settings.service.ts
- apps/api/src/tenders/tender-digest.scheduler.spec.ts
- apps/api/src/tenders/tender-digest.scheduler.ts
- apps/api/src/tenders/tender-matching.service.spec.ts
- apps/api/src/tenders/tender-matching.service.ts
- apps/api/src/tenders/tender-notifications.integration.spec.ts
- docs/mandantentrennung-datenbankrolle.md
- docs/mandantentrennung-etappe2-fehlerrichtung.md
- docs/mandantentrennung-etappe3-auftrag.md
- docs/mandantentrennung-zugriffsklassifikation.md
covered_digest: "v1:sha256:d81ca7fd6f1c5dd2e90f4b70f943467888d52417316824523b993980e9607e0e"
behavior_unverified: 0
overrides_applied: 0
---
# Quick 260914-eym: Mandantentrennung Etappe 3c — Systemkontext fuer die Hintergrunddienste — Verifikation
**Ziel:** Benannter Systemkontext `forSystem()` fuer die Hintergrunddienste: dritte Sitzungsvariable `app.system_context`, Funktion `is_system_context()`, neue Migration `20260914120000_rls_system_context_read` mit fuenf permissiven `system_read_policy ... FOR SELECT` (bestehende Migrationen unveraendert), Detektor mit fuenfter Erkennungsform und exakter Erlaubnisliste, Werkzeugabschnitt `runSystemContextChecks`, die sechs Faelle behandelt, Dokumente nachgezogen, Ledger #21/#30 fixed, #37 neu, gepusht. Der Schalter bleibt AUS.
**Verifiziert:** 2026-09-14, ca. 12:10-12:40 (HEAD 939c812, Arbeitsbaum nur mit den Orchestrator-Aenderungen `.planning/STATE.md` und der neuen SUMMARY)
**Status:** passed
**Erneute Verifikation:** Nein — Erstverifikation
Grundhaltung: Die SUMMARY wurde nicht als Beleg genommen. Jede Zahl unten ist in dieser Sitzung selbst gemessen (Kommando und beobachtetes Ergebnis stehen dabei). Wo ich etwas absichtlich kaputtgemacht habe, um den Wachhund zu pruefen, ist die Restauration mit `git status --porcelain -- apps/` (leer) belegt.
## 1. Git-Historie, Umfang und Schalter-Gates
| Pruefung | Kommando | Beobachtet | Status |
|---|---|---|---|
| Drei Commits seit Planstand | `git log --oneline 02016e1..HEAD` / `git rev-list --count 02016e1..HEAD` | `939c812`, `6e2a641`, `3d64567`; count=3 | VERIFIZIERT |
| 29 Dateien ausserhalb `.planning` | `git diff --stat 5e0e408 -- . ':!.planning'` | `29 files changed, 2499 insertions(+), 491 deletions(-)`; 29 Zeilen mit `\|` | VERIFIZIERT |
| Schalter-Gate leer | `git diff --name-only 5e0e408 -- apps/api/prisma/schema.prisma docker-compose.yml docker-compose.prod.yml docker-compose.dev.yml package.json apps/api/package.json pnpm-lock.yaml apps/api/scripts/rls-preflight.mjs apps/api/src/user/admin-seed.service.ts` | keine Ausgabe | VERIFIZIERT |
| Keine Umgebungs-/Compose-Datei im Diff | `git diff --name-only 5e0e408 \| awk 'index($0,"env") \|\| index($0,"compose")'` | keine Ausgabe | VERIFIZIERT |
| Keine bestehende Migration geaendert | `git diff --name-only 5e0e408 -- apps/api/prisma/migrations \| grep -v 20260914120000` | keine Ausgabe | VERIFIZIERT |
| Gepusht | `git fetch -q && git status -sb \| head -1`; `git rev-parse HEAD` / `origin/main` | `## main...origin/main` (kein `[ahead`); beide `939c8121a182fb8ad93b3b7f5b9fdcecc17a3ffd`; Push-URL zeigt auf `localhost:3002` | VERIFIZIERT |
| Arbeitsbaum | `git status --porcelain` | nur ` M .planning/STATE.md` und `?? .../260914-eym-SUMMARY.md` (Orchestrator-Dateien, unangetastet) | VERIFIZIERT |
## 2. Baseline-Messungen (frisch, nicht aus der SUMMARY)
| Messpunkt | Kommando | Beobachtet | Erwartung (Plan) | Status |
|---|---|---|---|---|
| API-Testsuite | `cd apps/api && npx vitest run` | `Test Files 64 passed (64)`, `Tests 1054 passed (1054)`, Exit 0 | >= 1044 | VERIFIZIERT |
| Typpruefung | `cd apps/api && npx tsc --noEmit` | Exit 0, keine Ausgabe | Exit 0 | VERIFIZIERT |
| Werkzeug gegen lebende DB (Lauf 1) | `TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs` | `Alle 253 Pruefungen bestanden.`, Exit 0, `FEHLGESCHLAGEN`-Zeilen: 0 | N >= 250 | VERIFIZIERT |
| Werkzeug (Lauf 2, nach allen Rueckbauten und Restaurationen) | dito | `Alle 253 Pruefungen bestanden.`, Exit 0 | 253 | VERIFIZIERT |
| Vier Funktionsfaelle | `grep -E "^is-system-context-" <log>` | `ungesetzt-false` (Rohwert null), `leer-false`, `true-true`, `fremdwert-false` — alle `bestanden` | vier gruen | VERIFIZIERT |
| Neun Kennungen je Tabelle (5 x 9 = 45) | Schleife ueber dkvmoduleconfig/ldapconfig/ldapfieldmapping/tendermatch/tendersavedsearch x wegwerftabelle-deckt-alle-spalten / ungebunden-null-zeilen / sieht-beide-mandanten / insert-abgewiesen-42501 / updatemany-count-0 / deletemany-count-0 / fortenant-a-nach-systemkontext-nur-a / is-system-context-unter-fortenant-false / pg-policies-genau-eine-system-read-policy-select | keine fehlende, keine rote Kennung | 45 gruen | VERIFIZIERT |
| Relations-Kennung | `grep '^ldapconfig-systemkontext-include-fieldmappings-beider-mandanten'` | `bestanden — ... liefert 2 Zeile(n): ["TENANT-A:1","TENANT-B:1"]` | gruen | VERIFIZIERT |
## 3. Lebende Datenbank (Container `tessera-ctl-db-1`, IP 172.19.0.2, Rolle `tessera`)
| Pruefung | Kommando | Beobachtet | Status |
|---|---|---|---|
| Container | `docker ps --filter name=tessera-ctl-db-1` | `Up About an hour (healthy)` | — |
| Migrationsstand | `DATABASE_URL=... ./node_modules/.bin/prisma migrate status` | `36 migrations found`, `Database schema is up to date!` | VERIFIZIERT |
| Funktion vorhanden | `SELECT count(*) FROM pg_proc WHERE proname='is_system_context'` | 1; `provolatile='s'` (STABLE), `prosecdef=false` (kein SECURITY DEFINER) | VERIFIZIERT |
| Systemleseregeln | `SELECT tablename, cmd, permissive, qual, with_check FROM pg_policies WHERE policyname='system_read_policy'` | genau 5 Zeilen: DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch — je `SELECT` / `PERMISSIVE` / `is_system_context()` / with_check `null` | VERIFIZIERT |
| Gesamtzahl Regeln | `SELECT count(*) FROM pg_policies WHERE schemaname='public'` | 34 | VERIFIZIERT |
| SmtpConfig ohne Systemregel | Regeln der sechs Tabellen gelistet | SmtpConfig nur `tenant_isolation_policy` (ALL); die fuenf anderen je zwei Regeln | VERIFIZIERT |
| Schalter AUS | `SELECT rolname, rolsuper, rolbypassrls FROM pg_roles` | `tessera` super+bypassrls; `tessera_app` weder noch — Anwendung verbindet unveraendert als `tessera` | VERIFIZIERT |
| Nach Rueckbau (a) | `pg_policies` erneut, plus `SELECT datname FROM pg_database WHERE datname LIKE '%scratch%'` | 34 Regeln; TenderMatch: `system_read_policy` SELECT + `tenant_isolation_policy` ALL; keine Wegwerf-DB uebrig | VERIFIZIERT |
## 4. Beobachtbare Wahrheiten (must_haves.truths)
| # | Wahrheit | Status | Beleg |
|---|---|---|---|
| 1 | `forSystem(prisma)` in Array-Form-`$transaction`, EINE getaggte Anweisung setzt `app.system_context='true'`, `app.current_tenant=''`, `app.current_user=''`; `forTenant()`/`withTenantTransaction()` setzen `app.system_context=''`; Kein-Erben gemessen und Reset per Rueckbau falsifiziert | VERIFIZIERT | Datei gelesen: `forSystem` baut `$executeRaw\`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)\`` und `$transaction([setContext, query(args)])`; `grep -cF "set_config('app.system_context', '', true)"` = 2 (forTenant + withTenantTransaction). Werkzeug: `<slug>-fortenant-a-nach-systemkontext-nur-a` und `<slug>-is-system-context-unter-fortenant-false` fuer alle fuenf Tabellen gruen. Rueckbau (c) nicht selbst wiederholt (siehe Angenommene Risiken); Helfer-Spec 15 Tests gruen |
| 2 | Migration mit `is_system_context()` (STABLE, COALESCE) und genau fuenf PERMISSIVE `system_read_policy ... FOR SELECT` auf den fuenf Tabellen, SmtpConfig nicht dabei, bestehende Migrationen unveraendert, Schalter AUS | VERIFIZIERT | Datei gelesen; Zaehlung ohne Kommentarzeilen: `CREATE POLICY system_read_policy`=5, `FOR SELECT`=5, `DROP POLICY`=0, `SmtpConfig`=0. Lebende DB s. Abschnitt 3. `git diff --name-only 5e0e408 -- apps/api/prisma/migrations \| grep -v 20260914120000` leer |
| 3 | Regel erweitert NUR das Lesen: INSERT 42501, updateMany/deleteMany count 0, je Tabelle die neun Kennungen ueber den generierten Client | VERIFIZIERT | 45 Kennungen gruen (Abschnitt 2). Eigener Rueckbau (a): `FOR SELECT` bei TenderMatch entfernt -> `tendermatch-systemkontext-insert-abgewiesen-42501: FEHLGESCHLAGEN — ... ist NICHT fehlgeschlagen — angelegt: "tm-system-schreibversuch"`, dazu updatemany count=3, deletemany count=3, `nur-a` 0 Zeilen, `pg-policies` zeigt `cmd: ALL`; `5 von 253 Pruefungen fehlgeschlagen.` Danach `git checkout -- <Migration>`, `git status --porcelain -- apps/` leer, Werkzeug wieder 253 |
| 4 | Sechs Faelle behandelt: DKV Auftrag je Mandant; Mail Transport je Versand, Startpfad geloescht; ldap beide Leser ueber forSystem, Schreibzeile forTenant; digest Kandidaten forSystem; matching Suchprofile forSystem, Katalog ungebunden; admin-seed unveraendert | VERIFIZIERT | dkv: `loadActiveConfigsForScheduler()` = `forSystem(this.prisma).dkvModuleConfig.findMany({ where: { isActive: true }, select: CONFIG_SAFE_SELECT, orderBy: { tenantId: 'asc' } })`; Scheduler `jobNameFor` = `dkv-inbox-poll:<tenantId>`, `setInterval(intervalMin, tenantId)`/`stopJob(tenantId)` nur dieser Name, Controller `setInterval(dto.pollIntervalMin, tenantId)` / `stopJob(tenantId)`; `activeTenantId` im Code 0, `loadAnyActiveConfigForScheduler` 0. mail: `mail.module.ts` nur `SettingsModule` + `MailService`; `MailerModule/MailerService` im Code 0; `resolveTransport(tenantId)` -> `getDecryptedSmtpConfig(tenantId)` (gebunden, `findUnique({ where: { tenantId } })`) sonst Env-Kette; `transport?.close()` im finally; `loadAnySmtpConfigForStartupTransport` in vier Dateien 0; `this.prisma.smtpConfig` in settings.service 0; `auth.service.ts:248` `sendPasswordResetEmail(email, token, user.tenantId)`. ldap: Zeile 71 forSystem (Bootstrap, `select id/tenantId/encryptedBindPassword`), Zeile 87/88 `forTenant(this.prisma, config.tenantId)` + `ldapConfig.update` je Altzeile; Zeile 322 forSystem `getAllActiveConfigs` mit `include: { tenant, fieldMappings }`; `this.prisma.ldapConfig` im Code 0. digest: Zeile 124 forSystem `tenderMatch.findMany({ where: { notifiedAt: null }, select: { userId, tenantId }, distinct: ['userId'] })`, Schleife `forTenant(this.prisma, tenantId)`; matching: Zeile 75 forSystem `tenderSavedSearch.findMany()`, Zeile 90 `this.prisma.tender.findMany` (D-03), Zeile 98 `forTenant(this.prisma, search.tenantId)`. admin-seed: `git diff --quiet 5e0e408 -- apps/api/src/user/admin-seed.service.ts` unveraendert |
| 5 | Mit EINEM Mandanten unter BYPASSRLS je Pfad identisch — als Test festgenagelt | VERIFIZIERT (verhaltensabhaengig, durch benannte Tests belegt) | `npx vitest run` der zehn betroffenen Specs: 10 Dateien / 172 Tests gruen. dkv-scheduler.service.spec.ts (7 Tests, ECHTES `cron`): Test 1 `cronTime.source === '*/15 * * * *'`, `isActive` true, genau ein Auftrag `dkv-inbox-poll:t1`; Test 2 `0 */2 * * *`; Test 3 `fireOnTick()` -> `processInbox('t1')`; Test 4 inaktiv/keine -> 0 Auftraege + `no active config found`; Test 5 zwei Mandanten, `setInterval(30,'t2')` laesst t1-Objekt identisch (`toBe(t1JobBefore)`), `stopJob('t1')` nur t1; Test 6 werfender Startpfad; Test 7 stopJob No-Op. mail.service.spec.ts (4 Tests): Test 1 `createTransport({host:'smtp-a.example.invalid',port:465,secure:true,requireTLS:false,auth:{user:'user-a',pass:'geheim-a'}})`, `from` = fromAddress, `close()` einmal, `MAIL_HOST` darf nicht greifen; Test 2 MAIL_* vor TESSERA_SMTP_* vor localhost:1025, `from` aus TESSERA_SMTP_FROM bzw. Vorgabe, TESSERA_SMTP_SECURE; Test 3 zwei Mandanten, kein Kennwort des anderen; Test 4 Throw verschluckt, Protokoll ohne Kennwort, close() trotzdem. Env-Kette feldweise gegen `git show 5e0e408:apps/api/src/mail/mail.module.ts` verglichen: host/port/user/pass/from/secure identisch; SmtpConfig-Zweig bildet `secure = encryption==='ssl-tls'`, `requireTLS = encryption==='starttls'` exakt wie die geloeschte `loadAnySmtpConfigForStartupTransport` und wie `dkv-mail.service.ts`. ldap-spec: `getAllActiveConfigs` forSystem 1 / forTenant nie; Bootstrap leer forSystem 1 / forTenant nie / kein Update; Bootstrap mit Altzeile `forTenant(prisma,'t1')`, update `aa11:bb22:<hex>`. digest/matching: `__systemCallLog` genau `[tenderMatch.findMany]` bzw. `[tenderSavedSearch.findMany]`, `forTenant` weiter genau einmal, `tender.findMany` auf rohem Client. auth-spec Zeile 392: `sendPasswordResetEmail('bob@example.com', expect.any(String), 't1')` |
| 6 | Detektor: fuenfte Erkennungsform, `system-gebunden`, Vorrangregel, `FORSYSTEM_ALLOWED_CALL_SITES` mit exakten Zahlen (dkv 1, ldap 2, digest 1, matching 1), Fremddatei/Abweichung/veralteter Eintrag -> rot | VERIFIZIERT | Spec: Regex `/const\s+(\w+)\s*=\s*forSystem\(/g` (Zeile 439), `STAND_TOKENS = ['gebunden','ungebunden','gemischt','system-gebunden']`, Map exakt wie im Plan. `npx vitest run src/prisma/rls-access-inventory.spec.ts` -> 30/30 gruen. Quelltextzaehlung: `grep -rl 'forSystem(' apps/api/src` ohne spec/Helfer = genau die 4 Dateien; `const systemPrisma = forSystem(this.prisma)` = 5. EIGENE Falsifikation 1: `const x = forSystem(this.prisma);` in `apps/api/src/groups/groups.service.ts` (nicht in der Liste) -> `1 failed \| 29 passed`, Meldung `apps/api/src/groups/groups.service.ts: 1 forSystem(-Aufruf(e), Datei steht NICHT in FORSYSTEM_ALLOWED_CALL_SITES — ein Anfrageweg darf den Systemkontext nie rufen`. EIGENE Falsifikation 2: zweite Zuweisung in `dkv.service.ts` (erlaubte Datei) -> `2 failed \| 28 passed`, Meldungen `gemessen 2 forSystem(-Aufruf(e), erlaubt sind genau 1` und `Erlaubnisliste nennt 1, gemessen 2 — der Eintrag ist ueberholt`. Beide per `git checkout --` restauriert; `git status --porcelain -- apps/` leer; `git diff --quiet HEAD -- <Datei>` sauber |
| 7 | Frage aus Etappe 2 je Pfad beantwortet (Leere ist nie Abwesenheit), Abschnitt (y1)-(y5) in der Kritikschrift | VERIFIZIERT | `## Systemkontext (Etappe 3c, 260914-eym)` Zeile 3250 VOR `## Etappe 2 — Abschluss` 3465; `### (y1)` 3270, `(y2)` 3333, `(y3)` 3399, `(y4)` 3431, `(y5)` 3451. (y1): 22 `bestanden`-Zeilen, enthaelt `is-system-context-ungesetzt-false: bestanden`, `tendermatch-systemkontext-insert-abgewiesen-42501: bestanden` und fuenf `#system_read_policy#SELECT#is_system_context()#`-Zeilen. (y3) nennt dkv-scheduler.service.ts, ldap-sync.scheduler.ts, ldap.service.ts, ldap-config.service.ts, tender-digest.scheduler.ts, tender-matching.service.ts, admin-seed.service.ts je einmal. Nachtrag (260914-eym) in (d4)/(s4)/(b4) je 1, Abschluss 2 Treffer |
| 8 | Aktenstand kohaerent: sechs Zeilen `system-gebunden`, settings/smtpConfig `gebunden`, 72 Paare / 35/21/14/2, Uebersichtstabelle mit Spalte System und ABGELEITETER Summenzeile, Hintergrunddienst-Regelschluesse, Auftrag 3c erledigt, Datenbankrolle mit dritter Variable + preflight-Aussage, Ledger #21/#30 fixed, #37 neu, Zaehler 15/1/21/37 | VERIFIZIERT | Gate-Schleife aus Aufgabe 3 selbst ausgefuehrt: PAARE=72; Klassen gezaehlt 35/21/14/2 = dokumentiert; `system-gebunden`-Zeilen = 6 (dkv/dkvModuleConfig, ldap-config/ldapConfig, /ldapFieldMapping, /tenant, digest/tenderMatch, matching/tenderSavedSearch), settings/smtpConfig `gebunden`; Ableitung `ABGELEITET 61/179/5` (dkv 0/22/1, ldap 1/27/2, tenders 33/27/2) = Summenzeile `**61** \| **179** \| **5**`; Kopf `\| Bereich \| Ungebunden \| Gebunden \| System \|`; `Regelschluss (260914-eym)` im Hintergrunddienst-Abschnitt 7x (>= 6); `**Stand 260914-eym` vorhanden. Auftrag: `Erledigt (260914-eym, 3d64567/6e2a641 ...` Zeile 145. Datenbankrolle: `app.system_context` (Z. 90-103), `is-system-context-ungesetzt-false` (Z. 166), preflight-Aussage (Z. 163); im Diff gegen 5e0e408 kein `SECURITY DEFINER` (0). Ledger: `gsd-tools windows status` -> #21 `fixed` resolved_at 2026-09-14T09:51:23Z, #30 `fixed` resolved_at 2026-09-14T09:51:24Z, #37 `open` (quick-260914-eym, deviation, dkv.service.ts, Single-Flight-Riegel); Frontmatter open 15 / waived 1 / fixed 21 / total 37 = aus Zeilen gezaehlt 15/21/1/37 |
| 9 | Schalter AUS, Gates gegen 5e0e408 leer, Tests >= 1044, tsc 0, Werkzeug >= 250, sauber, gepusht | VERIFIZIERT | Abschnitte 1-3 |
**Score:** 9/9 Wahrheiten verifiziert (0 present-behavior-unverified)
### Verhaltensabhaengige Wahrheiten — Belegform
Wahrheiten 1, 3, 5 und 6 behaupten Laufzeitverhalten (Kontext-Reset, Schreibverbot, Identitaet je Pfad, Wachhund). Keine davon ist auf Symbolpraesenz allein als VERIFIZIERT gesetzt: 1 und 3 sind live im Werkzeug gegen eine Rolle ohne BYPASSRLS gemessen (253 gruen, Rueckbau (a) selbst wiederholt), 5 durch die benannten Specs (172 Tests, echtes `cron`), 6 durch zwei eigene Falsifikationen.
## 5. Artefakte
| Artefakt | Erwartet | Status | Details |
|---|---|---|---|
| `apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql` | NEU, Funktion + fuenf Regeln + Abschnitt "bewusst NICHT" | VERIFIZIERT | 5/5/0/0-Zaehlung (s. o.); Abschnitt "Was diese Migration bewusst NICHT tut" vorhanden (SmtpConfig, Tenant/Tender, keine Schreibregel, Schalter); lokal angewendet (36 Migrationen) |
| `apps/api/src/prisma/prisma-tenant.extension.ts` | `forSystem()` mit Kopfkommentar, Reset in forTenant/withTenantTransaction, `$transaction`-Feld zwei Eintraege | VERIFIZIERT | gelesen; Abschnitt "SYSTEMKONTEXT (Etappe 3c, 260914-eym)" im Kopf; Array `[setContext, query(args)]` |
| `apps/api/src/prisma/prisma-tenant.extension.spec.ts` | >= 3 neue Tests | VERIFIZIERT | 11 -> 15 Tests, gruen |
| `apps/api/src/groups/migration-sql.spec.ts` | describe fuer neue Migration | VERIFIZIERT | `_rls_system_context_read` vorhanden; 32 Tests gruen |
| `apps/api/src/prisma/rls-access-inventory.spec.ts` | fuenfte Form, `systemModels`, `system-gebunden`, Erlaubnisliste | VERIFIZIERT | 30 Tests; zwei eigene Falsifikationen rot |
| `apps/api/scripts/rls-scratch-check.mjs` | `runSystemContextChecks`, `extractSystemReadPolicySql`, `buildInlineSystemClient` | VERIFIZIERT | grep-Treffer; 253 Pruefungen, Abschnitt laeuft im Hauptlauf |
| dkv (service, scheduler, controller, zwei Specs) | Auftrag je Mandant, `registeredTenantIds`, `stopJob(tenantId)`, neue Spec >= 6 Tests | VERIFIZIERT | 7 Scheduler-Tests, 17 Service-Tests gruen |
| mail (module, service, NEUE spec), settings (service, spec), auth (service, spec) | Startpfad weg, `resolveTransport`, Transport je Versand, `user.tenantId` durchgereicht | VERIFIZIERT | 4 Mail-Tests, 16 Settings-Tests, 29 Auth-Tests gruen |
| ldap-config, tender-digest, tender-matching (+Specs), tender-notifications.integration.spec | forSystem-Leser, Mocks ergaenzt | VERIFIZIERT | 19/16/17 Tests gruen; Integrationsspec in Vollsuite gruen |
| vier Dokumente + `.planning/WINDOWS.md` | Nachtraege, 3c erledigt, Ledger | VERIFIZIERT | Abschnitt 4, Wahrheiten 7/8 |
## 6. Schluesselverbindungen (key_links)
| Von | Nach | Ueber | Status | Details |
|---|---|---|---|---|
| `FOR SELECT` in der Migration | Schreibverbot unter Systemkontext | permissive ODER-Verknuepfung | VERBUNDEN | Rueckbau (a) selbst wiederholt: ohne `FOR SELECT` gelingt der Insert (5/253 rot); mit: 253 gruen |
| Detektor-Form `const X = forSystem(` | Bestandsaufnahme-Stand `system-gebunden` | Regex Zeile 439 + Erlaubnisliste | VERBUNDEN | 6 Zeilen `system-gebunden` in der Klassifikation; Falsifikationen rot |
| `set_config(..., true)` + Reset | Kein Erben zwischen Kontexten | Werkzeug `fortenant-a-nach-systemkontext-nur-a` | VERBUNDEN | 5x gruen; Rueckbau (c) nicht selbst wiederholt (Angenommene Risiken) |
| `auth.service.ts:248` | `MailService.sendPasswordResetEmail(..., tenantId)` | `user.tenantId` | VERBUNDEN | Quelltext + auth-spec Zeile 392 |
| `…-ungebunden-null-zeilen` + `…-sieht-beide-mandanten` | zu-wenig-statt-zu-viel-Falle | Werkzeugpaar je Tabelle | VERBUNDEN | 5 Paare gruen; (y3) beantwortet je Pfad |
## 7. Datenfluss (Level 4)
| Artefakt | Variable | Quelle | Echte Daten | Status |
|---|---|---|---|---|
| dkv-scheduler `onModuleInit` | `configs` | `forSystem(prisma).dkvModuleConfig.findMany({ where: { isActive: true } })` | ja | FLIESST |
| mail `resolveTransport` | `smtpConfig` | `settingsService.getDecryptedSmtpConfig(tenantId)` -> `forTenant(...).smtpConfig.findUnique({ where: { tenantId } })` | ja (Rueckfall Env-Kette explizit) | FLIESST |
| ldap `getAllActiveConfigs` / Bootstrap | `configs` | `forSystem(prisma).ldapConfig.findMany(...)` | ja | FLIESST |
| digest `candidates` / matching `savedSearches` | — | `forSystem(prisma).tenderMatch.findMany` / `.tenderSavedSearch.findMany()` | ja | FLIESST |
## 8. Verhaltens-Stichproben
| Verhalten | Kommando | Ergebnis | Status |
|---|---|---|---|
| Vollsuite | `npx vitest run` | 64 Dateien / 1054 Tests | PASS |
| Typpruefung | `npx tsc --noEmit` | Exit 0 | PASS |
| Zehn betroffene Specs benannt | `npx vitest run <10 Dateien>` | 10 / 172 gruen | PASS |
| Detektor | `npx vitest run src/prisma/rls-access-inventory.spec.ts` | 30/30 | PASS |
| Detektor mit Fremddatei | s. Wahrheit 6 | 1 failed / 29 passed | PASS (rot wie gefordert) |
| Detektor mit Zahlabweichung | s. Wahrheit 6 | 2 failed / 28 passed | PASS (rot wie gefordert) |
| Werkzeug gruen | `node apps/api/scripts/rls-scratch-check.mjs` (2x) | 253 / 253 | PASS |
| Werkzeug Rueckbau (a) | `FOR SELECT` bei TenderMatch entfernt | `5 von 253 Pruefungen fehlgeschlagen.` — Insert GELINGT | PASS (rot wie gefordert) |
## 9. Sonde-Ausfuehrung
Keine `scripts/*/tests/probe-*.sh` im Projekt; das Werkzeug `rls-scratch-check.mjs` ist die Sonde dieses Durchlaufs und wurde zweimal selbst ausgefuehrt (Abschnitt 2, 8).
## 10. Anforderungsabdeckung
| Anforderung | Plan | Beschreibung | Status | Beleg |
|---|---|---|---|---|
| ETAPPE-3C | 01 | Systemkontext fuer Hintergrunddienste | ERFUELLT | Wahrheiten 1-9 |
| WINDOWS-21 | 01 | DKV-Planer je Mandant | ERFUELLT | Wahrheit 4/5, Ledger #21 fixed |
| WINDOWS-30 | 01 | Mail-Startpfad | ERFUELLT (durch Entfernen, nicht Umstellen — im Plan so vorgesehen) | Wahrheit 4/5, Ledger #30 fixed |
Keine Zuordnung in `.planning/REQUIREMENTS.md` fuer Quick-Tasks — keine verwaisten Anforderungen.
## 11. Anti-Pattern-Scan
`grep -n -E "\b(TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER)\b"` ueber alle 29 geaenderten Dateien: keine Treffer. `console.log` in geaenderten Nicht-Spec-Dateien: keine Treffer. Keine Stubs: `sendWelcomeEmail` ist vollstaendig implementiert und hat null Aufrufer ausserhalb `mail/` (Bestand seit vor diesem Durchlauf, in (y4) benannt).
## 12. Menschliche Pruefung erforderlich
Keine — jede Zusicherung des Plans ist entweder statisch, per Test oder live gegen die Datenbank gemessen. Was ausserhalb dieses Repos liegt, steht unter "Angenommene Risiken".
## 13. Luecken
Keine.
## Angenommene Risiken
Was ich NICHT selbst messen konnte oder bewusst nicht wiederholt habe:
1. **Rueckbau (b), (c) und (d) nicht selbst wiederholt.** Der Auftrag verlangte EINE Rueckbau-Falsifikation (empfohlen (a)); die habe ich vollstaendig wiederholt und dasselbe Ergebnis wie die SUMMARY beobachtet (5 von 253 rot, Insert gelingt). Fuer (b) (Regel aus der Migration entfernt -> Werkzeug folgt der Datei, DB bleibt 34), (c) (`local=false` + Reset entfernt -> Erben sichtbar) und (d) (Erlaubniszahl auf 0) stuetze ich mich auf die woertlichen Ausgaben der SUMMARY. (d) ist durch meine beiden eigenen Detektor-Falsifikationen in der Sache gedeckt (Fremddatei rot, Zahlabweichung rot); (c) ist durch die fuenf gruenen `…-fortenant-a-nach-systemkontext-nur-a`-Kennungen und den gelesenen Helfer-Quelltext (Reset in allen drei Formen) gedeckt, nur der Beleg "Reset traegt bei local=false" ist nicht erneut erzeugt.
2. **Alpha-Go-live morgen ist nicht beobachtet.** Dass DKV-Postfach-Abruf und Kennwort-Zuruecksetzung auf `alpha.tessera.ctl.de` mit dem echten SMTP-Server und dem echten Postfach identisch zu heute laufen, ist hier per Test festgenagelt (Cron-Expression, Tick, Transport aus der SmtpConfig des Mandanten), nicht gegen den Testserver gemessen — Deploy und Beobachtung auf dem Server macht der User selbst (Absprache). Unter BYPASSRLS sind `forSystem`/`forTenant` wirkungslos; die einzigen beobachtbaren Verhaltensaenderungen sind (i) der Registry-Name `dkv-inbox-poll:<tenantId>` statt `dkv-inbox-poll` und (ii) der Transport je Versand statt beim Start — beide durch Tests gedeckt, (ii) zusaetzlich feldweise gegen den geloeschten Startpfad verglichen.
3. **Migration auf alpha.** `20260914120000_rls_system_context_read` ist rein additiv (CREATE FUNCTION, CREATE POLICY) auf Tabellen, die RLS bereits aus frueheren Migrationen tragen; sie ist lokal per `migrate deploy` gruen. Ob `migrate deploy` auf alpha morgen ebenso sauber laeuft, ist hier nicht messbar (kein Deploy durch mich).
4. **`@nestjs-modules/mailer` bleibt installiert und unbenutzt** (`package.json`/Lockfile bewusst unveraendert, im Plan so vorgesehen). Kein Defekt, aber Aufraeumbedarf — in (y4) und in der SUMMARY benannt.
5. **WINDOWS #37 (Single-Flight-Riegel prozessweit)** ist bewusst offen; mit einem Mandanten ohne Wirkung, mit mehreren nur Verzoegerung, kein Datenverlust — im Ledger als `open` eingetragen, nicht Teil dieses Auftrags.
6. **Ledger-Zeitstempel**: `resolved_at` von #21/#30 liegt bei 09:51 UTC (11:51 lokal), passend zur SUMMARY-Sitzung 11:10-12:05; nicht weiter pruefbar.
Der Arbeitsbaum wurde exakt so hinterlassen wie vorgefunden: `git status --porcelain` zeigt nur ` M .planning/STATE.md` und die neue SUMMARY (beide unangetastet) sowie jetzt diese VERIFICATION.md. Alle drei temporaeren Aenderungen (groups.service.ts, dkv.service.ts, migration.sql) sind per `git checkout --` restauriert und mit `git status --porcelain -- apps/` (leer) belegt; die lebende Datenbank steht bei 34 Regeln, keine Wegwerf-Datenbank blieb zurueck.
---
_Verifiziert: 2026-09-14T12:40:00Z_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,323 @@
---
phase: quick-260914-ku1
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260914-KU1]
files_modified:
- packages/shared/src/index.ts
- apps/api/src/health/app-version.ts
- apps/api/src/health/health.controller.ts
- apps/api/src/health/health.controller.spec.ts
- apps/api/src/main.ts
- apps/web/src/lib/app-version.ts
- apps/web/src/lib/app-version.test.ts
- apps/web/src/components/layout/app-version-badge.tsx
- apps/web/src/components/layout/app-version-badge.test.tsx
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/components/layout/sidebar.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/Dockerfile
- apps/api/Dockerfile
- .gitea/workflows/ci.yml
- .gitea/scripts/publish-images.sh
- docker-compose.prod.yml
- docs/anleitung-betrieb.md
- docs/ci-cd-setup.md
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 3
confidence: low
must_haves:
truths:
- "Ein angemeldeter Anwender sieht unten in der Seitenleiste (ausgeklappt, Desktop und mobile Schublade) eine kleine Zeile `<Version> · <Kanal>` (z. B. `v1.0.0 · Beta`, lokal `dev · Entwicklung`); der Tooltip (`title`) nennt den Web-Commit und — sobald `GET /health/version` geantwortet hat — die API-Version samt Kanal. Ein Fehler beim Laden der API-Version ist still (Zeile bleibt, Tooltip ohne API-Teil). Eingeklappt: nichts (konsistent mit dem heutigen `!isCollapsed`-Muster der Seitenleiste)."
- "`GET /health/version` liefert `{ name: 'tessera', version, channel, commit, buildTime }` aus `APP_VERSION`, `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME` mit Vorgaben `dev`/`dev`/``/`` — leere Zeichenketten (Compose reicht unbelegte Variablen leer weiter) zaehlen wie ungesetzt. Beim Start der API steht eine Protokollzeile `Tessera API <version> (<channel>) <commit>`. Der Endpunkt bleibt bewusst @Public (T-KU1-03), durch Spec-Test gepinnt."
- "Ein lokaler `docker build` beider Dockerfiles MIT `--build-arg APP_VERSION=v9.9.9-test --build-arg APP_CHANNEL=live --build-arg APP_COMMIT=abc1234 --build-arg APP_BUILD_TIME=2026-09-14T00:00:00Z` liefert Abbilder, in denen (a) `node -e 'console.log(process.env.APP_VERSION)'` `v9.9.9-test` ausgibt, (b) im Web-Abbild mindestens eine Datei unter `/app/apps/web/.next/static` die Zeichenkette `v9.9.9-test` enthaelt (Bauzeit-Einbettung durch Next.js bewiesen) und (c) `formatAppVersionLine()` aus dem kompilierten API-`dist` `Tessera API v9.9.9-test (live) abc1234` liefert. OHNE Build-Args baut alles weiter und liefert `dev`."
- "`.gitea/workflows/ci.yml` (per js-yaml geparst) loest auf `push` fuer `branches: [main, live]` UND `tags: ['v*']` aus; `quality` und `test` laufen fuer alle; `publish` checkt mit `fetch-depth: 0` aus und ruft `.gitea/scripts/publish-images.sh`. Das Skript entscheidet allein anhand `GITHUB_REF`: `refs/heads/main` -> Kanal `beta`, Etiketten `beta` und `latest`; `refs/tags/v*` -> Kanal `live`, Etiketten `live` und `vX.Y.Z`; jeder andere Ref (auch der Zweig `live` ohne Tag) -> `nichts zu tun`, Exit 0, kein Push. `--print-plan` zeigt das ohne Docker-Aufruf. Versionsstempel: `git describe --tags --always` (heute ohne Tags: kurzer SHA), `git rev-parse --short HEAD`, `date -u` ISO."
- "`docker-compose.prod.yml` bleibt EINE Datei; beide Abbild-Zeilen tragen `${IMAGE_TAG:-beta}`; `docker compose -f docker-compose.prod.yml config --images` rendert ohne Variable zweimal `:beta`, mit `IMAGE_TAG=live` zweimal `:live`. Registry-Host `git.vicolab.de` und alles andere in der Datei unangetastet."
- "Nach `git push` laeuft die Pipeline auf dem lokalen Gitea (localhost:3002, Runner `gitea-runner` mit Host-Docker-Socket) sichtbar durch: der Lauf zum gepushten Commit endet `completed`/`success`, und die vom Runner auf DIESEM Host gebauten Abbilder `localhost:3002/schalli/tessera-ctl/{api,web}:beta` tragen `APP_VERSION` = kurzer SHA des gepushten Commits und `APP_CHANNEL=beta` — der Versionsstempel ist damit einmal ueber den echten CI-Weg bewiesen, nicht nur lokal."
- "`docs/anleitung-betrieb.md` hat einen neuen Abschnitt `## 9. Zwei Kanäle: Live und Beta` in Alltagssprache mit echten Umlauten (Ton der Datei), der erklaert: was ein Kanal ist; welche Adresse welches Etikett holt (`latest` = Beta, bleibt vorerst); die eine `.env`-Zeile je Server (`IMAGE_TAG=beta` auf alpha, `IMAGE_TAG=live` auf dem neuen Server) und die zwei `image:`-Zeilen in der Server-Compose-Datei; Freigabe einer Version (Schritte, die Claude ausfuehrt, und `pull` + `up -d --force-recreate api web` durch den User); Hotfix-Ablauf inkl. der Regel 'keine Datenbankänderung als Hotfix' mit Begruendung; wie man die Version in der Oberflaeche, per `curl` und im Log erkennt; Einrichtung des neuen Live-Servers (Verweis auf Abschnitt 2 plus Abweichungen: `IMAGE_TAG=live`, eigene Secrets, eigene Datenbank, KEINE Kopie der alpha-Datenbank ohne ausdruecklichen Wunsch); Rezept fuer die Erstfreigabe v1.0.0. `docs/ci-cd-setup.md` behauptet nicht mehr, es gebe keinen Registry-Push oder einen `build-deploy`-Job, sondern beschreibt Trigger, Etiketten, Build-Args und die laengere Laufzeit."
- "Baseline am Ende: API `Test Files 65 passed (65)` / `Tests 1060 passed (1060)` (Planungszeit 64/1054 plus 6 neue), Web `Test Files 40 passed (40)` / `Tests 243 passed (243)` (Planungszeit 38/233 plus 5 + 4 + 1 neue), `tsc --noEmit` in api, web und shared Exit 0; `git diff --stat 6c19451 -- . ':!.planning'` nennt genau `20 files changed`; DATABASE_URL, `.env`-Dateien, `schema.prisma`, Migrationen, `biome.json`, `package.json`-Versionen unangetastet."
artifacts:
- "packages/shared/src/index.ts — `export interface VersionResponse { name: string; version: string; channel: string; commit: string; buildTime: string }` neben `HealthResponse`"
- "apps/api/src/health/app-version.ts — `getAppVersion(): VersionResponse` (liest `process.env.APP_*` mit `||`-Vorgaben) und `formatAppVersionLine(v?: VersionResponse): string`"
- "apps/api/src/health/health.controller.ts — `getVersion(): VersionResponse` delegiert an `getAppVersion()`, weiterhin `@Public()`; kein Zugriff mehr auf die npm-Paketversion"
- "apps/api/src/health/health.controller.spec.ts — NEU (es gab keinen Spec), 6 Tests"
- "apps/api/src/main.ts — `console.log(formatAppVersionLine())` direkt nach der bestehenden Port-Zeile"
- "apps/web/src/lib/app-version.ts — `appVersion: { version, channel: 'beta'|'live'|'dev', commit }` aus `process.env.NEXT_PUBLIC_APP_VERSION` / `_CHANNEL` / `_COMMIT` (jeweils voller Literalname) und `loadApiVersion(): Promise<ApiVersionInfo | null>` (memoisiert, `credentials: 'include'`, still bei Fehler) — die importierbare Quelle fuer den kommenden Fehler-melden-Knopf"
- "apps/web/src/lib/app-version.test.ts — NEU, 5 Tests (vi.stubEnv + vi.resetModules + dynamischer Import)"
- "apps/web/src/components/layout/app-version-badge.tsx — `AppVersionBadge`, `data-testid=\"app-version\"`, Text `${version} · ${t('channel.'+channel)}`, `title` aus Commit und API-Version"
- "apps/web/src/components/layout/app-version-badge.test.tsx — NEU, 4 Tests"
- "apps/web/src/components/layout/sidebar.tsx — Abzeichen-Block unter dem Einklapp-Block, nur `!isCollapsed`, ohne `hidden md:block` (damit auch die mobile Schublade ihn zeigt)"
- "apps/web/src/components/layout/sidebar.test.tsx — Mock fuer `@/components/layout/app-version-badge` (wie der bestehende SidebarFooter-Mock) und ein sechster Test"
- "apps/web/src/messages/de.json + en.json — `sidebar.channel.{beta,live,dev}` = Beta/Live/Entwicklung bzw. Beta/Live/Development"
- "apps/web/Dockerfile + apps/api/Dockerfile — globale `ARG APP_VERSION=dev`, `ARG APP_CHANNEL=dev`, `ARG APP_COMMIT=`, `ARG APP_BUILD_TIME=` vor dem ersten FROM; im builder (nur web) `ARG`-Wiederholung + `ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION NEXT_PUBLIC_APP_CHANNEL=$APP_CHANNEL NEXT_PUBLIC_APP_COMMIT=$APP_COMMIT` unmittelbar VOR `RUN pnpm --filter=@tessera/web build`; im runner (beide) `ARG`-Wiederholung + `ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME`"
- ".gitea/scripts/publish-images.sh — POSIX sh, `set -eu`, Kanal-/Etiketten-Entscheidung, `--print-plan`, Bau mit vier `--build-arg`, `docker tag` + `docker push` je Etikett; gibt nie ein Secret aus"
- ".gitea/workflows/ci.yml — Trigger erweitert, `publish` mit `fetch-depth: 0` und Skriptaufruf; Login-Schritt unveraendert"
- "docker-compose.prod.yml — `image: git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}` und `.../api:${IMAGE_TAG:-beta}`"
- "docs/anleitung-betrieb.md — Abschnitt 9 neu, Inhaltsverzeichnis, Tabelle in Abschnitt 1, `IMAGE_TAG`-Zeile in Abschnitt 3, Etiketten in Abschnitt 4, Log-Zeile in Abschnitt 7"
- "docs/ci-cd-setup.md — Abschnitte 3 (Secrets: REGISTRY_TOKEN) und 4 (Pipeline-Ueberblick) auf den gemessenen Stand; ASCII-Umschrift wie im Bestand"
key_links:
- "Bauzeit-Einbettung: `ENV NEXT_PUBLIC_APP_*` steht im builder VOR `pnpm build`, und `app-version.ts` liest jede Variable mit vollem Literalnamen — nur dann ersetzt Next.js den Ausdruck im Browser-Bundle (heute nachweisbar: 28 Dateien unter `.next/static` enthalten das eingebettete `/api-proxy`). Deshalb muss Task 1 (Code) VOR Task 2 (Build-Beweis) liegen: ohne die Quelle gibt es nichts einzubetten, der Grep auf `v9.9.9-test` waere sinnlos."
- "`.dockerignore` schliesst `.git` aus — `git describe` kann NICHT im Dockerfile laufen; der Stempel kommt ausschliesslich per `--build-arg` aus der CI (Skript), lokal greift die Vorgabe `dev`."
- "ARG-Sichtbarkeit: ein `ARG` vor dem ersten FROM liefert nur die Vorgabe; jede Stufe, die den Wert nutzt, wiederholt `ARG NAME` (ohne Wert) — zur Planungszeit mit einem Zweistufen-Testbau bestaetigt (`builder sees: v9.9.9-test / live`, `runner: v9.9.9-test live`)."
- "Runner und Gitea laufen auf DIESEM Rechner (`gitea`, `gitea-runner` mit Host-Docker-Socket, `localhost:3002` antwortet): der CI-Lauf ist nach dem Push ueber `GET /api/v1/repos/schalli/tessera-ctl/actions/runs` (Token aus `git config --get remote.origin.pushurl`, nie ausgeben) beobachtbar, und die CI-Abbilder erscheinen in `docker images` des Hosts."
- "Der Web-Container ruft `${NEXT_PUBLIC_API_URL}/health/version` = `/api-proxy/health/version`; `next.config.ts` schreibt `/api-proxy/:path*` auf `API_INTERNAL_URL` um — derselbe Weg wie `/modules/active` in `sidebar.tsx`."
- "`sidebar.test.tsx` stubbt `fetch` global mit dem Modul-Array; ohne Mock des Abzeichens bekaeme `loadApiVersion()` dieses Array — deshalb Modul-Mock wie beim `SidebarFooter`."
---
<objective>
Zwei Auslieferungskanaele fuer Tessera: `main` = Beta (alpha.tessera.ctl.de), Zweig `live` + Tag `vX.Y.Z` = Live (tessera.ctl.de, neuer Server ab 2026-09-15). Dieser Plan liefert (1) den Versionsstempel durch alle Schichten — CI berechnet `APP_VERSION`/`APP_CHANNEL`/`APP_COMMIT`/`APP_BUILD_TIME`, beide Dockerfiles nehmen sie als Build-Args, die API antwortet auf `GET /health/version` und protokolliert beim Start, die Web-Oberflaeche zeigt `v1.0.0 · Beta` unten in der Seitenleiste; (2) die Pipeline-Trigger und Etiketten je Kanal mit einem lokal pruefbaren Veroeffentlichungs-Skript; (3) `IMAGE_TAG` in der Compose-Datei; (4) das Betriebshandbuch fuer einen Nicht-Programmierer: Kanaele, Freigabe, Hotfix (ohne Datenbankaenderung), Versionskontrolle, Einrichtung des neuen Live-Servers.
Purpose: Morgen geht Live. Der User muss einen Fehler auf Live beheben koennen, ohne Beta-Neuerungen mitzunehmen, die Korrektur danach kontrolliert in die Beta uebernehmen und jederzeit sehen, welche Fassung ein Anwender benutzt. Der Fehler-melden-Knopf (eigener Folge-Quick-Task) bekommt mit `apps/web/src/lib/app-version.ts` seine Quelle.
Output: 20 Dateien (13 Code/Tests, 5 Build/CI/Compose, 2 Handbuecher), drei Commits mit Scope `quick-260914-ku1`, gepusht, CI-Lauf beobachtet und die CI-gebauten `:beta`-Abbilder auf ihren Stempel geprueft. Zweig `live` und Tag `v1.0.0` werden NICHT in diesem Plan angelegt (Rezept steht im Handbuch; der Orchestrator macht das nach dem Fehler-melden-Task).
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.gitea/workflows/ci.yml
@apps/web/Dockerfile
@apps/api/Dockerfile
@docker-compose.prod.yml
@apps/api/src/health/health.controller.ts
@apps/api/src/main.ts
@packages/shared/src/index.ts
@apps/web/src/components/layout/sidebar.tsx
@apps/web/src/components/layout/sidebar.test.tsx
@apps/web/src/messages/umlaut-guard.spec.ts
@docs/anleitung-betrieb.md
@docs/ci-cd-setup.md
<planning_measurements>
Zur Planungszeit (2026-09-14, HEAD `6c19451`, Arbeitsbaum sauber, main == origin/main) gemessen — die Ausfuehrung misst erneut; diese Zahlen sind der Bezugspunkt der Gates:
- API-Suite `pnpm -C apps/api exec vitest run` -> `Test Files 64 passed (64)`, `Tests 1054 passed (1054)`. Web-Suite `pnpm -C apps/web exec vitest run` -> `Test Files 38 passed (38)`, `Tests 233 passed (233)` (die Baseline im Auftrag „64/1054" war nur die API). `tsc --noEmit` in `apps/api`, `apps/web`, `packages/shared` je Exit 0.
- **`SidebarFooter` (`sidebar-footer.tsx`) wird seit `ba02b25` (2026-06-26, „restructure sidebar and admin navigation") NIRGENDS gerendert** — einziger Treffer ausserhalb der Datei ist der Mock in `sidebar.test.tsx`. Die Benutzerinfo lebt im Header-Dropdown (`header.tsx` 134-154), die Seitenleiste endet mit dem Einklapp-Block (`sidebar.tsx` 187-199, `hidden md:block border-t border-sidebar-border p-2`; `!isCollapsed` blendet dort und in der Navigation jeden Text aus). Eine Versionszeile in `sidebar-footer.tsx` waere unsichtbar. Deshalb: eigene Komponente `AppVersionBadge`, gerendert in `sidebar.tsx` im `sidebarContent` (Zeile 91 `<div className="flex h-full flex-col bg-sidebar">`, Ende bei 201) NACH dem Einklapp-Block; `sidebarContent` wird auch in der mobilen Schublade (Zeile 245) gerendert. `sidebar-footer.tsx` bleibt unangetastet (toter Code, im SUMMARY als Nebenbefund nennen, nicht loeschen).
- **Ohne Quellcode, der `process.env.NEXT_PUBLIC_APP_VERSION` liest, bettet Next.js nichts ein** — der Grep auf `v9.9.9-test` im Web-Abbild funktioniert erst, wenn `app-version.ts` existiert. Reihenfolge deshalb: Task 1 Code, Task 2 Build/CI. Nachweis des Mechanismus heute: `docker run --rm --entrypoint sh <web-image> -c 'grep -rl "/api-proxy" /app/apps/web/.next/static | wc -l'` -> 28.
- Docker 29.8.0 / Compose v5.5.1 lokal. Ein Web-Bau mit warmem Cache (deps-Stufe getroffen, builder neu) dauerte 129 s; der API-Bau liegt in derselben Groessenordnung. Vier lokale Baeue (Task 2) sind also 6-12 Minuten — erwartete Dauer, kein Haenger. Lokales Zwischenabbild `tessera-web-plancheck:baseline` existiert (Cache-Waerme), darf am Ende mit `docker rmi` weg.
- ARG-Semantik bestaetigt (Zweistufen-Testbau im Scratchpad): globale `ARG X=dev` vor dem ersten FROM + `ARG X` in jeder nutzenden Stufe -> ohne Args `dev`, mit `--build-arg` der Wert in builder UND runner.
- `.dockerignore`: `node_modules .next dist .turbo .git .env *.md coverage` — `.git` fehlt im Kontext, `git describe` im Dockerfile unmoeglich.
- `git tag` liefert keine Zeile (0 Tags); `git describe --tags --always` -> `6c19451` (kurzer SHA, Gate „Bau vor dem ersten Tag scheitert nicht" erfuellt). Nur Zweig `main` lokal und remote. Gitea 1.26.2 auf localhost:3002; `branch_protections` leer; 0 Kollaborateure (nur schalli). Push-URL `localhost:3002`, Fetch-URL `git.vicolab.de` (Memory).
- **Gitea UND `gitea-runner` (act_runner v0.6.1, Labels ubuntu-latest, Host-Docker-Socket) laufen auf DIESEM Rechner.** Folge: die CI-Baeue landen in `docker images` des Hosts (`localhost:3002/schalli/tessera-ctl/api:latest` wurde vom letzten Lauf 296 zu HEAD `6c19451` gebaut; Lauf gestartet 13:51:28, beendet 13:53:15 — unter 2 Minuten, weil Docker-Layer-Cache; mit Build-Args wird der builder jedes Mal neu laufen, also kuenftig ~4-6 Minuten). Der Lauf ist per `GET http://localhost:3002/api/v1/repos/schalli/tessera-ctl/actions/runs?limit=3` mit `Authorization: token <Token aus pushurl>` lesbar (Felder `head_sha`, `status`, `conclusion`); anonym 401. Der Auftrag nahm an, der Lauf sei nicht beobachtbar — er ist es.
- `docker compose -f docker-compose.prod.yml config --images 2>/dev/null` laeuft lokal mit Exit 0 (die lokale `.env` liefert den Pflichtwert `TESSERA_ENCRYPTION_KEY`) und rendert heute `web:latest` / `api:latest`. `IMAGE_TAG` kommt in `.env.example` und `.env.prod.example` nicht vor.
- Server alpha (nur gelesen, `ls`/`grep` per SSH): `/opt/tessera/.env` traegt `COMPOSE_FILE` (Zeile 32) und `APP_URL`; `/opt/tessera/docker-compose.prod.yml` (93 Zeilen) ist die Serverdatei mit `web:latest`/`api:latest` (Zeilen 3 und 23) und weicht vom Repo (94 Zeilen) genau um die fehlende Zeile `TESSERA_MIGRATE_DATABASE_URL` ab. Der aeltere Hinweis in Handbuch Abschnitt 6/7 auf `/opt/tessera/docker-compose.yml` ist damit ueberholt — im neuen Abschnitt 9 die tatsaechliche Datei nennen. Laufende Container: `tessera-web-1`, `tessera-api-1`, `tessera-db-1`.
- YAML-Parser: PyYAML NICHT installiert; `js-yaml@4.2.0` liegt in `node_modules/.pnpm`, ist aber vom Repo-Root nicht per `require('js-yaml')` aufloesbar — nur ueber den vollen Pfad `/home/vicolab/projects/tessera-ctl/node_modules/.pnpm/js-yaml@4.2.0/node_modules/js-yaml` (geprueft: parst `ci.yml`, `on.push.branches = ["main"]`, `tags = undefined`, Jobs `quality,test,publish`). Der Schluessel `on` wird als String geparst (YAML-1.2-Schema).
- `apps/web` haengt NICHT von `@tessera/shared` ab (nur `apps/api`); ein neuer Import wuerde `package.json` + `pnpm-lock.yaml` aendern. Deshalb spiegelt `app-version.ts` den Antworttyp lokal (`ApiVersionInfo`), `VersionResponse` bleibt in `packages/shared` die API-Wahrheit.
- Workspace-Paketversionen stehen NICHT in `pnpm-lock.yaml` (alle 16 Treffer auf `0.0.1` sind Fremdpakete) — ein Bump auf 1.0.0 waere lockfile-neutral, wuerde aber die deps-Stufe beider Dockerfiles (COPY `package.json`) invalidieren. Entscheidung: `package.json`-Versionen bleiben 0.0.1, die Wahrheit ist der Tag; im Handbuch so benannt.
- `health.controller.ts`: kein Spec vorhanden (Wave-0-Scaffold in Task 1). `getVersion()` liest heute die npm-Paketversion, die im Container nie gesetzt ist (Vorgabe `'0.0.1'`); kein Konsument im Web (`grep -rn "health/version" apps/` nur der Controller selbst). `@Public()` = `SetMetadata('isPublic', true)` (`IS_PUBLIC_KEY` aus `../auth/decorators/public.decorator`); Metadaten-Pruefung per `Reflect.getMetadata` wie in `tenant.controller.spec.ts` 300-308.
- Web-Muster: `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'` + `fetch(..., { credentials: 'include' })` (`sidebar.tsx` 12, 43-47; `favorites-api.ts`). `vi.resetModules()` + dynamischer Import: `module-access-gate.test.tsx` 37. Uebersetzungs-Mock: `sidebar.test.tsx` 16-41 (`useTranslations(ns)` -> `map[ns][key] ?? key`, verschachtelte Schluessel als `'categories.label'`).
- Uebersetzungs-Waechter: `umlaut-guard.spec.ts` prueft de.json auf `ae/oe/ue/ss`-Token ausserhalb `UMLAUT_ALLOWLIST` (Fehlermeldung nennt den Fix) und de/en-Schluesselgleichheit; `tenderRadar-parity.spec.ts` nur den Namensraum `tenderRadar`. „Beta", „Live", „Entwicklung" enthalten keines der Token.
- `docs/anleitung-betrieb.md`: 346 Zeilen, 73 Zeilen mit echten Umlauten, viermal `ß` neben „grösseren" — echte Umlaute beibehalten. Anker: Inhaltsverzeichnis 12-21 (Eintrag 8 in Zeile 21), Tabelle Abschnitt 1 Zeilen 32-33 (`:latest`), Konfigurationstabelle bis Zeile 161 (`API_INTERNAL_URL`), Abschnitt 4 Zeilen 194/207/210 (`:latest`), Abschnitt 7 Zeile 320 (`Tessera API running on port 3001`), Abschnitt 8 ab Zeile 334 (Ende der Datei 346). `docs/ci-cd-setup.md`: 169 Zeilen, 0 Umlaute (ASCII-Umschrift beibehalten); Anker: Zeilen 81-93 (Secrets: „keine Secrets", „kein Registry-Push"), 95-117 (Pipeline-Ueberblick: `build-deploy`, „Kein Registry-Push", D-13), 143-147 (Workflow-Dateien, D-11).
- Detektoren: `api-coverage` -> `{"detected":false}`; `assumption-delta scan quick-260914-ku1` -> `{"skipped":true,"reason":"phase_unresolved"}` (Quick-Task ohne ROADMAP-Abschnitt; inhaltlich IST es eine Einzahl-zu-Mehrzahl-Aenderung — ein Etikett wird zu zwei Kanaelen; Entscheidung siehe `<assumption_delta_decision>`); `schema-gate` -> keine Schemadatei in der Erlaubnisliste. Konfiguration: `tdd_mode=false` (Task 1 traegt trotzdem `tdd="true"`, die Tests sind vorab formulierbar), `security_enforcement=true`, ASVS 1, Blocking-Schwelle `high`, `human_verify_mode=end-of-phase`.
- Biome ist im Bestand nicht lauffaehig (WINDOWS #35, `pnpm lint` = Leerlauf) — kein Biome-Gate in diesem Plan; `biome.json` unangetastet.
</planning_measurements>
<assumption_delta_decision>
Noun, das jetzt primaer ist: der **Auslieferungskanal** (`beta` | `live`), nicht das Registry-Etikett. Entscheidung: **promote** — `IMAGE_TAG` in der Compose-Datei und `APP_CHANNEL` im Stempel sind die Primaerdarstellung; `latest` wird zum Alias von `beta` herabgestuft (bleibt nur, damit der bestehende alpha-Server ohne Handgriff weiterlaeuft, und darf spaeter entfallen — Handbuch sagt das). Kein `add-alongside`: es gibt keine Stelle mehr, die `latest` als eigene Wahrheit fuehrt.
</assumption_delta_decision>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Versionsstempel durch alle Schichten — API /health/version, geteilter Typ, Web-Quelle app-version.ts, Abzeichen in der Seitenleiste (Tests zuerst)</name>
<files>packages/shared/src/index.ts, apps/api/src/health/app-version.ts, apps/api/src/health/health.controller.ts, apps/api/src/health/health.controller.spec.ts, apps/api/src/main.ts, apps/web/src/lib/app-version.ts, apps/web/src/lib/app-version.test.ts, apps/web/src/components/layout/app-version-badge.tsx, apps/web/src/components/layout/app-version-badge.test.tsx, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<behavior>
API — `apps/api/src/health/health.controller.spec.ts` (NEU; Stil wie `tenant.controller.spec.ts`: `import 'reflect-metadata'`, deutsche Testnamen „Test N (...)", Kopfkommentar mit Bezug quick-260914-ku1). `vi.stubEnv` fuer `APP_VERSION`, `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME`; `afterEach(() => vi.unstubAllEnvs())`. Controller direkt instanziieren (`new HealthController()`, keine Abhaengigkeiten).
- Test 1 (`check()`): liefert `status: 'ok'` und einen `timestamp`, den `Date.parse` versteht — Regressions-Pin des unveraenderten Healthchecks.
- Test 2 (Vorgaben): ohne die vier Variablen liefert `getVersion()` genau `{ name: 'tessera', version: 'dev', channel: 'dev', commit: '', buildTime: '' }`.
- Test 3 (durchgereicht): `APP_VERSION=v1.2.3`, `APP_CHANNEL=live`, `APP_COMMIT=abc1234`, `APP_BUILD_TIME=2026-09-14T12:00:00Z` -> alle vier Felder woertlich, `name` weiterhin `'tessera'`.
- Test 4 (Compose-Semantik): alle vier Variablen auf `''` gestubbt -> Ergebnis wie Test 2 (leer zaehlt wie ungesetzt; Begruendung: Compose reicht unbelegte Variablen als Leerstring weiter, siehe `migrate-and-start.sh`).
- Test 5 (`formatAppVersionLine`): mit den Werten aus Test 3 -> `'Tessera API v1.2.3 (live) abc1234'`; ohne Commit (Vorgaben) -> `'Tessera API dev (dev)'` — kein Leerzeichen am Ende.
- Test 6 (bewusst oeffentlich, T-KU1-03): `Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.getVersion)` ist `true` und ebenso fuer `check`.
Web — `apps/web/src/lib/app-version.test.ts` (NEU; jeder Test `vi.resetModules()` und `const mod = await import('./app-version')`, weil die Konstante beim Laden des Moduls gelesen und die API-Antwort memoisiert wird; `vi.unstubAllEnvs()` und `vi.unstubAllGlobals()` im `afterEach`):
- Test 1 (Vorgaben): ohne `NEXT_PUBLIC_APP_*` -> `mod.appVersion` gleich `{ version: 'dev', channel: 'dev', commit: '' }`.
- Test 2 (Umgebung): `NEXT_PUBLIC_APP_VERSION=v1.2.3`, `_CHANNEL=beta`, `_COMMIT=abc1234` -> woertlich durchgereicht.
- Test 3 (Normalisierung): `NEXT_PUBLIC_APP_CHANNEL=gamma` -> `channel === 'dev'` (nur `beta` und `live` sind bekannte Kanaele; ein fremder Wert darf keinen fehlenden Uebersetzungsschluessel erzeugen).
- Test 4 (Laden, memoisiert): `vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ name: 'tessera', version: 'v1.2.3', channel: 'beta', commit: 'abc1234', buildTime: 'x' }) })))`; zwei Aufrufe `mod.loadApiVersion()` liefern beide das Objekt, `fetch` wurde genau EINMAL aufgerufen, die URL endet auf `/health/version`, die Optionen enthalten `credentials: 'include'`.
- Test 5 (still bei Fehler): `fetch` lehnt ab (`Promise.reject(new Error('netz'))`) -> `await mod.loadApiVersion()` ist `null`, nichts wird geworfen; zweiter Fall im selben Test mit `ok: false` -> ebenfalls `null`.
Web — `apps/web/src/components/layout/app-version-badge.test.tsx` (NEU; Vorlage `sidebar.test.tsx`: `cleanup`/`vi.restoreAllMocks` im `afterEach`, next-intl-Mock mit `sidebar: { 'channel.beta': 'Beta', 'channel.live': 'Live', 'channel.dev': 'Entwicklung' }`; Modul-Mock `vi.mock('@/lib/app-version', () => ({ appVersion: mockAppVersion, loadApiVersion: mockLoad }))` mit veraenderbaren Variablen; Komponente per dynamischem Import nach dem Setzen der Mocks):
- Test 1 (Zeile): `appVersion = { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' }`, `loadApiVersion` liefert `null` -> `screen.getByTestId('app-version')` hat den Text `v1.2.3 · Beta`.
- Test 2 (Tooltip mit API): `loadApiVersion` liefert `{ name: 'tessera', version: 'v1.2.3', channel: 'beta', commit: 'abc1234', buildTime: '' }` -> `waitFor`: das `title`-Attribut enthaelt `Commit abc1234` UND `API v1.2.3 (beta)`.
- Test 3 (Tooltip ohne API): `loadApiVersion` liefert `null` -> `title` enthaelt `Commit abc1234` und NICHT `API`.
- Test 4 (Kanal dev, kein Commit): `appVersion = { version: 'dev', channel: 'dev', commit: '' }`, `loadApiVersion` -> `null` -> Text `dev · Entwicklung`, und das Element hat KEIN `title`-Attribut (nichts zu zeigen).
Web — `sidebar.test.tsx`: Modul-Mock `vi.mock('@/components/layout/app-version-badge', () => ({ AppVersionBadge: () => <div data-testid="app-version-badge" /> }))` neben dem bestehenden SidebarFooter-Mock; neuer sechster Test „renders the version badge below the navigation" -> nach `waitFor` auf `Dashboard` ist `screen.getByTestId('app-version-badge')` im Dokument (Store-Mock hat `isCollapsed: false`).
</behavior>
<action>
Schritt A — RED: die drei neuen Testdateien und den sechsten Sidebar-Test aus `<behavior>` anlegen, BEVOR Produktionscode entsteht. `pnpm -C apps/api exec vitest run src/health/health.controller.spec.ts` und `pnpm -C apps/web exec vitest run src/lib/app-version.test.ts src/components/layout/app-version-badge.test.tsx src/components/layout/sidebar.test.tsx` muessen rot sein (Modul nicht gefunden bzw. Erwartungen verfehlt) — die Ausgabezeilen ins SUMMARY.
Schritt B — GREEN, API:
1. `packages/shared/src/index.ts`: nach `HealthResponse` das Interface `VersionResponse` mit `name`, `version`, `channel`, `commit`, `buildTime` (alle `string`) exportieren; kurzer Kommentar, dass `channel` in der Praxis `beta` | `live` | `dev` ist und die Wahrheit der Version der Git-Tag ist (quick-260914-ku1).
2. `apps/api/src/health/app-version.ts` (NEU): `getAppVersion(): VersionResponse` liest `process.env.APP_VERSION`, `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME` jeweils mit `||` (NICHT `??`) und den Vorgaben `'dev'`, `'dev'`, `''`, `''`, `name: 'tessera'`; `formatAppVersionLine(v = getAppVersion()): string` baut `Tessera API ${version} (${channel})` und haengt ` ${commit}` nur an, wenn `commit` nicht leer ist. Kopfkommentar (deutsch, ASCII): woher die Werte kommen (Build-Args -> `ENV` in der Runner-Stufe beider Dockerfiles, gesetzt vom CI-Skript `.gitea/scripts/publish-images.sh`), warum `||` (Compose-Leerstring-Semantik) und dass `main.ts` die Zeile beim Start protokolliert.
3. `health.controller.ts`: `getVersion(): VersionResponse` gibt `getAppVersion()` zurueck; Import `VersionResponse` als `import type` neben `HealthResponse`; `@Public()` und `@Get('version')` bleiben. Der bisherige Zugriff auf die npm-Paketversion entfaellt vollstaendig (das Gate greppt darauf, Erwartung 0). Kommentar ueber `getVersion` (drei Zeilen): bewusst oeffentlich — Betreiber-Kontrolle per `curl` auf dem Server ohne Anmeldung, keine Systemkomponenten-Versionen, Repo privat (T-KU1-03).
<!-- planner-discipline-allow: npm_package_version -->
4. `main.ts`: `import { formatAppVersionLine } from './health/app-version';` und direkt nach `console.log('Tessera API running on port 3001');` die Zeile `console.log(formatAppVersionLine());` (gleicher Stil wie die bestehende Zeile; kein Nest-Logger, damit die Zeile im Serverlog neben der Port-Zeile steht — Handbuch Abschnitt 7 nennt beide).
Schritt C — GREEN, Web:
5. `apps/web/src/lib/app-version.ts` (NEU): `export type AppChannel = 'beta' | 'live' | 'dev'`; `export interface AppVersionInfo { version: string; channel: AppChannel; commit: string }`; `export interface ApiVersionInfo { name: string; version: string; channel: string; commit: string; buildTime: string }` (Kommentar: Spiegel von `VersionResponse` aus `packages/shared`, weil `apps/web` nicht von `@tessera/shared` abhaengt und ein neuer Import Lockfile und Docker-deps-Stufe aendern wuerde). `normalizeChannel(raw)` -> `'beta'`/`'live'` durchreichen, alles andere `'dev'`. `export const appVersion: AppVersionInfo` mit `process.env.NEXT_PUBLIC_APP_VERSION || 'dev'`, `normalizeChannel(process.env.NEXT_PUBLIC_APP_CHANNEL)`, `process.env.NEXT_PUBLIC_APP_COMMIT || ''` — JEDER Zugriff mit vollem Literalnamen, kein Destructuring, kein `process.env[name]` (Kopfkommentar erklaert: Next.js ersetzt nur den woertlichen Ausdruck zur Bauzeit, sonst ist der Wert im Browser leer). `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'` wie in `sidebar.tsx`. `export function loadApiVersion(): Promise<ApiVersionInfo | null>` memoisiert ein Modul-Promise: `fetch(`${API_URL}/health/version`, { credentials: 'include' })` -> bei `res.ok` das JSON, sonst `null`; `.catch(() => null)`. Kopfkommentar nennt den Zweck: Quelle fuer das Abzeichen UND den kommenden Fehler-melden-Knopf.
6. `apps/web/src/components/layout/app-version-badge.tsx` (NEU, `'use client'`): `useTranslations('sidebar')`, `useState<ApiVersionInfo | null>(null)`, `useEffect` ruft `loadApiVersion().then(setApi)` einmal. Rendert ein `<span data-testid="app-version" className="block truncate text-xs text-muted-foreground" title={title}>` mit Text `{appVersion.version} · {t(`channel.${appVersion.channel}`)}`. `title`: Teile `Commit ${appVersion.commit}` (nur wenn Commit nicht leer) und `API ${api.version} (${api.channel})` (nur wenn geladen), mit ` · ` verbunden; keine Teile -> `title={undefined}` (kein Attribut). „Commit" und „API" sind in beiden Sprachen gleich, deshalb keine Schluessel dafuer.
7. `sidebar.tsx`: Import `AppVersionBadge` aus `@/components/layout/app-version-badge`; im `sidebarContent` NACH dem Einklapp-Block (gemessen 187-199) und vor dem schliessenden `</div>` (201) einen Block `{!isCollapsed && (<div className="border-t border-sidebar-border px-4 py-2"><AppVersionBadge /></div>)}` — bewusst OHNE `hidden md:block`, damit die mobile Schublade (rendert `sidebarContent`, Zeile 245) die Zeile ebenfalls zeigt; `!isCollapsed` haelt das heutige Muster (eingeklappt: kein Text).
8. `de.json`/`en.json`: im Namensraum `sidebar` ein Objekt `channel` mit `beta: "Beta"`, `live: "Live"`, `dev: "Entwicklung"` bzw. `dev: "Development"` — in BEIDEN Dateien an derselben Stelle (hinter `modules`), sonst faellt der Schluesselgleichheits-Test.
9. `sidebar.test.tsx`: Modul-Mock und sechster Test aus `<behavior>`.
Dann alle betroffenen Specs gruen: API-Spec `Tests 6 passed (6)`; Web `app-version.test.ts` 5, `app-version-badge.test.tsx` 4, `sidebar.test.tsx` 6. Volle Suiten: API `Test Files 65 passed (65)` / `Tests 1060 passed (1060)`, Web `Test Files 40 passed (40)` / `Tests 243 passed (243)`; `tsc --noEmit` in api, web, shared Exit 0. Weicht eine Zahl ab, ist das ein Befund fuer das SUMMARY — erst die Ursache benennen, dann korrigieren.
Commit: `feat(quick-260914-ku1): Versionsstempel — GET /health/version aus APP_*, VersionResponse, app-version.ts und Abzeichen v<Version> · <Kanal> in der Seitenleiste` mit genau den 13 Dateien dieser Aufgabe (`git show --stat HEAD` zeigt 13).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run src/health/health.controller.spec.ts 2>&1 | grep -E "^\s+Tests" ; pnpm -C apps/web exec vitest run src/lib/app-version.test.ts src/components/layout/app-version-badge.test.tsx src/components/layout/sidebar.test.tsx 2>&1 | grep -E "^\s+(Test Files|Tests)" ; grep -c "npm_package_version" apps/api/src/health/health.controller.ts ; grep -c "formatAppVersionLine()" apps/api/src/main.ts ; grep -c "process.env.NEXT_PUBLIC_APP_VERSION" apps/web/src/lib/app-version.ts ; grep -c "<AppVersionBadge />" apps/web/src/components/layout/sidebar.tsx ; node -e "const d=require('./apps/web/src/messages/de.json'),e=require('./apps/web/src/messages/en.json');console.log(d.sidebar.channel.beta,d.sidebar.channel.live,d.sidebar.channel.dev,'|',e.sidebar.channel.dev)" ; for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit; echo "TSC_$p=$?"; done</automated>
</verify>
<done>
API-Spec-Zeile `Tests 6 passed (6)`; Web-Ausgabe `Test Files 3 passed (3)` und `Tests 15 passed (15)`; Greps liefern `0` (npm-Paketversion), `1` (main.ts), `1` (Literalzugriff), `1` (Sidebar); die Node-Zeile lautet `Beta Live Entwicklung | Development`; alle drei `TSC_...=0`. Der RED-Lauf aus Schritt A steht mit seinen Ausgabezeilen im SUMMARY. Commit existiert mit genau 13 Dateien.
</done>
</task>
<task type="auto">
<name>Task 2: Build-Args in beide Dockerfiles, Veroeffentlichungs-Skript und CI-Trigger je Kanal, IMAGE_TAG in der Compose-Datei — Falsifizierung durch lokale Baeue</name>
<files>apps/web/Dockerfile, apps/api/Dockerfile, .gitea/scripts/publish-images.sh, .gitea/workflows/ci.yml, docker-compose.prod.yml</files>
<action>
Schritt A — Dockerfiles (beide): vor dem ersten `FROM` vier globale Args `ARG APP_VERSION=dev`, `ARG APP_CHANNEL=dev`, `ARG APP_COMMIT=`, `ARG APP_BUILD_TIME=` mit einem zweizeiligen Kommentar (ASCII): Werte kommen aus `.gitea/scripts/publish-images.sh`; lokal greifen die Vorgaben; jede nutzende Stufe wiederholt `ARG NAME`, weil ein globales ARG nur die Vorgabe liefert.
- `apps/web/Dockerfile`, Stufe `builder`: NACH den vier `COPY`-Zeilen und der bestehenden Zeile `ENV NEXT_PUBLIC_API_URL=/api-proxy`, unmittelbar VOR `RUN pnpm --filter=@tessera/web build`: `ARG APP_VERSION`, `ARG APP_CHANNEL`, `ARG APP_COMMIT`, dann `ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION NEXT_PUBLIC_APP_CHANNEL=$APP_CHANNEL NEXT_PUBLIC_APP_COMMIT=$APP_COMMIT` (Kommentar: muss VOR dem Build stehen, Next.js bettet zur Bauzeit ein; so spaet wie moeglich, damit die COPY-Schichten im Cache bleiben). Stufe `runner`: nach `ENV NODE_ENV=production` die vier `ARG`-Wiederholungen und `ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME` (Laufzeit-Umgebung, schadet nicht, gleiche Form wie die API).
- `apps/api/Dockerfile`, Stufe `runner`: nach `ENV NODE_ENV=production` dieselben vier `ARG`-Wiederholungen und dieselbe `ENV`-Zeile. Die builder-Stufe der API braucht nichts (Nest liest zur Laufzeit).
Sonst nichts an den Dockerfiles aendern (Nutzer, Ports, CMD, Prisma-Kopierpfade bleiben).
Schritt B — `.gitea/scripts/publish-images.sh` (NEU, POSIX `sh`, `set -eu`, ausfuehrbar `chmod +x`, Kopfkommentar ASCII mit Bezug quick-260914-ku1 und dem Kanalmodell):
- `REGISTRY="${REGISTRY:-localhost:3002/schalli/tessera-ctl}"`, `REF="${GITHUB_REF:-}"`.
- `case "$REF" in refs/tags/v*) APP_CHANNEL=live; TAGS="live ${REF#refs/tags/}" ;; refs/heads/main) APP_CHANNEL=beta; TAGS="beta latest" ;; *) echo "Kein Veroeffentlichungs-Anlass fuer '$REF' (nur main und Tags v*): nichts zu tun."; exit 0 ;; esac` — der Zweig `live` OHNE Tag wird damit gepreuft, aber nicht veroeffentlicht (Begruendung im Kommentar: auf `live` ist jeder auslieferbare Stand ein Tag; ein ungetaggter Merge darf das `live`-Etikett nicht ueberschreiben, sonst waere der Tag nicht mehr die Wahrheit).
- `APP_VERSION="$(git describe --tags --always)"`, `APP_COMMIT="$(git rev-parse --short HEAD)"`, `APP_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"`; eine Ausgabezeile `Tessera $APP_VERSION ($APP_CHANNEL) $APP_COMMIT $APP_BUILD_TIME -> Etiketten: $TAGS`.
- `if [ "${1:-}" = "--print-plan" ]`: fuer `IMG in web api` und `TAG in $TAGS` je eine Zeile `push $REGISTRY/$IMG:$TAG` ausgeben, `exit 0` — kein Docker-Aufruf (fuer lokale Gates und die CI-Fehlersuche).
- Sonst fuer `IMG in web api`: `docker build -t "$REGISTRY/$IMG:$APP_CHANNEL" --build-arg APP_VERSION="$APP_VERSION" --build-arg APP_CHANNEL="$APP_CHANNEL" --build-arg APP_COMMIT="$APP_COMMIT" --build-arg APP_BUILD_TIME="$APP_BUILD_TIME" -f "apps/$IMG/Dockerfile" .`; dann fuer jedes `TAG in $TAGS`: `docker tag "$REGISTRY/$IMG:$APP_CHANNEL" "$REGISTRY/$IMG:$TAG"` und `docker push "$REGISTRY/$IMG:$TAG"`.
- Das Skript gibt niemals ein Secret aus (es kennt keins; der Login bleibt im Workflow).
Schritt C — `.gitea/workflows/ci.yml`: `on.push.branches: [main, live]` und `on.push.tags: ['v*']`. `quality` und `test` unveraendert. `publish`: `actions/checkout@v4` bekommt `with: fetch-depth: 0` (Kommentar: ohne volle Historie und Tags liefert `git describe` nichts — Pflicht fuer den Stempel); der Login-Schritt bleibt byte-identisch; die drei bisherigen Build-/Push-Schritte werden durch EINEN Schritt `Versionsstempel berechnen, Abbilder bauen und veroeffentlichen` mit `run: sh .gitea/scripts/publish-images.sh` ersetzt. Kein `if:` auf Job-Ebene — die Entscheidung liegt im Skript, damit sie lokal mit `--print-plan` pruefbar ist und nicht vom Ausdrucks-Auswerter des Runners abhaengt. Kommentar oben in der Datei (ASCII): Kanalmodell in drei Zeilen.
Schritt D — `docker-compose.prod.yml`: nur die beiden `image:`-Zeilen auf `git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}` bzw. `.../api:${IMAGE_TAG:-beta}` mit einem Kommentar darueber (Ton der Datei, englisch wie die Nachbarkommentare oder deutsch — an den vorhandenen Kommentaren orientieren): `IMAGE_TAG` in `.env` = `beta` oder `live`, Vorgabe `beta`. Registry-Host, Ports, Umgebung, Healthchecks unangetastet. Danach darf kein `:latest` mehr in der Datei stehen (Gate).
<!-- planner-discipline-allow: :latest -->
Schritt E — Falsifizierung (erwartete Dauer 6-12 Minuten fuer vier Baeue, KEIN Haenger; Layer-Cache der deps-Stufe ist warm):
1. `docker build -t tessera-ku1-api:args --build-arg APP_VERSION=v9.9.9-test --build-arg APP_CHANNEL=live --build-arg APP_COMMIT=abc1234 --build-arg APP_BUILD_TIME=2026-09-14T00:00:00Z -f apps/api/Dockerfile .` und dasselbe fuer web (`tessera-ku1-web:args`, `-f apps/web/Dockerfile`).
2. `docker build -t tessera-ku1-api:noargs -f apps/api/Dockerfile .` und `tessera-ku1-web:noargs` — ohne Args.
3. Beweise (Ausgaben ins SUMMARY): `docker run --rm --entrypoint node tessera-ku1-api:args -e 'console.log(process.env.APP_VERSION, process.env.APP_CHANNEL, process.env.APP_COMMIT, process.env.APP_BUILD_TIME)'` -> `v9.9.9-test live abc1234 2026-09-14T00:00:00Z`; `docker run --rm --entrypoint node tessera-ku1-api:args -e 'console.log(require("/app/apps/api/dist/health/app-version").formatAppVersionLine())'` -> `Tessera API v9.9.9-test (live) abc1234` (kompilierter Code liest die Laufzeit-Umgebung); `docker run --rm --entrypoint sh tessera-ku1-web:args -c 'grep -rl "v9.9.9-test" /app/apps/web/.next/static | wc -l'` -> mindestens `1` (Bauzeit-Einbettung im Browser-Bundle); `docker run --rm --entrypoint node tessera-ku1-web:args -e 'console.log(process.env.APP_VERSION)'` -> `v9.9.9-test`; beide `:noargs`-Abbilder -> `dev` bei derselben Node-Zeile, und im Web-Bundle ohne Args ist `v9.9.9-test` NICHT enthalten (`wc -l` -> `0`).
4. Aufraeumen: `docker rmi tessera-ku1-api:args tessera-ku1-web:args tessera-ku1-api:noargs tessera-ku1-web:noargs tessera-web-plancheck:baseline` (die Etiketten; Layer bleiben im Cache).
Schritt F — Gates ohne Docker: js-yaml-Struktur (siehe verify), `--print-plan` in drei Lagen, `docker compose config --images` mit und ohne `IMAGE_TAG`, `git describe --tags --always` liefert einen 7-stelligen Hex-SHA (kein Tag vorhanden, Bau vor dem ersten Tag scheitert nicht).
Commit: `ci(quick-260914-ku1): zwei Kanaele — main -> beta+latest, Tag v* -> live+vX.Y.Z, Versionsstempel als Build-Args in beide Dockerfiles, IMAGE_TAG in docker-compose.prod.yml` mit genau den 5 Dateien dieser Aufgabe.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const y=require('/home/vicolab/projects/tessera-ctl/node_modules/.pnpm/js-yaml@4.2.0/node_modules/js-yaml');const d=y.load(require('fs').readFileSync('.gitea/workflows/ci.yml','utf8'));const p=d.jobs.publish.steps;const co=p.find(s=>(s.uses||'').startsWith('actions/checkout'));console.log('branches='+JSON.stringify(d.on.push.branches),'tags='+JSON.stringify(d.on.push.tags),'jobs='+Object.keys(d.jobs).join(','),'fetchDepth='+(co&&co.with&&co.with['fetch-depth']),'script='+p.some(s=>(s.run||'').includes('publish-images.sh')),'needs='+d.jobs.publish.needs)" ; GITHUB_REF=refs/heads/main sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push .*:\(beta\|latest\)$" ; GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push .*:\(live\|v1.2.3\)$" ; GITHUB_REF=refs/heads/live sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push " ; GITHUB_REF=refs/heads/main sh .gitea/scripts/publish-images.sh --print-plan | grep -cE "^Tessera [0-9a-f]{7} \(beta\) [0-9a-f]{7} [0-9]{4}-" ; docker compose -f docker-compose.prod.yml config --images 2>/dev/null | grep -c ':beta$' ; IMAGE_TAG=live docker compose -f docker-compose.prod.yml config --images 2>/dev/null | grep -c ':live$' ; grep -c ':latest' docker-compose.prod.yml ; grep -c '^ARG APP_VERSION=dev' apps/web/Dockerfile apps/api/Dockerfile ; grep -c 'NEXT_PUBLIC_APP_VERSION=\$APP_VERSION' apps/web/Dockerfile ; grep -c 'ENV APP_VERSION=\$APP_VERSION' apps/web/Dockerfile apps/api/Dockerfile ; test -x .gitea/scripts/publish-images.sh; echo EXEC=$?</automated>
</verify>
<done>
Node-Zeile lautet `branches=["main","live"] tags=["v*"] jobs=quality,test,publish fetchDepth=0 script=true needs=test`; die vier `--print-plan`-Greps liefern `4`, `4`, `0`, `1`; Compose-Greps `2` und `2`; `:latest`-Grep `0`; `ARG`-Grep je Datei `1`; `NEXT_PUBLIC_APP_VERSION`-Grep `1`; `ENV APP_VERSION`-Grep je Datei `1`; `EXEC=0`.
Das SUMMARY traegt unter „Falsifizierung Bauzeit-Einbettung" die sechs Docker-Ausgaben aus Schritt E (`v9.9.9-test live abc1234 ...`, die `Tessera API`-Zeile, die Trefferzahl im Web-Bundle >= 1, `v9.9.9-test` Web-Laufzeit, `dev`/`dev` ohne Args, `0` Treffer ohne Args) und die gemessene Baudauer. Commit existiert mit genau 5 Dateien.
</done>
</task>
<task type="auto">
<name>Task 3: Betriebshandbuch „Zwei Kanäle: Live und Beta", ci-cd-setup auf gemessenen Stand, Push und Beobachtung des echten CI-Laufs</name>
<files>docs/anleitung-betrieb.md, docs/ci-cd-setup.md</files>
<precondition>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 ist der CI-Lauf nicht beobachtbar — dann Push trotzdem, Beobachtung als offenen Punkt ins SUMMARY).</precondition>
<action>
Schritt A — `docs/anleitung-betrieb.md` (echte Umlaute, Alltagssprache, Sie-Form wie im Bestand, ohne Fachjargon, jeder Befehl als Codeblock):
1. Inhaltsverzeichnis (Zeilen 12-21): Eintrag `9. [Zwei Kanäle: Live und Beta](#9-zwei-kanäle-live-und-beta)` hinter Eintrag 8.
2. Abschnitt 1, Tabelle (Zeilen 32-33): `web:latest`/`api:latest` durch `web:${IMAGE_TAG}` bzw. `api:${IMAGE_TAG}` ersetzen und in Klammern „(`beta` oder `live`, siehe Kapitel 9)".
3. Abschnitt 3, Konfigurationstabelle: neue Zeile nach `API_INTERNAL_URL` (Zeile 161): `IMAGE_TAG` | empfohlen | `beta` | Welcher Kanal auf diesem Server läuft: `beta` (alle Neuerungen, alpha) oder `live` (nur freigegebene Versionen, tessera.ctl.de). Siehe Kapitel 9.
4. Abschnitt 4 (Zeilen 194, 207, 210): `:latest` im Text durch „demselben Etikett (`beta` bzw. `live`)" und in den zwei `docker image inspect`-Befehlen durch `:beta` (mit Hinweis „auf dem Live-Server `:live`") ersetzen.
5. Abschnitt 7 (Zeile 320): nach `Tessera API running on port 3001` die neue Zeile `Tessera API v1.0.0 (live) abc1234` als zweite Erwartung nennen (Version, Kanal, Kurzkennung des Standes).
6. Neuer Abschnitt `## 9. Zwei Kanäle: Live und Beta` NACH Abschnitt 8 (Dateiende), mit diesen Unterabschnitten (`###`), jeder in drei bis acht Sätzen plus Befehle:
- „Was ein Kanal ist": Beta = alles Neue, sofort nach jeder Änderung (Adresse alpha.tessera.ctl.de, Etikett `beta`; `latest` ist nur ein zweiter Name für `beta`, bleibt vorerst und kann später wegfallen). Live = nur freigegebene Versionen mit Nummer (tessera.ctl.de, Etikett `live` und zusätzlich `v1.0.0`, `v1.0.1`, …). Die Versionsnummer kommt aus der Freigabe (Git-Tag), nicht aus einer Datei im Code; zwischen zwei Freigaben zeigt die Beta „v1.0.0-12-abc1234" (12 Änderungen nach 1.0.0), vor der allerersten Freigabe nur eine Kurzkennung.
- „Die eine Zeile je Server": `IMAGE_TAG=beta` in `/opt/tessera/.env` auf alpha, `IMAGE_TAG=live` auf dem neuen Server; ohne die Zeile nimmt die Compose-Datei `beta`. Dazu, weil `/opt/tessera` keine Arbeitskopie ist (Kapitel 3, Drift): die zwei `image:`-Zeilen in `/opt/tessera/docker-compose.prod.yml` (das ist die auf alpha benutzte Datei; `.env` setzt `COMPOSE_FILE`) von Hand auf die Form `git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}` und `.../api:${IMAGE_TAG:-beta}` bringen (vorher `cp docker-compose.prod.yml docker-compose.prod.yml.bak.$(date +%Y%m%d)`), danach `pull` und `up -d --force-recreate api web`. Hinweis: die Vorlage `.env.prod.example` enthält die Zeile noch nicht — beim Anlegen einer neuen `.env` von Hand ergänzen.
- „Eine Version freigeben" (was Claude tut; der User sagt nur „Version X freigeben"): `git checkout live`, `git merge --ff-only main` (geht das nicht, ist eine Korrektur noch nicht zurück in `main` — erst Hotfix-Schritt 5 nachholen), `git tag -a vX.Y.Z -m "Tessera X.Y.Z"`, `git push origin live vX.Y.Z`; die Pipeline baut den Stand mit dem Tag und legt `live` + `vX.Y.Z` ab (zwei Läufe: Zweig prüft nur, Tag veröffentlicht). Danach der User auf dem Live-Server: `docker compose -f docker-compose.prod.yml pull` und `docker compose -f docker-compose.prod.yml up -d --force-recreate api web` (Kapitel 4 gilt unverändert). Erstfreigabe v1.0.0 als eigener kleiner Absatz: `git checkout -b live main`, Tag `v1.0.0`, Push — erfolgt nach dem Fehler-melden-Knopf, nicht in diesem Durchlauf.
- „Einen Fehler auf Live beheben (Hotfix)": 1. `git checkout live && git pull`; 2. Korrekturzweig `hotfix/<kurzer-name>` von `live`; 3. Korrektur + Tests; 4. nach `live` mergen, Tag `vX.Y.(Z+1)`, `git push origin live vX.Y.(Z+1)`, User spielt auf Live ein; 5. Korrektur in die Beta: `git checkout main && git merge live` — vorher prüfen, ob sie dort zusammenpasst (Konflikte, Tests), dann `git push` — die Beta bekommt sie mit dem nächsten Lauf. Regel in eigener Fettschrift: **Keine Datenbankänderung als Hotfix.** Begründung in Alltagssprache: Datenbankänderungen (Migrationen) werden nach ihrem Zeitstempel im Namen sortiert und in dieser Reihenfolge ausgeführt; die Beta hat womöglich schon neuere Änderungen eingespielt; eine Hotfix-Änderung mit noch späterem Stempel landet beim Zusammenführen hinter Änderungen, die sie eigentlich nicht kennt — das ist der eine Fall, der beim Übernehmen in die Beta still kaputtgehen kann. Braucht eine Korrektur eine Datenbankänderung, wird sie als reguläre Version über `main` freigegeben.
- „Woran Sie erkennen, welche Version läuft": (a) unten in der Seitenleiste steht `v1.0.0 · Live` bzw. `· Beta`; Maus darüber zeigt die Kurzkennung und die Version des Servers — weichen Oberfläche und Server ab, wurde nur einer der beiden Container neu erstellt (Kapitel 4, `--force-recreate api web`); (b) auf dem Server `curl -s http://localhost:3001/health/version` (Antwortfelder `version`, `channel`, `commit`, `buildTime`); (c) `docker compose -f docker-compose.prod.yml logs api | grep "Tessera API"`.
- „Den neuen Live-Server einrichten": Kapitel 2 gilt vollständig; Abweichungen: `IMAGE_TAG=live` in der `.env`; eigene, neu erzeugte Geheimnisse (`JWT_SECRET`, `TESSERA_ENCRYPTION_KEY`, `DB_PASSWORD`, Admin-Passwort) — nichts von alpha übernehmen; eigene, leere Datenbank (die API legt den ersten Admin an) — die alpha-Datenbank wird NICHT kopiert, es sei denn, das wird ausdrücklich gewünscht (dann Kapitel 6 Wiederherstellung UND derselbe `TESSERA_ENCRYPTION_KEY`, sonst sind gespeicherte Zugangsdaten unbrauchbar); `APP_URL=https://tessera.ctl.de`; erster `pull` holt `:live` — vor der Erstfreigabe v1.0.0 gibt es dieses Etikett noch nicht, deshalb erst freigeben, dann installieren (oder für den Probelauf `IMAGE_TAG=beta`, danach umstellen).
7. Abschnitt 8 bleibt; nur der Verweis „Kapitel 4" um „und 9" ergänzen, falls dort vom Einspielen die Rede ist (Zeile 344-345).
Schritt B — `docs/ci-cd-setup.md` (ASCII-Umschrift wie im Bestand, keine Umlaute):
1. Abschnitt 3 „Gitea Secrets" (Zeilen 81-93): die Aussage, es wuerden keine Secrets gebraucht und es gebe keinen Registry-Push, durch den Stand ersetzen: Secret `REGISTRY_TOKEN` (Gitea-Zugangstoken mit Paket-Schreibrecht), verwendet im Login `docker login localhost:3002 --password-stdin` (Token nie im Log; Gitea maskiert Secrets); Push geht ueber `localhost:3002`, weil der Nginx Proxy Manager vor `git.vicolab.de` grosse Blobs blockt — das Pullen auf den Servern laeuft ueber `git.vicolab.de`.
2. Abschnitt 4 „Pipeline-Ueberblick" (Zeilen 95-117): Trigger `push` auf `main` und `live` sowie Tags `v*`; Jobs `quality` (Lint ist derzeit ein Leerlauf, WINDOWS #35, Type-Check echt) -> `test` -> `publish`; `publish` = `actions/checkout@v4` mit `fetch-depth: 0` (Tags fuer `git describe`), Login, `.gitea/scripts/publish-images.sh`. Etiketten-Tabelle: `main` -> `beta` + `latest` (Alias, entfaellt spaeter); Tag `vX.Y.Z` -> `live` + `vX.Y.Z`; Zweig `live` ohne Tag -> nur pruefen. Stempel: `APP_VERSION` (`git describe --tags --always`), `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME` als `--build-arg` in beide Dockerfiles; Web bettet `NEXT_PUBLIC_APP_*` zur Bauzeit ein, deshalb baut der Web-`builder` jetzt bei jedem Lauf neu (Laufzeit eher 4-6 statt 2 Minuten). Lokale Probe: `GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan`. Der Unterabschnitt „Kein Registry-Push" und das Wort `build-deploy` verschwinden; D-13 als ueberholt kennzeichnen.
<!-- planner-discipline-allow: Kein Registry-Push, build-deploy -->
3. Abschnitt 5 „Workflow-Dateien" (143-147): einen Satz ergaenzen — ein Tag-Push ist der Freigabe-Hebel fuer Live; heute hat nur das Konto `schalli` Schreibrecht (0 Kollaborateure, keine Branch-Regeln); kommen weitere Konten dazu, in Gitea eine Tag-Schutzregel fuer `v*` und Branch-Schutz fuer `live` anlegen (T-KU1-04).
4. Abschnitt 6 „Fehlerbehebung": Punkt „Stempel zeigt `dev` oder nur eine Kurzkennung statt des Tags" -> `fetch-depth: 0` im Checkout pruefen und ob der Tag gepusht wurde (`git ls-remote --tags origin`).
Schritt C — Gates, Commit, Push, Beobachtung:
1. `grep -c "^## 9. Zwei Kanäle: Live und Beta" docs/anleitung-betrieb.md` -> 1; `grep -c "IMAGE_TAG" docs/anleitung-betrieb.md` -> mindestens 6; `grep -c "Keine Datenbankänderung als Hotfix" docs/anleitung-betrieb.md` -> 1; `grep -c "Kein Registry-Push\|build-deploy" docs/ci-cd-setup.md` -> 0; `grep -c "publish-images.sh" docs/ci-cd-setup.md` -> mindestens 2; `grep -c '[äöüÄÖÜß]' docs/ci-cd-setup.md` -> 0 (ASCII-Konvention der Datei gehalten).
2. `D=$(git diff --stat 6c19451 -- . ':!.planning'); echo GIT_EXIT=$?; tail -n1 <<< "$D"` -> `GIT_EXIT=0` und `20 files changed`; Stichprobe unangetastet: `git diff --stat 6c19451 -- apps/api/prisma biome.json '.env*' apps/web/package.json apps/api/package.json pnpm-lock.yaml` -> leer.
3. Commit: `docs(quick-260914-ku1): Betriebshandbuch — Zwei Kanäle Live und Beta, Freigabe, Hotfix ohne Datenbankänderung, neuer Live-Server; ci-cd-setup auf gemessenen Stand` (nur die 2 Dateien). Danach `git push` (schlichter Aufruf, die Push-URL zeigt auf localhost:3002); `S=$(git status -sb); echo GIT_EXIT=$?; head -n1 <<< "$S"` -> `GIT_EXIT=0`, kein `[ahead`.
4. Beobachtung des echten CI-Laufs (Token NIE ausgeben — nur in der Variablen verwenden): `PUSHED=$(git rev-parse HEAD); PUSHURL=$(git config --get remote.origin.pushurl); TOK=$(printf '%s' "$PUSHURL" | sed -E 's#.*schalli:([^@]+)@.*#\1#')`; dann 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, den Eintrag mit `head_sha == PUSHED` nehmen und auf `status == completed` warten (als Hintergrundbefehl starten, falls `sleep` im Vordergrund blockiert ist). Erwartung `conclusion == success`. Danach auf dem Host: `docker run --rm --entrypoint node localhost:3002/schalli/tessera-ctl/api:beta -e 'console.log(process.env.APP_VERSION, process.env.APP_CHANNEL)'` -> `<kurzer SHA von PUSHED> beta` (Vergleich mit `git rev-parse --short $PUSHED`), und `docker image inspect -f '{{.Created}}' localhost:3002/schalli/tessera-ctl/web:beta localhost:3002/schalli/tessera-ctl/web:latest` zeigt zwei gleiche Zeitstempel NACH dem Push (beide Etiketten aus demselben Bau). Ergebnis (Lauf-ID, Dauer `started_at`/`completed_at`, Stempel-Ausgabe) ins SUMMARY. Ist `conclusion` nicht `success`: Job-Log ueber `.../actions/runs/<id>/jobs` lesen, Ursache im SUMMARY benennen, Korrektur als eigener `fix(quick-260914-ku1)`-Commit, erneut pushen und beobachten — die haeufigste Ursache waere ein Runner-Umgebungsdetail (Git im Job-Container, `GITHUB_REF` nicht gesetzt); das Skript-Design mit `--print-plan` grenzt das ein.
5. Wird das SUMMARY erst nach dem Push committet, den Push danach wiederholen (ein weiterer CI-Lauf ist erwartet und in Ordnung).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -c "^## 9. Zwei Kanäle: Live und Beta" docs/anleitung-betrieb.md ; grep -c "IMAGE_TAG" docs/anleitung-betrieb.md ; grep -c "Keine Datenbankänderung als Hotfix" docs/anleitung-betrieb.md ; grep -c "Kein Registry-Push\|build-deploy" docs/ci-cd-setup.md ; grep -c "publish-images.sh" docs/ci-cd-setup.md ; grep -c '[äöüÄÖÜß]' docs/ci-cd-setup.md ; D=$(git diff --stat 6c19451 -- . ':!.planning'); echo GIT_EXIT=$? ; tail -n1 <<< "$D" ; U=$(git diff --stat 6c19451 -- apps/api/prisma biome.json '.env*' apps/web/package.json apps/api/package.json pnpm-lock.yaml); echo U_EXIT=$? ; test -z "$U"; echo U_EMPTY=$? ; S=$(git status -sb); head -n1 <<< "$S"</automated>
</verify>
<done>
Greps liefern `1`, `>= 6`, `1`, `0`, `>= 2`, `0`; `GIT_EXIT=0` und die Summenzeile nennt `20 files changed`; die Unangetastet-Stichprobe liefert `U_EXIT=0` und `U_EMPTY=0`; die Status-Zeile enthaelt kein `[ahead`. Das SUMMARY traegt unter „CI-Lauf nach dem Push" Lauf-ID, `conclusion`, Dauer und die Stempel-Zeile `<sha> beta` der vom Runner gebauten Abbilder (oder, falls Gitea/Runner nicht erreichbar waren, den Grund und den offenen Punkt).
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Gitea-Repository -> CI-Runner -> Registry | Ein Push auf `main` oder ein Tag `v*` loest Bau und Veroeffentlichung aus; der Runner haelt das Registry-Token als Secret und den Host-Docker-Socket |
| Registry -> Server (alpha, Live) | `docker compose pull` holt das Etikett aus `IMAGE_TAG`; welches Etikett, entscheidet eine Zeile in `.env` auf dem Server |
| Internet -> `GET /health/version` (@Public) | Unauthentifizierter Aufrufer erfaehrt Name, Version, Kanal, Commit-Kurzkennung, Bauzeit |
| Angemeldeter Nutzer -> Seitenleiste | Sieht Version, Kanal, Web-Commit und API-Version im Tooltip |
| Entwicklungsablauf (Hotfix) -> Datenbank der Beta | Ein Merge `live -> main` bringt Aenderungen in eine Umgebung mit moeglicherweise neueren Migrationen |
## STRIDE Threat Register (ASVS Level 1, Blocking-Schwelle `high`)
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-KU1-01 | Information Disclosure | `REGISTRY_TOKEN` im CI-Log (`ci.yml` Login-Schritt, neues Skript) | medium | mitigate | Login bleibt `--password-stdin` aus `${{ secrets.REGISTRY_TOKEN }}` (Gitea maskiert Secrets im Log); das Skript kennt das Token nicht und gibt nur Versions-/Etikettenzeilen aus; Task 3 Schritt C4 verwendet das Push-Token nur in einer Shell-Variablen, nie in einer Ausgabe |
| T-KU1-02 | Information Disclosure | Web-Commit-Kurzkennung im Tooltip fuer angemeldete Nutzer (`app-version-badge.tsx`) | low | accept | Repository ist privat (Gitea, Fetch nur mit Konto); ein 7-stelliger SHA ist ohne Repo-Zugang nicht verwertbar; die Beta-Versionszeichenkette `v1.0.0-12-gabc1234` enthaelt ihn ohnehin; Nutzen fuer Fehlermeldungen ueberwiegt |
| T-KU1-03 | Information Disclosure | `GET /health/version` bleibt `@Public()` mit allen Feldern (`health.controller.ts`) | low | accept | Bewusste Entscheidung, durch Spec-Test 6 gepinnt: Hauptzweck ist die Betreiber-Kontrolle per `curl` auf dem Server ohne Anmeldung (Handbuch Abschnitt 9); es werden keine Versionen von Systemkomponenten (Node, Nest, Postgres) preisgegeben — ASVS V14.3.3 zielt auf solche; Repo privat, Tessera laeuft hinter dem Nginx Proxy Manager fuer interne Nutzer. Wird Tessera spaeter extern verkauft, `commit`/`buildTime` hinter die Anmeldung ziehen (eine Zeile: `@Public()` entfernen, Abzeichen ruft ohnehin mit Cookie) |
| T-KU1-04 | Elevation of Privilege | Tag-Push `v*` als Freigabe-Hebel fuer Live (`ci.yml`, Skript) | medium | accept | Gemessen: 0 Kollaborateure, nur `schalli` hat Schreibrecht, Claude ist einziger Committer (D-11); kein Fremdcode. Empfehlung in `ci-cd-setup.md` Abschnitt 5: bei weiteren Konten Tag-Schutz `v*` und Branch-Schutz `live` in Gitea. Gitea-Konfiguration liegt ausserhalb der Erlaubnisliste |
| T-KU1-05 | Tampering | Falscher Kanal auf einem Server (`IMAGE_TAG` fehlt/falsch, Live zieht Beta) | medium | mitigate | Vorgabe `beta` ist fuer den bestehenden alpha-Server der richtige und fuer den Live-Server der auffaellige Fall (Seitenleiste zeigt `· Beta`, `/health/version` `channel: beta`); Handbuch nennt die Zeile je Server und die drei Kontrollwege; `docker compose config`-Gate beweist die Aufloesung beider Werte |
| T-KU1-06 | Tampering | Hotfix mit Migration bricht beim Merge `live -> main` die Beta-Datenbank (Prisma sortiert nach Zeitstempel) | medium | mitigate | Regel „Keine Datenbankaenderung als Hotfix" im Handbuch mit Begruendung in Alltagssprache; Korrekturen mit Migration gehen nur als regulaere Version ueber `main`; Prisma-Schema und Migrationen in diesem Plan unangetastet |
| T-KU1-07 | Denial of Service | Ungetaggter Push auf `live` ueberschreibt das `live`-Etikett mit einem ungepruefte Stand | medium | mitigate | Skript veroeffentlicht NUR fuer `refs/heads/main` und `refs/tags/v*`; jeder andere Ref endet mit „nichts zu tun" — durch `--print-plan`-Gate (Task 2, dritter Grep = 0) gepinnt |
| T-KU1-08 | Repudiation | Welcher Stand laeuft, ist ohne Stempel nicht nachvollziehbar (heute immer `0.0.1`) | low | mitigate | Genau der Gegenstand des Plans: Stempel im Abbild, in der Antwort, im Startlog und in der Oberflaeche; CI-Lauf nach dem Push beweist den echten Weg (Task 3 Schritt C4) |
| T-KU1-SC | Tampering | npm/pip/cargo installs | low | accept | Dieser Plan installiert KEIN Paket (keine neue Abhaengigkeit, `pnpm-lock.yaml` unangetastet — Gate in Task 3); Paketlegitimitaets-Gate nicht ausgeloest |
</threat_model>
<verification>
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 65 passed (65)` / `Tests 1060 passed (1060)`
- `pnpm -C apps/web exec vitest run 2>&1 | grep -E "^\s+(Test Files|Tests)"` -> `Test Files 40 passed (40)` / `Tests 243 passed (243)`
- `for p in packages/shared apps/api apps/web; do pnpm -C $p exec tsc --noEmit; echo "TSC_$p=$?"; done` -> dreimal `=0`
- `D=$(git diff --stat 6c19451 -- . ':!.planning'); echo GIT_EXIT=$?; tail -n1 <<< "$D"` -> `GIT_EXIT=0` und `20 files changed`
- `U=$(git diff --stat 6c19451 -- apps/api/prisma biome.json '.env*' apps/web/package.json apps/api/package.json pnpm-lock.yaml); echo U_EXIT=$?; test -z "$U"; echo U_EMPTY=$?` -> `U_EXIT=0` und `U_EMPTY=0`
- `GITHUB_REF=refs/heads/live sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push "` -> `0`; `GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan | grep -c "^push "` -> `4`
- `IMAGE_TAG=live docker compose -f docker-compose.prod.yml config --images 2>/dev/null | grep -c ':live$'` -> `2`
- `docker run --rm --entrypoint node localhost:3002/schalli/tessera-ctl/api:beta -e 'console.log(process.env.APP_VERSION, process.env.APP_CHANNEL)'` -> `<kurzer SHA des gepushten Commits> beta` (nach abgeschlossenem CI-Lauf)
- SUMMARY enthaelt: RED-Laeufe (Task 1), „Falsifizierung Bauzeit-Einbettung" mit sechs Docker-Ausgaben und Baudauer (Task 2), „CI-Lauf nach dem Push" mit Lauf-ID/Dauer/Stempel (Task 3), Nebenbefund „`sidebar-footer.tsx` ist seit ba02b25 toter Code" und den Hinweis, dass `.env.prod.example` bewusst nicht angefasst wurde (Regel `.env`-Dateien) — die `IMAGE_TAG`-Zeile steht nur im Handbuch.
- Human-Check (end-of-phase, nicht blockierend): lokal `docker compose up -d --build web api` und im Browser unten in der Seitenleiste `dev · Entwicklung` sehen, Tooltip `API dev (dev)`; eingeklappt verschwindet die Zeile.
</verification>
<success_criteria>
- Versionsstempel fliesst CI -> Build-Args -> Abbilder -> `GET /health/version` / Startlog -> Seitenleiste; lokal ohne Args bleibt alles `dev`; die Bauzeit-Einbettung im Browser-Bundle ist mit `v9.9.9-test` bewiesen; der echte CI-Lauf nach dem Push hat `:beta`-Abbilder mit dem SHA des gepushten Commits gebaut.
- Pipeline: `main` -> `beta` + `latest`; Tag `v*` -> `live` + `vX.Y.Z`; `live` ohne Tag prueft nur; `fetch-depth: 0`; Entscheidung im Skript, lokal per `--print-plan` gepinnt.
- `docker-compose.prod.yml` mit `${IMAGE_TAG:-beta}`, beide Werte per `config --images` bewiesen; Registry-Host unangetastet.
- Handbuch Abschnitt 9 in Alltagssprache mit echten Umlauten (Kanal, `.env`-Zeile, Freigabe, Hotfix ohne Datenbankaenderung, Versionskontrolle, neuer Live-Server, Erstfreigabe-Rezept); `ci-cd-setup.md` ohne die veralteten Aussagen.
- API 1060/65, Web 243/40, `tsc` dreimal 0, genau 20 Dateien ausserhalb `.planning`, drei Commits mit Scope `quick-260914-ku1`, gepusht; `live`-Zweig und `v1.0.0` NICHT angelegt.
</success_criteria>
<output>
Create `.planning/quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/260914-ku1-SUMMARY.md` when done
</output>
@@ -0,0 +1,361 @@
---
phase: quick-260914-ku1
plan: 01
subsystem: infra
tags: [ci, gitea-actions, docker, build-args, next-public-env, nestjs, health, versionsstempel, compose, handbuch]
status: complete
requires:
- phase: quick-260914-ku1 planning (1cd4212)
provides: Plan mit Erlaubnisliste, gemessenen Bezugszahlen und Threat-Register
provides:
- "GET /health/version liefert { name, version, channel, commit, buildTime } aus APP_* (Vorgaben dev/dev), Startzeile `Tessera API <version> (<channel>) <commit>`"
- "VersionResponse in packages/shared; apps/web/src/lib/app-version.ts als importierbare Quelle (appVersion + loadApiVersion) fuer Abzeichen und kommenden Fehler-melden-Knopf"
- "AppVersionBadge unten in der Seitenleiste (Desktop und mobile Schublade, nicht eingeklappt) mit Tooltip Commit/API-Version"
- "Beide Dockerfiles nehmen APP_VERSION/APP_CHANNEL/APP_COMMIT/APP_BUILD_TIME als Build-Args; Web bettet NEXT_PUBLIC_APP_* zur Bauzeit ein"
- ".gitea/scripts/publish-images.sh entscheidet Kanal/Etiketten aus GITHUB_REF (main -> beta+latest, v* -> live+vX.Y.Z, sonst nichts), --print-plan ohne Docker"
- "ci.yml loest auf main, live und Tags v* aus; publish mit fetch-depth 0 und Skriptaufruf"
- "docker-compose.prod.yml mit ${IMAGE_TAG:-beta} fuer web und api"
- "Betriebshandbuch Kapitel 9 (Kanaele, IMAGE_TAG je Server, Freigabe, Hotfix ohne Datenbankaenderung, Versionskontrolle, neuer Live-Server); ci-cd-setup auf gemessenen Stand"
affects: [fehler-melden-knopf, erstfreigabe-v1.0.0, live-server-einrichtung, deploy]
actuals:
tokens: 41545
tasks: 3
commits: 3
plan_head_before: 1cd4212df08cb0910e6c5c02abf6926534951935
tech-stack:
added: []
patterns:
- "Versionsstempel-Kette: CI-Skript -> --build-arg -> globales ARG + ARG-Wiederholung je Stufe -> ENV (runner) bzw. NEXT_PUBLIC_* vor pnpm build (web-builder)"
- "Kanalentscheidung im POSIX-Skript statt in Workflow-if-Ausdruecken, lokal per --print-plan pruefbar"
- "process.env.NEXT_PUBLIC_* nur mit vollem Literalnamen lesen (Bauzeit-Einbettung durch Next.js)"
- "||-Vorgaben fuer Compose-Leerstring-Semantik (leer == ungesetzt)"
key-files:
created:
- apps/api/src/health/app-version.ts
- apps/api/src/health/health.controller.spec.ts
- apps/web/src/lib/app-version.ts
- apps/web/src/lib/app-version.test.ts
- apps/web/src/components/layout/app-version-badge.tsx
- apps/web/src/components/layout/app-version-badge.test.tsx
- .gitea/scripts/publish-images.sh
modified:
- packages/shared/src/index.ts
- apps/api/src/health/health.controller.ts
- apps/api/src/main.ts
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/components/layout/sidebar.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/Dockerfile
- apps/api/Dockerfile
- .gitea/workflows/ci.yml
- docker-compose.prod.yml
- docs/anleitung-betrieb.md
- docs/ci-cd-setup.md
key-decisions:
- "Kanal ist die Primaerdarstellung (IMAGE_TAG, APP_CHANNEL); `latest` nur noch Alias von `beta`, damit alpha ohne Handgriff weiterlaeuft"
- "Zweig `live` ohne Tag wird geprueft, aber nicht veroeffentlicht — nur ein Tag darf das live-Etikett belegen (T-KU1-07)"
- "package.json-Versionen bleiben 0.0.1; die Wahrheit der Version ist der Git-Tag (git describe)"
- "GET /health/version bleibt @Public (T-KU1-03), per Spec-Test gepinnt"
- "apps/web spiegelt den Antworttyp lokal (ApiVersionInfo) statt @tessera/shared zu importieren — Lockfile und Docker-deps-Stufe bleiben unangetastet"
- "Commits direkt auf main (Projektkonvention branching_strategy: none; das Kanalmodell setzt main = Beta voraus)"
patterns-established:
- "ARG-Sichtbarkeit in Multi-Stage-Dockerfiles: globales ARG mit Vorgabe + ARG NAME (ohne Wert) in jeder nutzenden Stufe"
- "Memoisiertes Modul-Promise fuer einmalige API-Abfragen je Seitenladung, still bei Fehler"
requirements-completed: [QUICK-260914-KU1]
coverage:
- id: D1
description: "GET /health/version aus APP_* mit Vorgaben, Compose-Leerstring-Semantik, Startzeile, @Public gepinnt"
requirement: QUICK-260914-KU1
verification:
- kind: unit
ref: "apps/api/src/health/health.controller.spec.ts (6 Tests)"
status: pass
- kind: integration
ref: "docker run tessera-ku1-api:args node -e formatAppVersionLine() -> Tessera API v9.9.9-test (live) abc1234"
status: pass
- id: D2
description: "Web-Quelle app-version.ts (appVersion, loadApiVersion memoisiert/still) und AppVersionBadge in der Seitenleiste"
requirement: QUICK-260914-KU1
verification:
- kind: unit
ref: "apps/web/src/lib/app-version.test.ts (5), app-version-badge.test.tsx (4), sidebar.test.tsx#renders the version badge below the navigation"
status: pass
- kind: integration
ref: "grep -rl v9.9.9-test /app/apps/web/.next/static | wc -l -> 1 (Bauzeit-Einbettung)"
status: pass
- id: D3
description: "CI-Trigger je Kanal, publish-images.sh, Build-Args in beiden Dockerfiles, IMAGE_TAG in Compose"
requirement: QUICK-260914-KU1
verification:
- kind: automated_ui
ref: "js-yaml-Strukturpruefung, --print-plan in drei Lagen, docker compose config --images mit/ohne IMAGE_TAG"
status: pass
- kind: e2e
ref: "Gitea-Actions-Lauf 297 (success) und CI-gebaute :beta-Abbilder mit APP_VERSION=ea6aa99 APP_CHANNEL=beta"
status: pass
- id: D4
description: "Betriebshandbuch Kapitel 9 und ci-cd-setup auf gemessenen Stand"
requirement: QUICK-260914-KU1
verification:
- kind: other
ref: "grep-Gates (Kapitelueberschrift 1, IMAGE_TAG 11, Hotfix-Regel 1, veraltete Aussagen 0, Skriptname 4, Umlaute in ci-cd-setup 0)"
status: pass
metrics:
duration: "22 min (13:28Z bis 13:50Z, davon ca. 7,5 min lokale Docker-Bauten und 5,3 min CI-Lauf)"
completed: "2026-09-14"
---
# Quick 260914-ku1 Plan 01: Zwei Auslieferungskanaele (Beta auf main, Live per Tag) mit Versionsstempel durch alle Schichten — Summary
Versionsstempel `APP_VERSION/APP_CHANNEL/APP_COMMIT/APP_BUILD_TIME` fliesst vom CI-Skript ueber Build-Args in beide Abbilder, aus `GET /health/version` und dem Startlog der API und als `v<Version> · <Kanal>` unten in der Seitenleiste; `main` veroeffentlicht `beta`+`latest`, ein Tag `v*` veroeffentlicht `live`+`vX.Y.Z`; `docker-compose.prod.yml` waehlt den Kanal ueber `${IMAGE_TAG:-beta}`; das Betriebshandbuch erklaert Kanaele, Freigabe, Hotfix (ohne Datenbankaenderung) und den neuen Live-Server. Der echte CI-Weg ist einmal durchlaufen: Lauf 297 hat `:beta`-Abbilder mit `ea6aa99 beta` gebaut.
## Ausgangslage und Bezugspunkt
Alle Gates gegen `6c19451` (Code unangetastet seit Planung; HEAD bei Start `1cd4212`, Arbeitsbaum sauber, `main == origin/main`).
Baseline vor jeder Aenderung (erneut gemessen, identisch mit der Planung):
| Suite | Befehl | Ergebnis |
|---|---|---|
| API | `pnpm -C apps/api exec vitest run` | `Test Files 64 passed (64)` / `Tests 1054 passed (1054)`, Exit 0 |
| Web | `pnpm -C apps/web exec vitest run` | `Test Files 38 passed (38)` / `Tests 233 passed (233)`, Exit 0 |
## Task 1 — Versionsstempel durch alle Schichten (TDD)
### RED-Lauf (Schritt A, vor jedem Produktionscode)
`pnpm -C apps/api exec vitest run src/health/health.controller.spec.ts` (Exit 1):
```
Error: Cannot find module './app-version' imported from '.../apps/api/src/health/health.controller.spec.ts'
Test Files 1 failed (1)
Tests no tests
```
`pnpm -C apps/web exec vitest run src/lib/app-version.test.ts src/components/layout/app-version-badge.test.tsx src/components/layout/sidebar.test.tsx` (Exit 1):
```
❯ src/components/layout/app-version-badge.test.tsx (0 test) Failed to resolve import "./app-version-badge"
❯ src/lib/app-version.test.ts (0 test) Failed to resolve import "./app-version"
❯ src/components/layout/sidebar.test.tsx (6 tests | 1 failed)
× renders the version badge below the navigation Unable to find an element by: [data-testid="app-version-badge"]
Test Files 3 failed (3)
Tests 1 failed | 5 passed (6)
```
### GREEN und Gate (Schritt B/C)
Verify-Block von Task 1, gemessen:
| Pruefung | Erwartet | Gemessen |
|---|---|---|
| API-Spec `health.controller.spec.ts` | `Tests 6 passed (6)` | `Tests 6 passed (6)` |
| Web drei Specs | `Test Files 3 passed (3)` / `Tests 15 passed (15)` | `Test Files 3 passed (3)` / `Tests 15 passed (15)` |
| Plan-Checker-Zusatz: obige drei + `umlaut-guard.spec.ts` + `tenderRadar-parity.spec.ts` in EINEM Aufruf | gruen | `Test Files 5 passed (5)` / `Tests 21 passed (21)` |
| `grep -c npm_package_version health.controller.ts` | 0 | 0 |
| `grep -c "formatAppVersionLine()" main.ts` | 1 | 1 |
| `grep -c process.env.NEXT_PUBLIC_APP_VERSION app-version.ts` | 1 | 1 |
| `grep -c "<AppVersionBadge />" sidebar.tsx` | 1 | 1 |
| Node-Zeile Uebersetzungen | `Beta Live Entwicklung \| Development` | `Beta Live Entwicklung \| Development` |
| `tsc --noEmit` shared / api / web | 0 / 0 / 0 | 0 / 0 / 0 |
| API-Suite voll | 65 / 1060 | `Test Files 65 passed (65)` / `Tests 1060 passed (1060)` |
| Web-Suite voll | 40 / 243 | `Test Files 40 passed (40)` / `Tests 243 passed (243)` |
Commit `cdb571c` — `git show --stat HEAD` zeigt 13 Dateien (471+/8-).
## Task 2 — Build-Args, Veroeffentlichungs-Skript, CI-Trigger, IMAGE_TAG
### Gates ohne Docker (Schritt F), gemessen
```
branches=["main","live"] tags=["v*"] jobs=quality,test,publish fetchDepth=0 script=true needs=test
main_plan=4 tag_plan=4 live_plan=0 stamp=1
compose_beta=2 compose_live=2 latest-in-compose=0
ARG APP_VERSION=dev: api 1 / web 1; NEXT_PUBLIC_APP_VERSION=$APP_VERSION: 1; ENV APP_VERSION=$APP_VERSION: web 1 / api 1; EXEC=0
git describe --tags --always -> cdb571c (kein Tag vorhanden, Bau vor dem ersten Tag scheitert nicht)
```
`--print-plan` in den drei Lagen (woertlich):
```
GITHUB_REF=refs/heads/main:
Tessera cdb571c (beta) cdb571c 2026-09-14T13:33:40Z -> Etiketten: beta latest
push localhost:3002/schalli/tessera-ctl/web:beta
push localhost:3002/schalli/tessera-ctl/web:latest
push localhost:3002/schalli/tessera-ctl/api:beta
push localhost:3002/schalli/tessera-ctl/api:latest
GITHUB_REF=refs/tags/v1.2.3:
Tessera cdb571c (live) cdb571c 2026-09-14T13:33:40Z -> Etiketten: live v1.2.3
push localhost:3002/schalli/tessera-ctl/web:live
push localhost:3002/schalli/tessera-ctl/web:v1.2.3
push localhost:3002/schalli/tessera-ctl/api:live
push localhost:3002/schalli/tessera-ctl/api:v1.2.3
GITHUB_REF=refs/heads/live:
Kein Veroeffentlichungs-Anlass fuer 'refs/heads/live' (nur main und Tags v*): nichts zu tun.
```
`docker compose -f docker-compose.prod.yml config --images` ohne Variable: `web:beta`, `api:beta`, `postgres:16-alpine`; mit `IMAGE_TAG=live`: `api:live`, `postgres:16-alpine`, `web:live`.
### Falsifizierung Bauzeit-Einbettung (Schritt E)
Baudauer (warmer deps-Cache): api:args 111 s, web:args 129 s, api:noargs 113 s, web:noargs 99 s — zusammen 7 min 32 s, alle Exit 0.
| # | Befehl (Kurzform) | Erwartet | Gemessen |
|---|---|---|---|
| 1 | `api:args` node `APP_VERSION APP_CHANNEL APP_COMMIT APP_BUILD_TIME` | `v9.9.9-test live abc1234 2026-09-14T00:00:00Z` | `v9.9.9-test live abc1234 2026-09-14T00:00:00Z` |
| 2 | `api:args` node `require("/app/apps/api/dist/health/app-version").formatAppVersionLine()` | `Tessera API v9.9.9-test (live) abc1234` | `Tessera API v9.9.9-test (live) abc1234` |
| 3 | `web:args` sh `grep -rl v9.9.9-test /app/apps/web/.next/static \| wc -l` | >= 1 | `1` |
| 4 | `web:args` node `APP_VERSION` | `v9.9.9-test` | `v9.9.9-test` |
| 5 | `api:noargs` node `APP_VERSION APP_CHANNEL` / `web:noargs` node `APP_VERSION` | `dev dev` / `dev` | `dev dev` / `dev` |
| 6 | `web:noargs` Bundle-Grep auf `v9.9.9-test` | `0` | `0` |
| 6b | `api:noargs` dist-Zeile (Zusatz) | `Tessera API dev (dev)` | `Tessera API dev (dev)` |
Aufgeraeumt: `docker rmi` der vier `tessera-ku1-*`-Etiketten und `tessera-web-plancheck:baseline` (alle „Untagged", `docker images` zeigt keine mehr).
Commit `9731501` — 5 Dateien (115+/13-).
## Task 3 — Handbuch, ci-cd-setup, Push, CI-Beobachtung
Precondition: `curl localhost:3002/api/v1/version` -> `{"version":"1.26.2"}`; `docker ps | grep -c ^gitea-runner$` -> 1.
Gates, gemessen:
| Pruefung | Erwartet | Gemessen |
|---|---|---|
| `grep -c "^## 9. Zwei Kanäle: Live und Beta"` | 1 | 1 |
| `grep -c IMAGE_TAG anleitung-betrieb.md` | >= 6 | 11 |
| `grep -c "Keine Datenbankänderung als Hotfix"` | 1 | 1 |
| `grep -c "Kein Registry-Push\|build-deploy" ci-cd-setup.md` | 0 | 0 |
| `grep -c publish-images.sh ci-cd-setup.md` | >= 2 | 4 |
| `grep -c '[äöüÄÖÜß]' ci-cd-setup.md` | 0 | 0 |
| `git diff --stat 6c19451 -- . ':!.planning'` | `GIT_EXIT=0`, `20 files changed` | `GIT_EXIT=0`, `20 files changed, 851 insertions(+), 49 deletions(-)` |
| Unangetastet-Stichprobe (prisma, biome.json, .env*, package.json, lockfile) | `U_EXIT=0`, `U_EMPTY=0` | `U_EXIT=0`, `U_EMPTY=0` |
| `git status -sb` nach Push | kein `[ahead` | `## main...origin/main` |
Commit `ea6aa99` — 2 Dateien (265+/28-). `git push` -> `6c19451..ea6aa99 main -> main`.
### CI-Lauf nach dem Push
| Feld | Wert |
|---|---|
| Lauf-ID | 297 (event `push`, ref `main`, `head_sha` = `ea6aa99…`) |
| status / conclusion | `completed` / `success` |
| started_at / completed_at | 2026-09-14T15:44:23+02:00 / 2026-09-14T15:49:41+02:00 — **5 min 18 s** (Planung: 4-6 min mit Build-Args) |
| `api:beta` node `APP_VERSION APP_CHANNEL APP_COMMIT APP_BUILD_TIME` | `ea6aa99 beta ea6aa99 2026-09-14T13:46:12Z` (`git rev-parse --short ea6aa99` = `ea6aa99`) |
| `web:beta` node `APP_VERSION APP_CHANNEL` | `ea6aa99 beta` |
| `web:beta` Bundle-Grep auf `ea6aa99` | `1` (Bauzeit-Einbettung ueber den echten CI-Weg) |
| `docker image inspect Created` web:beta / web:latest | beide `2026-09-14T15:47:54.520283239+02:00` (ein Bau, zwei Etiketten, nach dem Push) |
| `docker image inspect Created` api:beta / api:latest | beide `2026-09-14T15:48:40.191081238+02:00` |
| Registry (Gitea-API `/packages/schalli?type=container`) | `tessera-ctl/web` `beta` 15:47:59, `latest` 15:48:00; `tessera-ctl/api` `beta` und `latest` 15:49:34 |
Kein Fehlversuch, ein einziger Push, ein einziger Lauf.
## Abschluss-Verifikation (nach Task 3)
- API `Test Files 65 passed (65)` / `Tests 1060 passed (1060)`, Exit 0
- Web `Test Files 40 passed (40)` / `Tests 243 passed (243)`, Exit 0
- `tsc --noEmit`: `TSC_packages/shared=0`, `TSC_apps/api=0`, `TSC_apps/web=0`
- `git fetch -q && git status -sb | head -1` -> `## main...origin/main`
- `git ls-remote --heads origin live | wc -l` -> 0, `git tag | wc -l` -> 0 (Zweig `live` und Tag `v1.0.0` wie geplant NICHT angelegt)
`git status --porcelain` (vor dem Schreiben dieses SUMMARY): leer.
`git log --oneline 1cd4212..HEAD`:
```
ea6aa99 docs(quick-260914-ku1): Betriebshandbuch — Zwei Kanäle Live und Beta, Freigabe, Hotfix ohne Datenbankänderung, neuer Live-Server; ci-cd-setup auf gemessenen Stand
9731501 ci(quick-260914-ku1): zwei Kanaele — main -> beta+latest, Tag v* -> live+vX.Y.Z, Versionsstempel als Build-Args in beide Dockerfiles, IMAGE_TAG in docker-compose.prod.yml
cdb571c feat(quick-260914-ku1): Versionsstempel — GET /health/version aus APP_*, VersionResponse, app-version.ts und Abzeichen v<Version> · <Kanal> in der Seitenleiste
```
`commits: 3` gemessen aus `git rev-list --count 1cd4212..HEAD`; `actuals.tokens` = 166183 Zeichen ueber die 20 geaenderten Dateien / 4 = 41545 (der reine Diff waere 49515 Zeichen = 12378).
## Deviations from Plan
### Auto-fixed Issues
None — plan executed exactly as written. Alle Zahlen des Plans (65/1060, 40/243, 20 Dateien, 13/5/2 Dateien je Commit, Grep-Werte) wurden exakt getroffen; keine Erwartung musste angepasst werden.
### Prozess-Abweichung (dokumentiert, keine Code-Abweichung)
**Commits auf `main`.** Die Executor-Vorschrift verlangt eigentlich einen Nicht-Standard-Zweig. Dieses Projekt arbeitet per `branching_strategy: none` seit jeher direkt auf `main`, der Auftrag verlangt ausdruecklich `git push` auf `main`, und das Kanalmodell dieses Plans definiert `main` = Beta — ein Seitenzweig haette den Plan nicht erfuellen koennen (die Pipeline haette nichts gebaut). Kein `git update-ref`, kein Force-Push, kein Eingriff in `.planning/config.json`.
## Nebenbefunde
- **`apps/web/src/components/layout/sidebar-footer.tsx` ist seit `ba02b25` (2026-06-26) toter Code**: wird nirgends gerendert, einziger Treffer ausserhalb der Datei ist der Mock in `sidebar.test.tsx`. Unangetastet gelassen (nicht in der Erlaubnisliste); Kandidat fuer einen Aufraeum-Quick-Task.
- **`.env.prod.example` bewusst nicht angefasst** (Regel: keine `.env*`-Dateien). Die Zeile `IMAGE_TAG` steht nur im Handbuch (Kapitel 3 Tabelle, Kapitel 9 mit ausdruecklichem Hinweis, dass die Vorlage sie noch nicht enthaelt).
- Der Web-Bau laeuft in der CI jetzt bei jedem Lauf durch die builder-Stufe (NEXT_PUBLIC_APP_COMMIT aendert sich je Commit) — gemessen 5 min 18 s statt unter 2 min zuvor; im ci-cd-setup vermerkt.
- Handbuch Kapitel 6/7 nennen weiterhin `/opt/tessera/docker-compose.yml` fuer die Volume-Reparatur; Kapitel 9 nennt die tatsaechlich benutzte Datei `/opt/tessera/docker-compose.prod.yml` (`.env` setzt `COMPOSE_FILE`). Die aelteren Stellen wurden nicht umgeschrieben (nicht Teil des Plans).
## Threat Flags
Keine neue Angriffsflaeche ausserhalb des Threat-Registers des Plans: `GET /health/version` war bereits @Public (T-KU1-03, akzeptiert und per Spec-Test 6 gepinnt); das Skript kennt kein Secret; das Push-Token wurde in Task 3 nur in einer Shell-Variablen benutzt (Ausgabe der Push-URL im Log mit `***` maskiert).
## Known Stubs
Keine. Alle neuen Werte sind an echte Quellen gebunden (Umgebung, `/health/version`); ohne Build-Args greifen die bewussten Vorgaben `dev`.
## Was bewusst offen bleibt
- **Zweig `live` und Tag `v1.0.0` sind NICHT angelegt** — Rezept steht im Handbuch Kapitel 9 („Erstfreigabe v1.0.0"); erfolgt nach dem Fehler-melden-Knopf (eigener Quick-Task, der `apps/web/src/lib/app-version.ts` importiert).
- **Server nicht angefasst**: alpha (`/opt/tessera/.env` und `docker-compose.prod.yml`) und der neue Live-Server werden vom User eingerichtet, siehe „Handgriffe" unten. Bis dahin zieht alpha weiter `:latest` = dasselbe Beta-Abbild.
- `.env.prod.example` ohne `IMAGE_TAG`-Zeile (Regel `.env*`); ein spaeterer Quick-Task darf sie ergaenzen.
- `latest` bleibt als Alias von `beta`, bis alpha auf `IMAGE_TAG=beta` umgestellt ist; danach kann das Etikett aus dem Skript entfallen.
- Tag-Schutz `v*` und Branch-Schutz `live` in Gitea (T-KU1-04) — heute nicht noetig (nur `schalli` hat Schreibrecht), im ci-cd-setup als Empfehlung fuer den Fall weiterer Konten.
- Human-Check (end-of-phase, nicht blockierend): lokal `docker compose up -d --build web api` und unten in der Seitenleiste `dev · Entwicklung` sehen, Tooltip `API dev (dev)`; eingeklappt verschwindet die Zeile.
## Handgriffe fuer den User
Aus dem Handbuch Kapitel 9 („Die eine Zeile je Server" und „Den neuen Live-Server einrichten"):
**Auf alpha (Beta), einmalig:**
1. In `/opt/tessera/.env` die Zeile `IMAGE_TAG=beta` eintragen.
2. Sicherung der Compose-Datei:
```bash
cd /opt/tessera
cp docker-compose.prod.yml docker-compose.prod.yml.bak.$(date +%Y%m%d)
```
3. In `/opt/tessera/docker-compose.prod.yml` die zwei `image:`-Zeilen aendern (nur das Ende der Zeile):
```yaml
image: git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}
image: git.vicolab.de/schalli/tessera-ctl/api:${IMAGE_TAG:-beta}
```
4. Dann wie in Kapitel 4:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --force-recreate api web
```
5. Kontrolle: unten in der Seitenleiste steht `<Kurzkennung> · Beta`; `curl -s http://localhost:3001/health/version` zeigt `"channel":"beta"`.
**Auf dem neuen Live-Server (tessera.ctl.de):** Kapitel 2 des Handbuchs vollstaendig, mit diesen Abweichungen:
- `IMAGE_TAG=live` in der `.env` (Pflicht — ohne die Zeile zieht der Server die Beta).
- Eigene, neu erzeugte Geheimnisse (`JWT_SECRET`, `TESSERA_ENCRYPTION_KEY`, `DB_PASSWORD`, Admin-Passwort); nichts von alpha uebernehmen.
- Eigene, leere Datenbank; die alpha-Datenbank wird NICHT kopiert (falls doch gewuenscht: Kapitel 6 UND derselbe `TESSERA_ENCRYPTION_KEY`).
- `APP_URL=https://tessera.ctl.de`.
- Der erste `pull` holt `:live` — dieses Etikett gibt es erst nach der Erstfreigabe v1.0.0. Also erst freigeben (Claude: `git checkout -b live main`, Tag `v1.0.0`, Push), dann installieren; oder fuer einen Probelauf voruebergehend `IMAGE_TAG=beta`, danach auf `live` umstellen und `pull` + `up -d --force-recreate api web` wiederholen.
**Bei jeder Freigabe danach** (User sagt „Version X freigeben", Claude pusht Zweig und Tag), auf dem Live-Server:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --force-recreate api web
```
## Self-Check: PASSED
- Dateien vorhanden: alle 7 neu erstellten Dateien (`app-version.ts` api/web, beide Specs/Tests, Badge + Test, `publish-images.sh`) — FOUND.
- Commits vorhanden: `cdb571c`, `9731501`, `ea6aa99` — FOUND (`git log --oneline 1cd4212..HEAD`), alle auf `origin/main`.
@@ -0,0 +1,198 @@
---
phase: quick-260914-ku1
verified: 2026-09-14T13:59:19Z
status: passed
score: 8/8 must-haves verified
covered_files:
- ".gitea/scripts/publish-images.sh"
- ".gitea/workflows/ci.yml"
- ".planning/quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/260914-ku1-PLAN.md"
- ".planning/quick/260914-ku1-zwei-auslieferungskanaele-beta-auf-main-/260914-ku1-SUMMARY.md"
- "apps/api/Dockerfile"
- "apps/api/src/health/app-version.ts"
- "apps/api/src/health/health.controller.spec.ts"
- "apps/api/src/health/health.controller.ts"
- "apps/api/src/main.ts"
- "apps/web/Dockerfile"
- "apps/web/src/components/layout/app-version-badge.test.tsx"
- "apps/web/src/components/layout/app-version-badge.tsx"
- "apps/web/src/components/layout/sidebar.test.tsx"
- "apps/web/src/components/layout/sidebar.tsx"
- "apps/web/src/lib/app-version.test.ts"
- "apps/web/src/lib/app-version.ts"
- "apps/web/src/messages/de.json"
- "apps/web/src/messages/en.json"
- "docker-compose.prod.yml"
- "docs/anleitung-betrieb.md"
- "docs/ci-cd-setup.md"
- "packages/shared/src/index.ts"
covered_digest: "v1:sha256:3cc012949ab9484c9c9821c7dea15392439adc9224bd2f9e3025991c0eba6062"
behavior_unverified: 0
overrides_applied: 0
---
# Quick-Task 260914-ku1: Zwei Auslieferungskanaele (Beta/Live) — Verifikationsbericht
**Ziel:** Zwei Auslieferungskanaele (main -> beta+latest; Tag v* -> live+vX.Y.Z; Zweig live ohne Tag nur geprueft), Versionsstempel per Build-Args in beide Container-Abbilder, `GET /health/version`, Versionsabzeichen in der Seitenleiste, `IMAGE_TAG` in docker-compose.prod.yml, Publish-Skript mit `--print-plan`, Betriebshandbuch Kapitel „Zwei Kanaele" inkl. Hotfix-Rezept und Live-Server-Einrichtung, ci-cd-setup.md aktualisiert, gepusht, echter CI-Lauf gruen.
**Verifiziert:** 2026-09-14T13:59:19Z
**Status:** passed
**Re-Verifikation:** Nein — Erstverifikation
Alle Pruefungen wurden selbst erneut ausgefuehrt (nicht aus dem SUMMARY uebernommen). Wo die eigene Messung von der SUMMARY-Behauptung abweicht, ist das unten vermerkt — es gab keine Abweichung.
## Pruefung 1 — Commits, Diff-Umfang, unangetastete Dateien
| Befehl | Erwartet | Gemessen |
|---|---|---|
| `git log --oneline 1cd4212..HEAD` | 3 Commits | `ea6aa99`, `9731501`, `cdb571c` — 3 Commits |
| `git diff --stat 6c19451 -- . ':!.planning'` (letzte Zeile) | `20 files changed` | `20 files changed, 851 insertions(+), 49 deletions(-)` |
| `git diff --name-only 6c19451 -- '.env*' apps/api/prisma/schema.prisma apps/api/prisma/migrations package.json pnpm-lock.yaml biome.json apps/web/src/components/layout/sidebar-footer.tsx` | leer | leer (keine Ausgabe) |
| `git status --porcelain` (vor jeder Aenderung durch die Verifikation) | nur die zwei erwarteten offenen Dateien | `M .planning/STATE.md`, `?? .../260914-ku1-SUMMARY.md` — beides die dem Orchestrator gehoerenden, unberuehrt gelassenen Dateien |
| `git fetch -q && git status -sb \| head -1` | kein `[ahead` | `## main...origin/main` |
Status: ✓ VERIFIED
## Pruefung 2 — Testsuiten und Typpruefung
| Befehl | Erwartet | Gemessen |
|---|---|---|
| `cd apps/api && npx vitest run` | 65 Dateien / 1060 Tests | `Test Files 65 passed (65)` / `Tests 1060 passed (1060)` |
| `cd apps/web && npx vitest run` | 40 Dateien / 243 Tests | `Test Files 40 passed (40)` / `Tests 243 passed (243)` |
| `pnpm -C packages/shared exec tsc --noEmit` | Exit 0 | Exit 0 |
| `pnpm -C apps/api exec tsc --noEmit` | Exit 0 | Exit 0 |
| `pnpm -C apps/web exec tsc --noEmit` | Exit 0 | Exit 0 |
Status: ✓ VERIFIED
## Pruefung 3 — Versionsstempel im Code (API und Web)
Quelldateien gelesen (nicht nur gegrept):
- `apps/api/src/health/health.controller.ts`: `getVersion()` delegiert an `getAppVersion()` aus `./app-version`, `@Public()` und `@Get('version')` gesetzt, kein Zugriff mehr auf `npm_package_version`. Kommentar begruendet T-KU1-03.
- `apps/api/src/health/app-version.ts`: `getAppVersion()` liest `process.env.APP_VERSION/APP_CHANNEL/APP_COMMIT/APP_BUILD_TIME` mit `||`-Vorgaben `dev`/`dev`/``/``, `name: 'tessera'`. `formatAppVersionLine()` baut `Tessera API <version> (<channel>) <commit>`, ohne Commit ohne Leerzeichen am Ende.
- `apps/api/src/health/health.controller.spec.ts`: 6 Tests wie im Plan beschrieben (`check()`, Vorgaben, Durchreichen, Compose-Leerstring-Semantik, `formatAppVersionLine`, `@Public()`-Metadatenpruefung per `Reflect.getMetadata`). Alle 6 gruen (siehe Pruefung 2).
- `apps/api/src/main.ts`: `console.log(formatAppVersionLine())` direkt nach der bestehenden Port-Zeile (Zeile 35, nach Zeile 34).
- `apps/web/src/lib/app-version.ts`: `appVersion` liest `process.env.NEXT_PUBLIC_APP_VERSION/_CHANNEL/_COMMIT` je mit vollem Literalnamen (keine Destrukturierung, kein `process.env[name]`); `normalizeChannel` faellt bei unbekanntem Kanal auf `'dev'` zurueck; `loadApiVersion()` memoisiert ein Modul-Promise gegen `${API_URL}/health/version` mit `credentials: 'include'`, still bei Fehler (`.catch(() => null)`).
- `apps/web/src/components/layout/app-version-badge.tsx`: `data-testid="app-version"`, Text `${appVersion.version} · ${t('channel.'+channel)}`, `title` aus `Commit <commit>` und `API <version> (<channel>)`, `undefined` wenn beides fehlt.
- `apps/web/src/components/layout/sidebar.tsx`: Badge-Block liegt NACH dem Einklapp-Block (der `hidden md:block` traegt) und OHNE dieses Attribut — dadurch auch in der mobilen Schublade sichtbar; `!isCollapsed` blendet ihn eingeklappt aus (Zeilen ~201-206).
- `apps/web/src/messages/de.json` / `en.json`: `sidebar.channel = { beta: "Beta", live: "Live", dev: "Entwicklung"/"Development" }` — in beiden Dateien vorhanden, per Node geprueft.
- `sidebar.test.tsx`: Modul-Mock fuer `AppVersionBadge` und ein sechster Test `renders the version badge below the navigation`, der `screen.getByTestId('app-version-badge')` prueft.
Status: ✓ VERIFIED
## Pruefung 4 — Dockerfile-Falsifizierung (unabhaengig wiederholt)
Beide Dockerfiles gelesen: globales `ARG APP_VERSION=dev` (und die drei weiteren) vor dem ersten `FROM`; im Web-`builder` `ARG`-Wiederholung + `ENV NEXT_PUBLIC_APP_*` unmittelbar VOR `RUN pnpm --filter=@tessera/web build`; im `runner` beider Images `ARG`-Wiederholung + `ENV APP_VERSION=...` nach `ENV NODE_ENV=production`.
Eigener Bau (nicht aus dem SUMMARY uebernommen):
```
docker build -f apps/api/Dockerfile --build-arg APP_VERSION=v7.7.7-verify --build-arg APP_CHANNEL=live -t tessera-api-verify:tmp .
docker run --rm --entrypoint node tessera-api-verify:tmp -e 'console.log(process.env.APP_VERSION, process.env.APP_CHANNEL)'
-> v7.7.7-verify live
```
Erwartung erfuellt. Danach `docker rmi tessera-api-verify:tmp` ausgefuehrt — kein Rueckstand (`docker images | grep -c tessera-api-verify` -> 0).
Status: ✓ VERIFIED
## Pruefung 5 — CI-Workflow-Struktur und Publish-Skript
`.gitea/workflows/ci.yml` per js-yaml geparst:
```
branches=["main","live"] tags=["v*"] jobs=quality,test,publish fetchDepth=0 script=true needs=test
```
`.gitea/scripts/publish-images.sh --print-plan` in drei Lagen (eigene Ausfuehrung):
| GITHUB_REF | Ergebnis |
|---|---|
| `refs/heads/main` | Kanal `beta`, Push-Zeilen fuer `web:beta`, `web:latest`, `api:beta`, `api:latest` (main-Fall enthaelt BEIDE, `beta` und `latest`) |
| `refs/heads/live` | „Kein Veroeffentlichungs-Anlass ... nichts zu tun." — keine `push`-Zeile |
| `refs/tags/v1.0.0` | Kanal `live`, Push-Zeilen fuer `web:live`, `web:v1.0.0`, `api:live`, `api:v1.0.0` |
Status: ✓ VERIFIED
## Pruefung 6 — CI-gebaute Abbilder und echter Gitea-Lauf
`docker images | grep tessera-ctl` zeigt `localhost:3002/schalli/tessera-ctl/{api,web}:{beta,latest}` (zusaetzlich `git.vicolab.de/...` als Fetch-Alias und lokale `tessera-ctl-{api,web}:latest` aus fruehreren lokalen Bauten — nicht Teil dieser Pruefung).
```
docker run --rm --entrypoint node localhost:3002/schalli/tessera-ctl/api:beta -e 'console.log(process.env.APP_VERSION, process.env.APP_CHANNEL, process.env.APP_COMMIT)'
-> ea6aa99 beta ea6aa99
docker run --rm --entrypoint sh localhost:3002/schalli/tessera-ctl/web:beta -c 'grep -rl "ea6aa99" apps/web/.next/static | wc -l'
-> 1
```
`ea6aa99` ist der zuletzt gepushte Commit (siehe Pruefung 1) — Uebereinstimmung.
Gitea-API (`GET /api/v1/repos/schalli/tessera-ctl/actions/runs?limit=5`, Token aus `git remote get-url --push origin`, nicht ausgegeben):
```
297 ea6aa995b27de1dfa9b557c06df9fa3bbcdd8ea3 completed success push
296 6c19451be9a25feb227af075076a839a968c5366 completed success push
...
```
Lauf 297 entspricht `head_sha = ea6aa99...`, `status: completed`, `conclusion: success` — deckt sich mit der SUMMARY-Angabe.
Status: ✓ VERIFIED
## Pruefung 7 — docker-compose.prod.yml / IMAGE_TAG
```
docker compose -f docker-compose.prod.yml config --images
-> git.vicolab.de/schalli/tessera-ctl/web:beta, .../api:beta, postgres:16-alpine
IMAGE_TAG=live docker compose -f docker-compose.prod.yml config --images
-> .../api:live, postgres:16-alpine, .../web:live
```
Ohne Variable Vorgabe `beta`, mit `IMAGE_TAG=live` `live` fuer beide Images. Kein `:latest` mehr in der Datei (per grep unabhaengig bestaetigt: 0 Treffer).
Status: ✓ VERIFIED
## Pruefung 8 — Betriebshandbuch und ci-cd-setup.md
`docs/anleitung-betrieb.md`, Abschnitt `## 9. Zwei Kanäle: Live und Beta` (Zeile 355) gelesen (nicht nur gegreppt): erklaert den Kanalbegriff, die eine `.env`-Zeile je Server (`IMAGE_TAG=beta`/`IMAGE_TAG=live`), die Server-Compose-Anpassung, `pull` + `up -d --force-recreate`, das Freigabe-Rezept, den vollstaendigen Hotfix-Ablauf mit der fett gesetzten Regel „Keine Datenbankänderung als Hotfix" samt Alltagssprache-Begruendung (Migrations-Zeitstempel-Reihenfolge), die drei Erkennungswege (Oberflaeche/`curl`/Log) und die Einrichtung des neuen Live-Servers (eigene Secrets, eigene leere Datenbank, `APP_URL`, Reihenfolge Erstfreigabe vor erstem Pull). Echte Umlaute durchgaengig (`Zwei Kanäle`, `änderungen`, `möglicherweise`, `größeren` etc.) — Ton der Datei gehalten, keine ASCII-Umschrift in diesem Kapitel.
`docs/ci-cd-setup.md`: Abschnitt „Gitea Secrets" beschreibt `REGISTRY_TOKEN` und den Login (kein „keine Secrets" mehr); Abschnitt „Pipeline-Ueberblick" beschreibt Trigger (main/live/Tags v*), Etiketten-Tabelle, Build-Args-Tabelle, laengere Web-Bauzeit und markiert D-13 als ueberholt. `grep -c "Kein Registry-Push\|build-deploy"` -> 0.
| Grep | Erwartet | Gemessen |
|---|---|---|
| `^## 9\. Zwei Kanäle: Live und Beta` in anleitung-betrieb.md | 1 | 1 |
| `IMAGE_TAG` in anleitung-betrieb.md | >= 6 | 11 |
| `Keine Datenbankänderung als Hotfix` | 1 | 1 |
| `Kein Registry-Push\|build-deploy` in ci-cd-setup.md | 0 | 0 |
| `[äöüÄÖÜß]` in ci-cd-setup.md (ASCII-Konvention) | 0 | 0 |
Status: ✓ VERIFIED
## Anti-Pattern-Scan
Alle 13 durch die Fingerprint-Liste erfassten Code-/Config-Dateien auf `TBD`, `FIXME`, `XXX`, `TODO`, `HACK`, `PLACEHOLDER`, „not yet implemented" u. ae. geprueft — keine Treffer in einer der geaenderten Dateien.
## Requirements-Abdeckung
`QUICK-260914-KU1` ist eine Quick-Task-Anforderung ohne eigenen Eintrag in `.planning/REQUIREMENTS.md` (projektueblich fuer `/gsd-quick`-Auftraege, kein Roadmap-Phasenbezug) — kein verwaistes Requirement, da REQUIREMENTS.md keinen Phasen-Bezug fuer diesen Quick-Task erwartet.
## Beobachtete Nebenpunkte (keine Gaps)
- Zusaetzliche lokale Docker-Images (`git.vicolab.de/...:latest`, `tessera-ctl-api:latest`, `tessera-ctl-web:latest`) liegen auf dem Host aus fruehreren Bauten/Pulls — nicht Teil dieses Plans und nicht durch ihn verursacht.
- `sidebar-footer.tsx` bleibt wie geplant unangetastet (toter Code, im SUMMARY als Nebenbefund vermerkt).
- `.env.prod.example` bewusst ohne `IMAGE_TAG`-Zeile (Regel: keine `.env*`-Aenderungen) — im Handbuch als offener Punkt vermerkt.
## Angenommene Risiken
- **T-KU1-03** (`GET /health/version` bleibt `@Public()`, gibt Version/Kanal/Commit/Bauzeit ohne Anmeldung preis): als "accept" im Threat-Register des Plans geflaggt, durch Spec-Test 6 gepinnt. Bei externem Verkauf von Tessera muesste das revidiert werden (im Plan bereits als Folgeaenderung genannt). Die Verifikation bestaetigt nur, dass die Entscheidung wie dokumentiert umgesetzt und getestet ist — keine eigene Bewertung des Risikos selbst.
- **T-KU1-04** (Tag-Push `v*` als alleiniger Freigabe-Hebel, kein Tag-/Branch-Schutz in Gitea): als "accept" geflaggt, weil aktuell nur ein Konto Schreibrecht hat. Diese Verifikation hat KEINE Gitea-Repository-Einstellungen aendern koennen/muessen (ausserhalb der Erlaubnisliste) und bestaetigt nur, dass die Empfehlung im Handbuch/ci-cd-setup steht.
- **`live`-Zweig und Tag `v1.0.0` sind bewusst nicht angelegt** — laut Plan Folgearbeit nach dem noch ausstehenden „Fehler-melden-Knopf"-Quick-Task. Das bedeutet: der Live-Kanal ist bislang nur durch die drei `--print-plan`-Simulationen und den lokalen Docker-Falsifizierungslauf bewiesen, NICHT durch einen echten CI-Lauf mit `refs/tags/v*` (der echte CI-Lauf in Pruefung 6 deckt nur den Beta-Pfad ab). Das ist im Rahmen des Plans ausdruecklich so vorgesehen (Output-Abschnitt: "Zweig live und Tag v1.0.0 werden NICHT in diesem Plan angelegt") und daher kein Gap, aber ein offener Punkt fuer die naechste Freigabe.
- Zusaetzliche, vom Runner gebaute lokale Images auf dem Host (`git.vicolab.de/...:latest` etc.) wurden nicht aufgeraeumt — sie stammen nicht aus dieser Verifikation und wurden nicht entfernt, um den Host-Zustand nicht ueber das Mandat hinaus zu veraendern.
---
_Verifiziert: 2026-09-14T13:59:19Z_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,388 @@
---
phase: quick-260914-m97
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260914-M97]
files_modified:
- 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
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 3
confidence: low
must_haves:
truths:
- "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."
artifacts:
- "apps/api/prisma/schema.prisma — `bugReportRecipient String?` in `model SmtpConfig` hinter `fromAddress` (Kommentar: Postfach fuer den Fehler-melden-Knopf, quick-260914-m97)"
- "apps/api/prisma/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"
key_links:
- "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."
---
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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>
</context>
<tasks>
<task type="auto" tdd="true">
<name>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)</name>
<files>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</files>
<precondition>`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).</precondition>
<behavior>
`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'`.
</behavior>
<action>
Schritt A — RED: die drei neuen/erweiterten Spec-Dateien aus `<behavior>` 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).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run src/bug-reports src/mail/mail.service.spec.ts src/settings/settings.service.spec.ts src/prisma/rls-access-inventory.spec.ts 2>&1 | grep -E "^\s+(Test Files|Tests)" ; DBIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1); (cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate status 2>&1 | tail -1 ; DATABASE_URL="postgresql://tessera:tessera_dev@${DBIP}:5432/tessera" ./node_modules/.bin/prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script 2>/dev/null | head -1) ; ls apps/api/prisma/migrations | grep -c "" ; grep -c "bugReportRecipient String?" apps/api/prisma/schema.prisma ; grep -c "ADD COLUMN \"bugReportRecipient\" TEXT" apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql ; grep -c "FileInterceptor('screenshot'" apps/api/src/bug-reports/bug-reports.controller.ts ; grep -c "fileSize: 4 \* 1024 \* 1024" apps/api/src/bug-reports/bug-reports.controller.ts ; grep -v '^\s*//' apps/api/src/bug-reports/bug-reports.controller.ts | grep -v '^\s*\*' | grep -c "@Roles" ; grep -v '^\s*//' apps/api/src/bug-reports/dto/bug-report.dto.ts | grep -v '^\s*\*' | grep -c "tenantId" ; grep -c "const tenantPrisma = forTenant(" apps/api/src/bug-reports/bug-reports.service.ts ; grep -c "sendBugReport" apps/api/src/mail/mail.service.ts ; grep -c "BugReportsModule" apps/api/src/app.module.ts ; grep -c "bug-reports.service.ts | user | muss-mandantengebunden | gebunden" docs/mandantentrennung-zugriffsklassifikation.md ; grep -c 'TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}' docker-compose.prod.yml ; M=$(git diff --stat 5c42c55 -- apps/api/src/main.ts '.env*' apps/api/prisma/migrations/2026061* apps/api/prisma/migrations/2026062* apps/api/prisma/migrations/2026080* apps/api/prisma/migrations/2026081* apps/api/prisma/migrations/2026090* apps/api/prisma/migrations/2026091[0-4]12*); echo M_EXIT=$? ; test -z "$M"; echo M_EMPTY=$? ; pnpm -C apps/api exec tsc --noEmit; echo TSC_api=$?</automated>
</verify>
<done>
Vitest-Zeilen `Test Files 5 passed (5)` und `Tests <Summe: bisherige Tests der vier Dateien + 16 neue>` — die volle Suite danach `67 passed (67)` / `1076 passed (1076)`; `migrate status` letzte Zeile `Database schema is up to date!`, `migrate diff` erste Zeile `-- This is an empty migration.`; Migrationsordner-Zaehlung `38` (36 + `migration_lock.toml` + 1 neu); Greps liefern `1` (Schema), `1` (Migration), `1` (FileInterceptor), `1` (4-MiB-Limit je Route, revidiert Runde 1), `0` (kein Rollen-Dekorator im Controller), `0` (kein Mandantenfeld im DTO), `1` (Zuweisungsform), mindestens `2` (sendBugReport in mail.service.ts), `2` (Import + Eintrag), `1` (Doku-Zeile), `1` (Compose); `M_EXIT=0` und `M_EMPTY=0` (main.ts, .env*, bestehende Migrationen unangetastet); `TSC_api=0`. Der RED-Lauf aus Schritt A und die drei Prisma-Ausgaben aus Schritt B stehen im SUMMARY. Commit existiert mit genau 16 Dateien.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Web — html-to-image, Fehlerpuffer, Knopf in der Kopfzeile, Dialog mit Vorschau, Feld „Fehlermeldungen an" im SMTP-Formular, i18n de/en, Komponententests (zwei Commit-Teile 2a/2b)</name>
<files>apps/web/package.json, pnpm-lock.yaml, apps/web/src/lib/error-buffer.ts, apps/web/src/lib/error-buffer.test.ts, apps/web/src/lib/bug-report-api.ts, apps/web/src/components/bug-report/bug-report-button.tsx, apps/web/src/components/bug-report/bug-report-dialog.tsx, apps/web/src/components/bug-report/bug-report-button.test.tsx, apps/web/src/components/layout/header.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/lib/settings-api.ts, apps/web/src/components/settings/smtp-settings-form.tsx, apps/web/src/components/settings/smtp-settings-form.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<behavior>
`apps/web/src/lib/error-buffer.test.ts` (NEU, 4 Tests; jsdom; `afterEach`: `vi.restoreAllMocks()`, `vi.unstubAllGlobals()`, Puffer per exportiertem `clearErrorBuffer()` leeren, Installations-Guard per exportiertem `uninstallErrorBuffer()` zuruecksetzen):
- Test 1 (Ringpuffer): 25-mal `recordError('error', 'm<i>')` -> `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 `[<ISO>] 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.<key>`, nie hartkodierte Saetze (revidiert Runde 1); `vi.mock('@/lib/stores/auth-store', () => ({ useAuthStore: (sel?: any) => (sel ? sel({ user: mockUser }) : { user: mockUser }) }))`; `vi.mock('@/lib/app-version', () => ({ appVersion: { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' } }))`; `vi.stubGlobal('fetch', mockFetch)`; `@testing-library/user-event` fuer Klicks; Komponente per dynamischem Import nach den Mocks; `cleanup` im `afterEach`):
- Test 1 (Bild VOR dem Dialog): `mockToPng` prueft in seiner Implementierung `expect(screen.queryByRole('dialog')).toBeNull()` und liefert `'data:image/png;base64,iVBORw0KGgo='`; Klick auf `getByRole('button', { name: 'Fehler melden' })` -> `mockToPng` genau einmal mit `document.body` als erstem Argument und Optionen `pixelRatio: 1`, `skipFonts: true`, und — nachdem vor dem Klick `Object.defineProperty(document.body, 'scrollWidth', { value: 3200, configurable: true })` und `scrollHeight` mit `1000` gestubbt wurden — `canvasWidth: 1600`, `canvasHeight: 500` (revidiert Runde 1: die 1600-px-Kante ist damit in der CI regressionsgetestet); danach `findByRole('dialog')` sichtbar, `<img>` mit `src` = Data-URL, Checkbox `checked`, Textfeld leer.
- Test 2 (Senden mit Bild): Beschreibung `Knopf tut nichts` tippen, `recordError('fetch', 'GET /modules -> 500')` vorher, `mockFetch` -> `new Response('{"sent":true}', { status: 200 })`; Klick „Senden" -> `mockFetch` einmal; URL endet auf `/bug-reports`; `init.method === 'POST'`, `init.credentials === 'include'`, `init.body instanceof FormData`, `body.get('description') === 'Knopf tut nichts'`, `body.get('webVersion') === 'v1.2.3'`, `body.get('webChannel') === 'beta'`, `body.get('page')` beginnt mit `/`, `body.getAll('errors')` enthaelt einen Eintrag mit `GET /modules -> 500`, `body.get('screenshot')` ist eine `Blob` mit `type === 'image/png'` und `size > 0`; kein `Content-Type`-Header in `init.headers`; danach Text `Vielen Dank, die Meldung wurde gesendet.` und ein Knopf „Schließen".
- Test 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`.
</behavior>
<action>
Schritt A — Abhaengigkeit: in `apps/web/package.json` unter `dependencies` alphabetisch `"html-to-image": "1.11.13"` (exakt, ohne Caret — Bildaufnahme ist empfindlich gegen Verhaltensaenderungen); dann `pnpm install` (Lockfile aendert sich, erwartet), danach `pnpm install --frozen-lockfile` Exit 0 (Gate). `ls apps/web/node_modules/html-to-image/lib/index.d.ts` vorhanden. Kein anderes Paket anfassen; `git diff --stat 5c42c55 -- apps/api/package.json packages/shared/package.json package.json` bleibt leer.
Schritt 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).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm install --frozen-lockfile >/dev/null 2>&1; echo FROZEN=$? ; node -e "const p=require('./apps/web/package.json');console.log('h2i='+p.dependencies['html-to-image'])" ; pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report src/components/settings/smtp-settings-form.test.tsx src/messages 2>&1 | grep -E "^\s+(Test Files|Tests)" ; grep -c "<BugReportButton />" apps/web/src/components/layout/header.tsx ; awk '/<BugReportButton \/>/{b=NR} /<ThemeToggle \/>/{t=NR} END{print (b>0 && t>b) ? "ORDER=ok" : "ORDER=falsch"}' apps/web/src/components/layout/header.tsx ; grep -c "installErrorBuffer()" apps/web/src/components/layout/app-shell.tsx ; grep -c "skipFonts: true" apps/web/src/lib/bug-report-api.ts ; grep -c "export function computeCaptureSize" apps/web/src/lib/bug-report-api.ts ; grep -c "smtp-bug-report-recipient" apps/web/src/components/settings/smtp-settings-form.tsx ; node -e "const d=require('./apps/web/src/messages/de.json'),e=require('./apps/web/src/messages/en.json');console.log(d.bugReport.button,'|',d.bugReport.descriptionLabel,'|',d.settings.smtp.bugReportRecipient,'|',e.bugReport.button,'|',Object.keys(d.bugReport).length===Object.keys(e.bugReport).length)" ; U=$(git diff --stat 5c42c55 -- apps/api/package.json packages/shared/package.json package.json); test -z "$U"; echo U_EMPTY=$? ; pnpm -C apps/web exec tsc --noEmit; echo TSC_web=$?</automated>
</verify>
<done>
`FROZEN=0`; `h2i=1.11.13`; Vitest-Zeilen `Test Files 5 passed (5)` (error-buffer, bug-report-button, smtp-settings-form, umlaut-guard, tenderRadar-parity) und `Tests <bisherige Tests der beiden Waechter + 17 neue>` (4 + 11 + 2, revidiert Runde 1) — die volle Suite danach `43 passed (43)` / `260 passed (260)`; Greps `1`, `ORDER=ok`, `1`, `1`, `1` (computeCaptureSize exportiert), mindestens `2`; die Node-Zeile lautet `Fehler melden | Was ist passiert? | Fehlermeldungen an | Report a problem | true`; `U_EMPTY=0`; `TSC_web=0`. RED-Lauf aus Schritt B im SUMMARY; das SUMMARY nennt die vom Umlaut-Waechter geforderten Allowlist-Woerter und die Lockfile-Aenderung (Docker-deps-Stufe beider Abbilder wird beim naechsten CI-Bau neu laufen — erwartet). Zwei Commits existieren: 2a mit genau 13 Dateien, 2b mit genau 5 Dateien (revidiert Runde 1).
</done>
</task>
<task type="auto">
<name>Task 3: Handbuecher (Anwender, Administration, Betrieb), Abschluss-Gates, Push und Beobachtung des echten CI-Laufs</name>
<files>docs/anleitung-anwender.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md</files>
<precondition>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).</precondition>
<action>
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).
</action>
<verify>
<automated>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"</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<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>
<verification>
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.
</verification>
<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>
<output>
Create `.planning/quick/260914-m97-fehler-melden-knopf-bildschirmfoto-der-a/260914-m97-SUMMARY.md` when done
</output>
@@ -0,0 +1,366 @@
---
phase: quick-260914-m97
plan: 01
subsystem: api, ui, mail
tags: [bug-report, html-to-image, multipart, multer, nodemailer, prisma, smtp, next-intl, vitest]
requires:
- phase: quick-260914-ku1
provides: Versionsstempel appVersion (Web) und formatAppVersionLine (API), CI-Skript mit Build-Args, Kanaele beta/live
- phase: quick-260914-eym
provides: MailService mit Transport je Versand nach Mandant (resolveTransport)
provides:
- "POST /bug-reports (Multipart, 4 MiB je Route, alle angemeldeten Rollen, Drossel 5/10 min, PNG-Signatur, 409/413/429/502) mit E-Mail und PNG-Anhang ueber den Transport des Sitzungs-Mandanten"
- "SmtpConfig.bugReportRecipient (additive Migration 20260914170000), Feld Fehlermeldungen an unter Administrator -> SMTP, Rueckfall TESSERA_BUGREPORT_TO in docker-compose.prod.yml"
- "Fehler-melden-Knopf in der Kopfzeile: Bild VOR dem Dialog (html-to-image 1.11.13), Dialog mit Vorschau, Fehlerpuffer im Browser (error-buffer.ts)"
- "Handbuecher Anwender/Administration/Betrieb mit Ablauf, Datenschutz-Hinweis, Feld und Rueckfall"
affects: [erstfreigabe-v1.0.0, smtp, mail, handbuecher]
actuals:
tokens: 206676
tasks: 3
commits: 4
plan_head_before: 17a7e5ef9b77b9e6bd4cf5cb337e691b215bbabb
tech-stack:
added: [html-to-image 1.11.13 (apps/web, exakt gepinnt, 0 Abhaengigkeiten)]
patterns:
- "Multipart je Route mit FileInterceptor-Limit statt globalem JSON-Limit (main.ts unangetastet)"
- "MailService: Versandkern deliver wirft, Mantel sendViaTenantTransport verschluckt (T-02-12 nur fuer Kennwort-Reset/Willkommen)"
- "Multipart-Wiederholfelder im DTO per @Expose() + @Transform normalisieren (String/undefined/Array -> Array)"
- "Browser-Fehlerpuffer: fetch-Wrapper notiert nur !ok, nie Anfrage-Rumpf/Suchteil/Kopfzeilen (T-M97-02)"
- "Komponententest liest Texte aus der echten de.json (next-intl-Mock mit Punktpfad-Lookup)"
key-files:
created:
- apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql
- 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/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/settings/smtp-settings-form.test.tsx
modified:
- apps/api/prisma/schema.prisma
- 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/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- docker-compose.prod.yml
- apps/web/package.json
- pnpm-lock.yaml
- 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/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
- docs/anleitung-betrieb.md
key-decisions:
- "Multipart statt JSON+Base64: Limit nur auf POST /bug-reports (FileInterceptor 4 MiB), main.ts unangetastet, kein globales Body-Limit fuer /auth/login"
- "Keine GET /bug-reports/status-Route: 409 beim Senden reicht, kein Aufruf je Seitenladung"
- "Empfaenger lebt in SmtpConfig (Feld Fehlermeldungen an), Rueckfall TESSERA_BUGREPORT_TO nur ueber die Prod-Compose; Leerstring zaehlt als ungesetzt"
- "sendBugReport laesst Transportfehler durch (502), sendPasswordResetEmail verschluckt weiter (T-02-12) — Regressionstest im selben Spec"
- "Commits auf main (branching_strategy none, quick_branch_template null, wie 260914-ku1); CI-Lauf ueber Rerun-API wiederholt, weil die Ursache ein Gitea-Ausfall war, kein Code"
patterns-established:
- "Multipart-Wiederholfeld errors: @Expose() + @Transform im DTO, Pipe-Test mit metatype"
- "Bild-vor-Dialog per toPng-Mock gepinnt (queryByRole('dialog') ist null waehrend der Aufnahme)"
requirements-completed: [QUICK-260914-M97]
coverage:
- id: D1
description: "POST /bug-reports: Drossel, PNG-Signatur, Empfaenger-Aufloesung, Mandant aus der Sitzung, E-Mail mit PNG-Anhang, 502 bei Versandfehler"
requirement: QUICK-260914-M97
verification:
- kind: unit
ref: "apps/api/src/bug-reports/bug-reports.service.spec.ts#Test 1-8 (Falsifizierungen a-d)"
status: pass
- kind: unit
ref: "apps/api/src/bug-reports/bug-reports.controller.spec.ts#Test 1-3 (whitelist, Grenzen, kein @Roles)"
status: pass
- kind: unit
ref: "apps/api/src/mail/mail.service.spec.ts#Test 5-6 (Anhaenge, Fehler durch vs. verschluckt)"
status: pass
human_judgment: false
- id: D2
description: "SmtpConfig.bugReportRecipient mit Migration, DTO, SAFE_SELECT, getBugReportRecipient; Feld Fehlermeldungen an im SMTP-Formular"
requirement: QUICK-260914-M97
verification:
- kind: unit
ref: "apps/api/src/settings/settings.service.spec.ts#Test A-C"
status: pass
- kind: unit
ref: "apps/web/src/components/settings/smtp-settings-form.test.tsx#Test 1-2"
status: pass
- kind: other
ref: "prisma migrate deploy / migrate status / migrate diff gegen tessera-ctl-db-1"
status: pass
human_judgment: false
- id: D3
description: "Fehler-melden-Knopf: Bild VOR dem Dialog, Dialog mit Vorschau/Haekchen/Beschreibung, Multipart-Versand, Meldungen je Status, Fehlerpuffer ohne Kennwoerter"
requirement: QUICK-260914-M97
verification:
- kind: unit
ref: "apps/web/src/components/bug-report/bug-report-button.test.tsx#Test 1-11"
status: pass
- kind: unit
ref: "apps/web/src/lib/error-buffer.test.ts#Test 1-4"
status: pass
human_judgment: true
rationale: "html-to-image rastert nur im echten Browser (jsdom: HTMLVideoElement is not defined, kein Canvas); dass das Bild den Dialog NICHT zeigt und OKLCH-Farben stimmen, und dass die E-Mail mit PNG-Anhang in mailhog ankommt, muss der Browser-Check zeigen (siehe Fuer den Verifizierer)"
- id: D4
description: "Handbuecher Anwender/Administration/Betrieb"
requirement: QUICK-260914-M97
verification:
- kind: other
ref: "grep-Gates Task 3 (1 / 1 / 3 / 2 / 1)"
status: pass
human_judgment: true
rationale: "Alltagssprache und Verstaendlichkeit fuer Nicht-Programmierer kann nur ein Mensch beurteilen"
duration: "27 min (14:37Z bis 15:04Z, davon ca. 3,5 min Suiten-Laeufe und 2 x 4 min CI-Beobachtung)"
completed: "2026-09-14"
status: complete
---
# Quick 260914-m97 Plan 01: Fehler-melden-Knopf — Bildschirmfoto der aktuellen Seite per E-Mail mit PNG-Anhang — Summary
Ein Klick auf den Kaefer-Knopf rechts in der Kopfzeile nimmt zuerst ein Bild der Seite auf (html-to-image 1.11.13, laengste Kante 1600 px) und oeffnet erst danach den Dialog mit Vorschau, Haekchen und Feld „Was ist passiert?“; „Senden“ schickt Bild, Beschreibung, Seite, Web-/API-Version mit Kanal und Commit, Browser, Fenstergroesse, Zeitpunkt, angemeldeten Benutzer und die letzten 20 Browser-Fehler als Multipart an `POST /bug-reports`, das daraus eine E-Mail mit PNG-Anhang ueber den Transport des Sitzungs-Mandanten an `SmtpConfig.bugReportRecipient` (neues Feld „Fehlermeldungen an“ unter Administrator -> SMTP) oder den Rueckfall `TESSERA_BUGREPORT_TO` schickt. Drossel 5 je Benutzer je 10 Minuten (429), PNG-Signatur (400), kein Postfach (409), Versandfehler (502) — der Anwender erfaehrt immer, ob sein Bericht ankam. Vier Commits auf `main`, gepusht, CI-Lauf 299 nach einem Gitea-Datenbank-Ausfall im ersten Versuch per Rerun `success`; `:beta`-Abbilder tragen `77117de beta`.
## Ausgangslage und Bezugspunkt
Alle Gates gegen `5c42c55` (Code unangetastet seit Planung; HEAD bei Start `17a7e5e`, Arbeitsbaum sauber, `main == origin/main`). Vorbedingung Task 1: `docker ps | grep -c ^tessera-ctl-db-1$` -> `1`, `prisma migrate status` -> `36 migrations found` / `Database schema is up to date!` (IP `172.19.0.2`). Vorbedingung Task 3: `curl localhost:3002/api/v1/version` -> `{"version":"1.26.2"}`, `gitea-runner` -> `1`.
Baseline vor jeder Aenderung (erneut gemessen, identisch mit der Planung):
| Suite | Test Files | Tests |
|---|---|---|
| API (`pnpm -C apps/api exec vitest run`) | `65 passed (65)` | `1060 passed (1060)` |
| Web (`pnpm -C apps/web exec vitest run`) | `40 passed (40)` | `243 passed (243)` |
Konfiguration: `branching_strategy: none`, `quick_branch_template: null`, `auto_advance: false`, `human_verify_mode: end-of-phase`, `commit_docs: true`. Alle Commits liegen deshalb — wie die Plan-Commits dieses Auftrags und 260914-ku1 heute — auf `main`; `git.allow_default_branch_commits` ist nicht gesetzt, die Projektkonfiguration und der Plan (Push auf `main`, CI-Beobachtung des `main`-Laufs) verlangen es aber ausdruecklich.
## Task 1 — API (Commit `54121c1`, 16 Dateien)
**Schritt A, RED** (`pnpm -C apps/api exec vitest run src/bug-reports src/mail/mail.service.spec.ts src/settings/settings.service.spec.ts`):
```
FAIL src/bug-reports/bug-reports.controller.spec.ts — Error: Cannot find module './bug-reports.controller'
FAIL src/bug-reports/bug-reports.service.spec.ts — Error: Cannot find module './bug-reports.service'
FAIL mail.service.spec.ts > Test 5 / Test 6 — TypeError: service.sendBugReport is not a function
FAIL settings.service.spec.ts > Test A — TypeError: service.getBugReportRecipient is not a function
FAIL settings.service.spec.ts > Test B / Test C — AssertionError: expected undefined to be 'fehler@a.example.invalid'
Test Files 4 failed (4)
Tests 5 failed | 20 passed (25)
```
**Schritt B, Schema und Migration, [BLOCKING] `migrate deploy`** (`DATABASE_URL` nur in der Shell-Zeile, keine `.env` angefasst):
```
The following migration(s) have been applied:
migrations/
└─ 20260914170000_smtp_config_bug_report_recipient/
└─ migration.sql
All migrations have been successfully applied.
--- migrate status: 37 migrations found in prisma/migrations / Database schema is up to date!
--- migrate diff: -- This is an empty migration.
--- prisma generate: (ohne Fehler; nur der Accelerate-Tipp)
```
Migrationsordner-Zaehlung `ls apps/api/prisma/migrations | grep -c ""` -> `38` (36 + `migration_lock.toml` + 1 neu).
**Schritte C-E:** `SmtpConfigDto.bugReportRecipient` (`@IsOptional() @IsEmail()`, `string | null`), `SMTP_SAFE_SELECT` um das Feld, `saveSmtpConfig` mit bedingtem Spreading (fehlend = bewahren, `null`/leer = loeschen), neue Methode `getBugReportRecipient` (ein gebundener Klient, `findUnique` mit schmalem `select`). `MailService`: exportierte Typen `OutgoingAttachment`/`OutgoingMail`/`BugReportMail`, Versandkern `deliver` (wirft, `close()` im `finally`, Anhaenge/HTML nur wenn gesetzt), `sendViaTenantTransport` als verschluckender Mantel, `sendBugReport` ruft `deliver` direkt. Modul `bug-reports` mit DTO (Grenzen 4000/2000/100/20/64/1000/50/50, `errors` 30 x 1000), Dienst (Drossel-Map, PNG-Signatur, Empfaenger-Kette, gebundene Benutzerzeile `const tenantPrisma = forTenant(`, Betreff/Text/Anhang, 502-Uebersetzung, eine Protokollzeile), Controller (`@Controller('bug-reports')`, `@Post()`, `FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } })`, kein `@Roles`), Modul in `app.module.ts` hinter `TendersModule`. Doku-Zeilen in `docs/mandantentrennung-zugriffsklassifikation.md` (Bestandsaufnahme alphabetisch hinter `auth/`, Bereichs-Tabelle `bug-reports | 0 | 1 | 0`). `docker-compose.prod.yml`: `TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}` hinter `TESSERA_SMTP_FROM` mit englischem Kommentar.
**Schritt F, GREEN** (Zielspecs): `Test Files 5 passed (5)` / `Tests 66 passed (66)` (rls-inventory 30, mail 6, bug-reports.service 8, settings 19, bug-reports.controller 3 = 50 bisherige + 16 neue). Volle Suite: `Test Files 67 passed (67)` / `Tests 1076 passed (1076)`. `tsc --noEmit` api: `TSC_api=0`.
**Automatisierter Verify-Block Task 1** (Ausgabe in Planreihenfolge): `5 passed (5)` / `66 passed (66)`; `Database schema is up to date!`; `-- This is an empty migration.`; `38`; `1` (Schema); `1` (Migration); `1` (FileInterceptor); `1` (4-MiB-Limit); `0` (kein `@Roles`); `0` (kein `tenantId` im DTO); `1` (Zuweisungsform); `2` (`sendBugReport` in mail.service.ts); `2` (Import + Eintrag); `1` (Doku-Zeile); **`0` (Compose-Grep — siehe Befund unten)**; `M_EXIT=0`; `M_EMPTY=0`; `TSC_api=0`.
**Befund Compose-Grep (Messinstrument, nicht Datei):** das Muster des Plans `grep -c 'TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}' docker-compose.prod.yml` liefert `0` — dasselbe Muster liefert fuer die BESTEHENDE Zeile `TESSERA_SMTP_HOST: ${TESSERA_SMTP_HOST:-}` ebenfalls `0`. Mit festem Text `grep -c -F 'TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}'` -> `1`, mit `\$` -> `1`; `sed -n 52p | cat -A` zeigt exakt ` TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}$`. Die Datei traegt die vorgeschriebene Zeile; das Regex-Muster (`$` vor `{`) trifft sie nicht. Gate nicht angepasst, beide Zahlen hier festgehalten.
## Task 2 — Web (Commit 2a `60b0ee8`, 13 Dateien; Commit 2b `b41be21`, 5 Dateien)
**Schritt A, Abhaengigkeit:** `"html-to-image": "1.11.13"` alphabetisch unter `dependencies`; `pnpm install` -> `Done in 5.5s` (Lockfile +8 Zeilen: `html-to-image@1.11.13` als Importer-Eintrag, Paket und Snapshot; die Warnung `nunjucks 3.2.4 unmet peer chokidar` ist Bestand). `pnpm install --frozen-lockfile` -> `FROZEN=0`. `apps/web/node_modules/html-to-image/lib/index.d.ts` vorhanden. `git diff --stat 5c42c55 -- apps/api/package.json packages/shared/package.json package.json` leer (`U_EMPTY=0`). Folge fuer die CI: die deps-Stufe beider Dockerfiles laeuft wegen des Lockfiles neu (gemessen: Lauf 299 baute 3 min 37 s bzw. 3 min 58 s statt 5 min 18 s bei 297 — der Bau war sogar schneller, weil kein Base-Image nachzuladen war).
**Schritt B, RED Teil 2a** (`pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report`):
```
FAIL src/lib/error-buffer.test.ts — Error: Failed to resolve import "./error-buffer"
FAIL src/components/bug-report/bug-report-button.test.tsx — Error: Failed to resolve import "@/lib/error-buffer"
Test Files 2 failed (2)
Tests no tests
```
**Schritte C-F:** `error-buffer.ts` (Ringpuffer 20, `MAX_MESSAGE` 1000, `BODY_EXCERPT` 200, Guard `__tesseraErrorBufferInstalled`, `error`/`unhandledrejection`/`console.error`/`fetch`-Wrapper, `uninstallErrorBuffer` nur fuer Tests), `installErrorBuffer()` in `app-shell.tsx` als eigener `useEffect`; `bug-report-api.ts` (`computeCaptureSize`, `captureScreenshot` mit dynamischem Import und `filter` auf `data-bug-report-ignore`, `dataUrlToBlob`, `sendBugReport` ohne `headers`); i18n `bugReport` (20 Schluessel) in `de.json`/`en.json` je an Position 10 hinter `theme`; Dialog (`role="dialog"`, `aria-modal`, `aria-labelledby`, Escape ausser waehrend `sending`, Fokus auf das Textfeld, Zustaende `ready | sending | sent | failed`, Meldung je Status, Admin-Link `next/link` auf `/admin/smtp`), Knopf (Kaefer-Symbol nach lucide `bug`, `captureScreenshot()` VOR `setOpen(true)`), `<BugReportButton />` unmittelbar vor `<ThemeToggle />` in `header.tsx`.
**Umlaut-Waechter:** nach dem Eintrag des Namensraums flaggte `umlaut-guard.spec.ts` GENAU EIN Wort: `bugReport.descriptionLabel: "passiert" is a new word not on UMLAUT_ALLOWLIST` -> `'passiert'` mit Kommentar `// 260914-m97` in `UMLAUT_ALLOWLIST` eingetragen; `geschehen`, `aktuellen` wurden nicht verlangt. Danach `src/messages`: `Test Files 2 passed (2)` / `Tests 6 passed (6)`.
**Schritt F2, Gate 2a:** `pnpm -C apps/web exec vitest run src/lib/error-buffer.test.ts src/components/bug-report src/messages` -> `Test Files 4 passed (4)` / `Tests 21 passed (21)` (error-buffer 4, bug-report-button 11, Waechter 6); `TSC_web=0`. Commit 2a mit genau 13 Dateien (`git show --stat HEAD` -> `13 files changed, 968 insertions(+)`).
**Schritt G, RED Teil 2b** (`pnpm -C apps/web exec vitest run src/components/settings/smtp-settings-form.test.tsx`):
```
× Test 1: das Feld ist aus GET /settings/smtp vorbelegt
× Test 2: PUT-Payload traegt den Wert; leeres Feld -> null
Error: It looks like undefined was passed instead of a matcher. Did you do something like getByText(undefined)?
Test Files 1 failed (1)
Tests 2 failed (2)
```
(rot, weil `de.settings.smtp.bugReportRecipient` noch nicht existierte — das Label war `undefined`.)
**Schritt G/H, GREEN Teil 2b:** `settings.smtp.bugReportRecipient` / `bugReportRecipientHelp` hinter `testFailed` in beiden Dateien (jetzt 20 Schluessel unter `settings.smtp`), `settings-api.ts` (`bugReportRecipient: string | null` in `SmtpConfig`, optional in `SaveSmtpPayload`), Formular (`FormState.bugReportRecipient`, Vorbelegung `config.bugReportRecipient ?? ''`, `payload.bugReportRecipient = form.bugReportRecipient.trim() || null`, Eingabefeld `id="smtp-bug-report-recipient"` `type="email"` zwischen Absenderadresse und „Test-E-Mail an“). Zielspecs `Test Files 3 passed (3)` / `Tests 8 passed (8)`; volle Suite `Test Files 43 passed (43)` / `Tests 260 passed (260)`; `TSC_web=0`.
**Automatisierter Verify-Block Task 2:** `FROZEN=0`; `h2i=1.11.13`; `Test Files 5 passed (5)` / `Tests 23 passed (23)` (6 Waechter + 17 neue); `1`; `ORDER=ok`; `1`; `1`; `1`; `2` (`smtp-bug-report-recipient`, `htmlFor` + `id`); `Fehler melden | Was ist passiert? | Fehlermeldungen an | Report a problem | true`; `U_EMPTY=0`; `TSC_web=0`. Commit 2b mit genau 5 Dateien (`5 files changed, 115 insertions(+), 2 deletions(-)`).
## Task 3 — Handbuecher, Abschluss-Gates, Push, CI (Commit `77117de`, 3 Dateien)
`docs/anleitung-anwender.md`: Inhaltsverzeichnis 8. „Einen Fehler melden“, 9. „Haeufige Stolpersteine“; Kopfleiste „drei Bedienelemente“ mit dem Knopf als erstem Punkt; neuer Abschnitt (fuenf Absaetze: Ablauf, Beschreibung, Haekchen mit fettem Datenschutz-Satz, was mitgeschickt wird und dass nichts in Tessera gespeichert wird, Rueckmeldungen); neuer Stolperstein „kein Postfach“. `docs/anleitung-administration.md`: Kapitel 6 nennt das Feld in der Feldaufzaehlung und in einem eigenen Absatz (Wirkung, Betreff, Anhang, immer sichtbar, Drossel, 4 MB, dieselben Zugangsdaten, Rueckfall, Datenschutz); zwei neue Zeilen in der Fehlersuche-Tabelle. `docs/anleitung-betrieb.md`: `TESSERA_BUGREPORT_TO` in der Konfigurationstabelle nach der `TESSERA_SMTP_*`-Zeile (Serverdatei von Hand, wie `IMAGE_TAG`), neue Zeile in der Symptomtabelle Kapitel 7.
**Grep-Gates:** `^## Einen Fehler melden` -> `1`; `drei Bedienelemente` -> `1`; `Fehlermeldungen an` in administration -> `3` (erste Messung `2`, weil `grep -c` Zeilen zaehlt und Absatz plus Tabellenzeile zwei Zeilen sind — die Feldaufzaehlung im ersten Absatz von Kapitel 6 hat das Feld dann sachlich richtig als dritte Nennung bekommen; `grep -o | wc -l` -> `3`); `TESSERA_BUGREPORT_TO` in betrieb -> `2`; in administration -> `1`.
**Abschluss-Gates (alle nach dem letzten Code-Commit gemessen):**
| Gate | Ergebnis |
|---|---|
| API volle Suite | `Test Files 67 passed (67)` / `Tests 1076 passed (1076)` |
| Web volle Suite | `Test Files 43 passed (43)` / `Tests 260 passed (260)` |
| `tsc --noEmit` | `TSC_packages/shared=0`, `TSC_apps/api=0`, `TSC_apps/web=0` |
| `pnpm install --frozen-lockfile` | `FROZEN=0` |
| `git diff --stat 5c42c55 -- . ':!.planning'` | `GIT_EXIT=0`, ` 35 files changed, 2026 insertions(+), 17 deletions(-)` |
| Unangetastet-Stichprobe (`main.ts`, `biome.json`, `.env*`, `apps/api/package.json`, `packages/shared`, `package.json`, Migration `20260914120000`) | `U_EXIT=0`, `U_EMPTY=0` |
| `migrate status` (nach allem) | `Database schema is up to date!` |
| `git status -sb` nach `git fetch` | `## main...origin/main` (kein `[ahead`) |
**Push:** `git push` um 14:53:40Z -> `5c42c55..77117de main -> main` (die zwei Plan-Commits des Orchestrators gingen mit).
### CI-Lauf nach dem Push
| Feld | Versuch 1 | Versuch 2 |
|---|---|---|
| Lauf-ID | 299 (event `push`, ref `main`, `head_sha` `77117de`) | 299 (Rerun per `POST .../actions/runs/299/rerun`, HTTP 201, 14:59:08Z) |
| status / conclusion | `completed` / **`failure`** | `completed` / **`success`** |
| started_at / completed_at | 16:53:44 / 16:57:21 (+02:00) — 3 min 37 s | 16:59:10 / 17:03:08 (+02:00) — 3 min 58 s |
| Jobs | Lint & Type Check `success`, Tests `success`, Build & Publish `failure` im Schritt „Versionsstempel berechnen, Abbilder bauen und veroeffentlichen“ | alle drei `success` |
| Ursache Versuch 1 | Beide Abbilder wurden fertig gebaut (Web-Bundle inkl. `/login`-Route, `naming to localhost:3002/schalli/tessera-ctl/web:beta done`); der anschliessende `docker push` scheiterte sofort mit `error from registry: unauthorized`, obwohl `Login Succeeded` vorher stand. Gitea-Serverlog 16:57:09-16:57:19: `dial tcp: lookup db on 127.0.0.11:53: no such host` — Gitea konnte seine eigene Datenbank (`gitea-db`, laut `docker ps` in dieser Minute neu gestartet, nicht durch mich) zehn Sekunden lang nicht erreichen, jede authentifizierte Anfrage antwortete 401 (auch mein Poll 11 um 14:57:17Z: `GET .../actions/runs 401` -> `not-found`, und die Registry-`HEAD /v2/.../blobs` -> 401). Kein Zusammenhang mit den 35 Dateien; kein `fix`-Commit moeglich oder noetig. | — |
| `api:beta` node `APP_VERSION APP_CHANNEL APP_COMMIT` | — | `77117de beta 77117de` (`git rev-parse --short HEAD` = `77117de`) |
| `docker image inspect Created` web:beta / api:beta | — | `2026-09-14T17:01:00+02:00` / `2026-09-14T17:02:07+02:00` (nach dem Rerun-Start) |
| `web:beta` html-to-image, Probe des Plans (`ls /app/apps/web/node_modules/html-to-image/package.json \|\| ls /app/node_modules/.pnpm \| grep -c html-to-image`) | — | **`0`** — Messinstrument-Befund: das Standalone-Abbild traegt nur die vom Server benoetigten Pakete (`/app/node_modules/.pnpm` hat 25 Eintraege, kein `html*`); `html-to-image` wird ausschliesslich im Browser dynamisch importiert und liegt deshalb im Client-Bundle |
| `web:beta` html-to-image, korrigierte Probe | — | Chunk `/app/apps/web/.next/static/chunks/3717.5ecd3f65b9b8111a.js` (12.547 Bytes) enthaelt `cacheBust` und `skipFonts` (die Optionen des Aufrufs, in der minifizierten Bibliothek erhalten); 4 Chunks mit `foreignObject`, 1 Chunk mit `bug-report-ignore` -> Bibliothek und Knopf sind im Abbild |
## Falsifizierungen (a)-(d) — rot/gruen
| Spec | RED (vor Produktionscode) | GREEN |
|---|---|---|
| (a) `bug-reports.service.spec.ts` Test 3: fuenf Berichte durch, der sechste -> `HttpException` mit `getStatus() === 429`, `sendBugReport` genau fuenfmal; `u2` gleichzeitig frei; `vi.advanceTimersByTime(600001)` -> `u1` wieder `{ sent: true }` | `Cannot find module './bug-reports.service'` | `✓ 8 tests` in `bug-reports.service.spec.ts` |
| (b) Test 4: `Buffer.from('nicht png, aber lang genug')` -> `BadRequestException`; die ersten 7 PNG-Bytes -> `BadRequestException`; `sendBugReport` nie gerufen | wie oben | ✓ |
| (c) Test 5: Settings `null` + Variable `undefined` -> `ConflictException` mit `Fehlermeldungen an` in der Meldung; Variable `''` -> ebenfalls `ConflictException`; nie versendet | wie oben | ✓ |
| (d) Test 8: DTO mit `tenantId: 'fremd'`, `userId: 'u-fremd'` -> `getBugReportRecipient('t1')`, jeder `forTenant`-Aufruf mit `'t1'`, `sendBugReport` mit `'t1'`; Text enthaelt `Anna Muster (anna)`, nicht `Fremde Anna`/`Eindringling`/`fremd@x.invalid`. Controller-Spec Test 1: `ValidationPipe({ whitelist: true, transform: true })` entfernt `tenantId`, `errors: 'einzeln'` -> `['einzeln']`, fehlend -> `[]`, Array bleibt | Service: wie oben; Controller: `Cannot find module './bug-reports.controller'`; nach dem ersten GREEN-Lauf war Test 1 noch rot (`BadRequestException` bei ganz fehlendem `errors`) — siehe Abweichung 1 | ✓ 3 tests |
| Reihenfolge Bild-vor-Dialog: `bug-report-button.test.tsx` Test 1 (`toPng`-Mock prueft `queryByRole('dialog')` ist `null`, `canvasWidth: 1600`, `canvasHeight: 500` bei 3200x1000) | `Failed to resolve import "@/lib/error-buffer"` | ✓ 11 tests |
| Kennwort-Reset bleibt verschluckend: `mail.service.spec.ts` Test 6 (`sendBugReport` -> `rejects.toThrow('ECONNREFUSED')`, `sendPasswordResetEmail` -> `resolves.toBeUndefined()`, `close()` zweimal) | `service.sendBugReport is not a function` | ✓ 6 tests |
| Fehlerpuffer ohne Geheimnisse: `error-buffer.test.ts` Test 2 (`POST /api/x -> 500`, enthaelt `kaputt`, NICHT `geheim`, NICHT `password`, NICHT `token=`; `res.json()` weiter lesbar; 200 nicht notiert) | `Failed to resolve import "./error-buffer"` | ✓ 4 tests |
## Task Commits
1. **Task 1: API** — `54121c1` (feat) — 16 Dateien
2. **Task 2a: Web Knopf/Dialog/Puffer** — `60b0ee8` (feat) — 13 Dateien
3. **Task 2b: SMTP-Formular** — `b41be21` (feat) — 5 Dateien
4. **Task 3: Handbuecher** — `77117de` (docs) — 3 Dateien
`commits: 4` gemessen aus `git rev-list --count 17a7e5e..HEAD` (Ledger `plan_head_before` = `17a7e5ef9b77b9e6bd4cf5cb337e691b215bbabb`). `actuals.tokens` = 826.706 Zeichen ueber die 35 geaenderten Dateien / 4 = 206.676 (Methode wie 260914-ku1; der reine Diff waere 121.245 Zeichen = 30.311). Der Plan schaetzte 150.000 bei `confidence: low`.
`git log --oneline 17a7e5e..HEAD` (vor dem SUMMARY):
```
77117de docs(quick-260914-m97): Handbuecher — Einen Fehler melden (Anwender), Feld Fehlermeldungen an (Administration), TESSERA_BUGREPORT_TO als Rueckfall (Betrieb)
b41be21 feat(quick-260914-m97): Feld Fehlermeldungen an im SMTP-Formular — settings-api, Formular, i18n settings.smtp
60b0ee8 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
54121c1 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
```
`git status --porcelain` (vor dem SUMMARY): nur ` M .planning/WINDOWS.md` (Ledger-Eintrag der Abweichung 1, siehe unten) — kein Code ungeschrieben, kein Code uncommittet.
## Entscheidungen
- **Multipart statt JSON+Base64** (Planungsmessung uebernommen und im Bau bestaetigt): `FileInterceptor('screenshot', { limits: { fileSize: 4 MiB, files: 1 } })` begrenzt nur diese Route; `main.ts` unangetastet (Gate `M_EMPTY=0`). Ein globales `app.useBodyParser('json', { limit })` haette jede JSON-Route inkl. `/auth/login` geoeffnet.
- **Keine `GET /bug-reports/status`-Route:** der Knopf ist immer sichtbar, 409 beim Senden traegt die Information (mit Admin-Link); keine zusaetzliche Anfrage je Seitenladung.
- **`@Expose()` im DTO** (Abweichung 1): ohne `@Expose()` ruft class-transformer `@Transform` fuer einen im Rumpf GANZ fehlenden Schluessel nicht auf; die Normalisierung `undefined -> []` haette nur auf dem Papier gestanden.
- **Rerun statt `fix`-Commit** fuer den CI-Lauf: die Ursache lag in Gitea (Datenbank kurz nicht erreichbar), nicht im Code; ein Leer-Commit haette nur die Historie verschmutzt.
- **Commits auf `main`:** siehe Ausgangslage — Projektkonfiguration `branching_strategy: none`, Plan verlangt Push auf `main` und CI-Beobachtung; identisch mit 260914-ku1 heute.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] `@Expose()` auf `errors`, damit die `@Transform`-Normalisierung auch bei ganz fehlendem Feld greift**
- **Found during:** Task 1, Schritt F (erster GREEN-Lauf, Controller-Spec Test 1 rot: `BadRequestException` bei `pipe.transform({ ...baseBody }, meta)` ohne `errors`)
- **Issue:** Das Rezept des Plans (`@Transform` gefolgt von `@IsArray()`) deckt den Fall „Feld fehlt im Multipart-Rumpf“ nicht: class-transformer laeuft `@Transform` nur fuer Schluessel, die im Quellobjekt vorhanden sind; `errors` blieb `undefined`, `@IsArray()` schlug fehl — ein Anwender ohne Browserfehler haette 400 bekommen.
- **Fix:** `@Expose()` (aus `class-transformer`, bereits installiert) vor `@Transform`; Kommentar im DTO nennt die Messung.
- **Files modified:** `apps/api/src/bug-reports/dto/bug-report.dto.ts`
- **Verification:** `bug-reports.controller.spec.ts` Test 1 gruen (String -> `['einzeln']`, fehlend -> `[]`, Array bleibt), Test 2 (Grenzen) unveraendert gruen
- **Committed in:** `54121c1` (Teil des Task-1-Commits)
**2. [Rule 2 - Missing content] Dritte Nennung von „Fehlermeldungen an“ in der Feldaufzaehlung von Kapitel 6**
- **Found during:** Task 3, Grep-Gate (`grep -c "Fehlermeldungen an" docs/anleitung-administration.md` -> `2`, Plan: mindestens `3`)
- **Issue:** `grep -c` zaehlt Zeilen; Absatz und Tabellenzeile sind zwei Zeilen. Inhaltlich fehlte das neue Feld in der Aufzaehlung der SMTP-Felder im ersten Absatz von Kapitel 6.
- **Fix:** Aufzaehlung ergaenzt („… die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten)“). Kein Gate angepasst.
- **Files modified:** `docs/anleitung-administration.md`
- **Verification:** `grep -c` -> `3`, `grep -o | wc -l` -> `3`
- **Committed in:** `77117de`
---
**Total deviations:** 2 auto-fixed (1 x Rule 1, 1 x Rule 2). **Impact on plan:** keine Erweiterung der 35 Dateien, keine Gate-Anpassung; beide Korrekturen sind fuer Korrektheit bzw. Vollstaendigkeit noetig.
Ledger: `gsd_run windows append --kind deviation` fuer Abweichung 1 ist geschrieben (`.planning/WINDOWS.md`, `ok: true`) — die Datei liegt uncommittet fuer den Docs-Commit des Orchestrators.
## Issues Encountered
1. **CI-Lauf 299, Versuch 1 `failure`** — Ursache Gitea-Datenbank-Ausfall 16:57:09-16:57:19 (siehe Tabelle „CI-Lauf nach dem Push“), Registry antwortete 401 beim Push. Behoben durch Rerun ueber die API (Versuch 2 `success`). Kein Code-Commit.
2. **Zwei Messinstrumente des Plans trafen die Wirklichkeit nicht:** Compose-Grep-Muster (`0` fuer die neue UND fuer die bestehende SMTP-Zeile; `grep -F` -> `1`) und Abbild-Probe fuer `html-to-image` (`0` in den Server-`node_modules` des Standalone-Abbilds; korrigierte Probe im Client-Chunk positiv). Beide Male ist die Datei/das Abbild wie vorgeschrieben; beide Zahlen stehen oben nebeneinander.
3. **Web-Test-Baseline unveraendert 40/243, API 65/1060** — keine Abweichung, hier nur als Kontrolle: die Zielzahlen 67/1076 und 43/260 wurden exakt erreicht (API +2 Dateien, +16 Tests; Web +3 Dateien, +17 Tests).
## Was bewusst offen bleibt
- **Browser-Beweis** (Bild ohne Dialog, OKLCH-Farben, E-Mail mit PNG-Anhang in mailhog, Groesse des Anhangs): nicht im Executor moeglich (jsdom rastert nicht) — Human-Check `end-of-phase`, Anleitung unten.
- **Serverdatei `/opt/tessera/docker-compose.prod.yml`** bekommt die Zeile `TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}` nur von Hand (wie `IMAGE_TAG`, Betriebshandbuch Kapitel 3/9). Ohne sie gilt auf dem Server ausschliesslich das UI-Feld — was fuer den Live-Betrieb reicht.
- **Basis-/Dev-Compose** reichen `TESSERA_BUGREPORT_TO` nicht durch (Plan: nur `docker-compose.prod.yml`); lokal ist der Empfaenger ueber das UI-Feld zu setzen.
- **Drossel im Prozessspeicher:** je API-Prozess, geht bei Neustart verloren und gilt je Instanz — fuer eine Instanz korrekt, bei mehreren Instanzen waere die Grenze n x 5.
- **Erstfreigabe v1.0.0** (Zweig `live` + Tag) ist nicht Teil dieses Plans.
## Fuer den Verifizierer (Browser-Check mit mailhog)
1. Mail-Senke starten: `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mailhog` (Ports 1025 SMTP, 8025 Web-Oberflaeche: `http://localhost:8025`). Dann `docker compose up -d --build api web` (die `:latest`-Abbilder sind lokal warm; `--build` ist Pflicht, `up` allein baut nicht neu).
2. Im Browser anmelden, **Administrator -> SMTP**: Host `mailhog`, Port `1025`, Verschluesselung „Keine“, Benutzername/Passwort leer, Absender `tessera@tessera.local`, **Fehlermeldungen an** `fehler@example.invalid`, speichern. (Env-Variante nur bei Bedarf: `TESSERA_BUGREPORT_TO` ist in der Basis-Compose NICHT durchgereicht — dafuer muesste man sie lokal, uncommittet, in den `environment`-Block von `api` in `docker-compose.dev.yml` eintragen; das UI-Feld ist der vorgesehene Weg.)
3. Auf einer Seite mit Inhalt (z. B. Benutzerverwaltung, dunkles Erscheinungsbild) den Kaefer-Knopf rechts oben klicken: Dialog mit Vorschau — die Vorschau darf den Dialog NICHT zeigen und muss die Farben der Seite wiedergeben. Beschreibung eintragen, „Senden“ -> „Vielen Dank, die Meldung wurde gesendet.“
4. `http://localhost:8025`: E-Mail mit Betreff `[Tessera Fehlermeldung] dev dev - /admin/users` (lokal ohne Build-Args: Version/Kanal `dev`), Text mit allen Kontextzeilen, Anhang `fehlermeldung-<yyyymmdd-hhmm>.png` — Groesse notieren (Erwartung unter 2 MB).
5. Zweite Probe: Feld „Fehlermeldungen an“ leeren und speichern -> Senden zeigt „Fuer Fehlermeldungen ist noch kein Postfach eingerichtet.“ plus Admin-Hinweis mit Link „Zu den SMTP-Einstellungen“ (als ADMIN/SUPER_ADMIN).
6. Dritte Probe: sechs Meldungen hintereinander -> die sechste zeigt „Zu viele Meldungen in kurzer Zeit …“.
7. `docker compose logs api | grep "Bug report"` -> je gesendeter Meldung eine Zeile `Bug report from <user> (tenant <id>) sent to <adresse> — page <pfad>, screenshot <n> bytes`, ohne Beschreibung und ohne Bild.
## Handgriffe fuer den User
- **Wo der Empfaenger eingestellt wird:** In Tessera als Administrator oben rechts **Administrator -> SMTP**, Feld **Fehlermeldungen an** (unter der Absenderadresse), Adresse eintragen, „Einstellungen speichern“. Ab dann gehen alle Meldungen der Anwender dieses Mandanten mit Bild dorthin. Feld leeren und speichern schaltet den Versand wieder ab (der Knopf bleibt sichtbar und erklaert dann, dass kein Postfach eingerichtet ist).
- **Rueckfall ueber die Umgebung** (nur fuer Installationen ohne gespeicherte SMTP-Einstellungen): `TESSERA_BUGREPORT_TO=<adresse>` in der `.env` des Servers UND die Zeile `TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}` im `environment`-Block von `api` in `/opt/tessera/docker-compose.prod.yml` (von Hand, wie beim `IMAGE_TAG`), danach `api` neu erstellen. Das UI-Feld gewinnt immer, wenn beides gesetzt ist.
- **Datenbank:** Die neue Spalte kommt beim naechsten Deploy automatisch mit (`migrate deploy` beim API-Start, additive Migration, nichts zu tun).
## Self-Check: PASSED
- Dateien: alle 14 neu angelegten Dateien vorhanden (`bug-reports/*` 7, `error-buffer.ts/.test.ts`, `bug-report-api.ts`, `bug-report/*` 3, `smtp-settings-form.test.tsx`, Migration).
- Commits: `54121c1`, `60b0ee8`, `b41be21`, `77117de` in `git log --oneline --all` gefunden; `main == origin/main`.
- `commits: 4` = `git rev-list --count 17a7e5e..HEAD`.
@@ -0,0 +1,179 @@
---
phase: quick-260914-m97
verified: 2026-09-14T15:15:19Z
status: passed
score: 9/9 must-haves verified (Code/Tests/CI) + Browser-Beweis durch den Orchestrator am 2026-09-14 15:16Z bestanden (siehe Nachtrag unten)
behavior_unverified: 0
overrides_applied: 0
human_verification:
- test: "Browser-Check mit mailhog (SUMMARY-Abschnitt 'Fuer den Verifizierer')"
expected: "Kaefer-Knopf nimmt Bild VOR dem Dialog auf (Vorschau zeigt NICHT den Dialog, OKLCH-Farben korrekt); Senden erzeugt E-Mail in mailhog mit Betreff '[Tessera Fehlermeldung] ...' und PNG-Anhang < 2 MB; leeres Empfaenger-Feld -> 409-Text mit Admin-Link; sechste Meldung -> 429-Text; API-Log zeigt eine Zeile je Meldung ohne Bild/Beschreibung"
why_human: "jsdom rastert nicht (HTMLVideoElement is not defined, kein Canvas-Backend) - html-to-image kann nur im echten Browser beobachtet werden; dies ist laut Auftrag ausdruecklich Aufgabe des Orchestrators, NICHT dieses Verifizierers"
---
# Quick 260914-m97: Fehler-melden-Knopf — Verifikationsbericht
**Auftrag:** Fehler-melden-Knopf in der Kopfzeile — Bildschirmfoto VOR dem Dialog (html-to-image 1.11.13), Dialog mit Vorschau/Beschreibung/Haekchen, `POST /bug-reports` (Multipart, nur angemeldet, Mandant/Benutzer nur aus der Sitzung, 4 MiB -> 413, PNG-Signatur -> 400, fehlender Empfaenger -> 409, Drossel 5/10 min -> 429, Versandfehler -> 502), E-Mail mit PNG-Anhang und Kontext, Empfaenger als neue Spalte `SmtpConfig.bugReportRecipient`, Rueckfall `TESSERA_BUGREPORT_TO`, Handbuecher, gepusht, CI gruen.
**Verifiziert:** 2026-09-14T15:15Z
**Status:** human_needed (Code/Tests/Migration/CI vollstaendig verifiziert; einziger offener Punkt ist der Browser-Beweis, der laut Auftrag dem Orchestrator obliegt)
Umgebungshinweis: Zu Beginn dieser Verifikation war die Festplatte `/` kurzzeitig zu 100% voll, wodurch drei parallel gestartete `npx tsc`-Aufrufe mit `ENOSPC` fehlschlugen (npx wollte Cache-Metadaten schreiben, auch fuer bereits lokal vorhandene Binaries). Ich habe daraufhin `node node_modules/typescript/bin/tsc --noEmit` direkt aufgerufen (umgeht den npx-Cache) — alle drei Pakete meldeten danach Exit 0. Kein Projektartefakt betroffen; df zeigte kurz danach wieder 8,4 GiB frei (90% belegt), vermutlich ein voruebergehender Cache-Peak eines Fremdprozesses auf der Maschine.
## 1. Git-Historie und Datei-Umfang
| Pruefung | Befehl | Ergebnis | Status |
|---|---|---|---|
| Vier Commits | `git log --oneline 17a7e5e..HEAD` | `77117de`, `b41be21`, `60b0ee8`, `54121c1` — exakt die vier erwarteten | OK |
| Datei-Umfang | `git diff --stat 5c42c55 -- . ':!.planning'` | `35 files changed, 2026 insertions(+), 17 deletions(-)` | OK |
| Sensible Dateien unangetastet | `git diff --name-only 5c42c55 -- '.env*' apps/api/src/main.ts biome.json` | leer | OK |
| Bestehende Migrationen unangetastet | `git diff --name-only 5c42c55 -- apps/api/prisma/migrations \| grep -v 20260914170000` | leer (grep exit 1) | OK |
| Commit `60b0ee8` Datei-Zeilen | `git show --stat 60b0ee8 \| grep -c '\|'` | `13` | OK, passt zu 13 Dateien |
| Commit `b41be21` Datei-Zeilen | `git show --stat b41be21 \| grep -c '\|'` | `7` — bei Pruefung: Commit-Botschaft selbst enthaelt zwei `\|`-Zeichen (`string \| null`, `trim() \|\| null`); tatsaechliche Datei-Zeilen im Diffstat sind **5** (`smtp-settings-form.test.tsx`, `smtp-settings-form.tsx`, `settings-api.ts`, `de.json`, `en.json`) — passt zu den 5 erwarteten Dateien | OK (Messmuster liefert falsches Positiv, Datei-Zaehlung selbst stimmt) |
| Nur die zwei erwarteten Paket-Dateien geaendert | `git diff --name-only 5c42c55 -- apps/api/package.json packages/shared/package.json package.json apps/web/package.json pnpm-lock.yaml` | nur `apps/web/package.json`, `pnpm-lock.yaml` | OK |
## 2. Testsuiten und Typprüfung
| Pruefung | Befehl | Ergebnis | Status |
|---|---|---|---|
| API-Suite | `cd apps/api && npx vitest run` | `Test Files 67 passed (67)` / `Tests 1076 passed (1076)` | OK, passt exakt |
| Web-Suite | `cd apps/web && node node_modules/vitest/vitest.mjs run` (npx scheiterte an ENOSPC, siehe Umgebungshinweis) | `Test Files 43 passed (43)` / `Tests 260 passed (260)` | OK, passt exakt |
| `tsc --noEmit` api | `node node_modules/typescript/bin/tsc --noEmit` | Exit 0 | OK |
| `tsc --noEmit` web | `node node_modules/typescript/bin/tsc --noEmit` | Exit 0 | OK |
| `tsc --noEmit` shared | `node node_modules/typescript/bin/tsc --noEmit` | Exit 0 | OK |
| `pnpm install --frozen-lockfile` | `pnpm install --frozen-lockfile` | Exit 0, `Lockfile is up to date, resolution step is skipped`; `git status --porcelain -- pnpm-lock.yaml apps/web/package.json` danach leer | OK |
| `rls-access-inventory.spec.ts` | `node node_modules/vitest/vitest.mjs run src/prisma/rls-access-inventory.spec.ts` | `30 passed (30)` | OK |
| `umlaut-guard.spec.ts` + `tenderRadar-parity.spec.ts` | `node node_modules/vitest/vitest.mjs run src/messages` | `Test Files 2 passed (2)` / `Tests 6 passed (6)` | OK |
## 3. Migration und Datenbank
| Pruefung | Befehl | Ergebnis | Status |
|---|---|---|---|
| Migrationsstatus | `prisma migrate status` (DATABASE_URL gegen `172.19.0.2`, nicht in `.env` geschrieben) | `37 migrations found` / `Database schema is up to date!` | OK |
| Migrations-SQL additiv | `cat .../20260914170000_.../migration.sql` | genau EINE Anweisung `ALTER TABLE "SmtpConfig" ADD COLUMN "bugReportRecipient" TEXT;` (nullbar, keine weiteren Statements), davor nur Kommentarzeilen | OK |
| Spalte in lokaler DB | `docker exec ... psql -c '\d "SmtpConfig"'` | `bugReportRecipient \| text \| \| \|` (nullable) | OK |
## 4. API-Modul `bug-reports`
Gelesen: `bug-reports.module.ts`, `bug-reports.controller.ts`, `bug-reports.service.ts`, `dto/bug-report.dto.ts`, `mail.service.ts`.
| Anforderung | Befund | Status |
|---|---|---|
| Kein `@Roles`, offen fuer alle angemeldeten Rollen | `@Controller('bug-reports')` / `@Post()` ohne Rollen-Dekorator; `ROLES_KEY`-Metadatum ist laut `bug-reports.controller.spec.ts` Test 3 `undefined` | OK |
| `@CurrentUser()` einzige Quelle fuer Mandant/Benutzer | `submit(@CurrentUser() user, @Body() dto, @UploadedFile() file)`; DTO hat keine `tenantId`/`userId`-Felder | OK |
| `FileInterceptor` 4 MiB | `FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } })` | OK |
| PNG-Signatur | `PNG_SIGNATURE = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a])`, Pruefung vor Versand, `BadRequestException` bei Fehlschlag | OK |
| Drossel 5/10min -> 429 | `WINDOW_MS = 10*60*1000`, `MAX_PER_WINDOW = 5`, `HttpException(..., 429)`; **unabhaengig falsifiziert** (siehe unten) | OK |
| 409 bei fehlendem Empfaenger | `getBugReportRecipient(tenantId) \|\| TESSERA_BUGREPORT_TO.trim() \|\| null`, sonst `ConflictException` mit Text „Fehlermeldungen an" | OK |
| 502 bei Versandfehler | `try { sendBugReport(...) } catch { throw new BadGatewayException(...) }` | OK |
| Genau eine Protokollzeile, nie Bild/Beschreibung | `this.logger.log('Bug report from ... sent to ... page ..., screenshot ... bytes')` | OK |
| `mail.service.ts`: `sendBugReport` wirft, `sendPasswordResetEmail` verschluckt weiter | `deliver()` wirft, `sendViaTenantTransport` faengt (T-02-12 unveraendert), `sendBugReport` ruft `deliver` direkt; `mail.service.spec.ts` Test 5/6 pinnt genau das | OK |
**Unabhaengige Falsifizierung (Punkt 4 der Vorgabe):** `MAX_PER_WINDOW` in `bug-reports.service.ts` temporaer von `5` auf `6` geaendert, `bug-reports.service.spec.ts` erneut gelaufen -> Test 3 ("Falsifizierung a") wird ROT (`expected undefined to be an instance of HttpException`), die uebrigen 7 Tests bleiben gruen. Danach `git checkout -- apps/api/src/bug-reports/bug-reports.service.ts`; `grep MAX_PER_WINDOW` zeigt wieder `= 5`; `git status --porcelain -- apps/` ist leer — Ruecksetzung bewiesen.
## 5. Web: Fehlerpuffer, Bildaufnahme, Knopf, Dialog
| Anforderung | Befund | Status |
|---|---|---|
| Ringpuffer 20, `error`/`unhandledrejection`/`console.error`/`fetch` | `error-buffer.ts`: `MAX_ENTRIES = 20`, alle vier Quellen registriert | OK |
| Fetch-Wrapper notiert nur `!response.ok`, nie Anfrage-Rumpf/Cookies/Suchteil | Wrapper prueft `if (!response.ok)`, nutzt `pathOf()` (nur `pathname`, kein `search`), liest nur `response.clone().text()` (Antwort, nicht Anfrage); Test 2 pinnt „enthaelt NICHT geheim/password" | OK |
| SSR-sicher, idempotent | `if (typeof window === 'undefined') return;`, Guard `__tesseraErrorBufferInstalled` | OK |
| `computeCaptureSize` exportiert und rein | `apps/web/src/lib/bug-report-api.ts`, reine Funktion, Test 11 (eigener describe-Block) prueft 5 Faelle direkt | OK |
| `toPng` mit `pixelRatio: 1, skipFonts: true, cacheBust: true, canvasWidth/canvasHeight` | `captureScreenshot()` genau so implementiert | OK |
| Bild VOR Dialog | `bug-report-button.tsx`: `const shot = await captureScreenshot(); setScreenshot(shot); setOpen(true);` — Reihenfolge im Code UND in Test 1 (`toPng`-Mock prueft `queryByRole('dialog')` ist `null` waehrend seines eigenen Aufrufs) gepinnt | OK |
| Knopf vor ThemeToggle in `header.tsx` | Zeile 113/114: `<BugReportButton />` unmittelbar vor `<ThemeToggle />` | OK |
| Dialog: Escape, Fokus, Zustaende, 409/413/429/502/allgemein | `bug-report-dialog.tsx`: `role="dialog" aria-modal`, Escape-Handler (nicht waehrend `sending`), Fokus auf Textarea beim Oeffnen, `errorKey`-Zuordnung 409/429/413/502/sonst; 11 Tests in `bug-report-button.test.tsx` (echte `it(...)`-Zeilen gezaehlt: 11, eine weitere Fundstelle war ein Kommentar/Helper, kein Test) decken Tests 7-10 fuer 413/429/502/allgemein mit Texten aus `de.json` | OK |
| `installErrorBuffer()` in `app-shell.tsx` | `useEffect(() => { installErrorBuffer(); }, [])` vorhanden | OK |
| `html-to-image` exakt `1.11.13` | `apps/web/package.json` Zeile 15 | OK |
## 6. i18n
| Pruefung | Ergebnis | Status |
|---|---|---|
| `bugReport`-Namensraum in de.json/en.json identisch strukturiert | 20 Schluessel in beiden Dateien, gleiche Schluesselmenge (Python-Vergleich) | OK |
| `settings.smtp.bugReportRecipient`/`bugReportRecipientHelp` in beiden Sprachen | vorhanden, Sie-Form in de | OK |
| `umlaut-dictionary.ts` um `passiert` erweitert | Zeile 178, Kommentar `// 260914-m97` | OK |
| `umlaut-guard.spec.ts` / `tenderRadar-parity.spec.ts` gruen | siehe Abschnitt 2 | OK |
## 7. Einstellungen (SMTP-Formular)
| Pruefung | Ergebnis | Status |
|---|---|---|
| `SMTP_SAFE_SELECT` enthaelt `bugReportRecipient` | `settings.service.ts` Zeile 22 | OK |
| DTO `@IsOptional() @IsEmail()` | `smtp-config.dto.ts` Zeile 59-61 | OK |
| `PUT /settings/smtp` weiterhin nur ADMIN/SUPER_ADMIN | `@Put('smtp') @Roles(Role.ADMIN, Role.SUPER_ADMIN)` | OK |
| Web-Formular: Feld „Fehlermeldungen an" mit Hinweistext | `smtp-settings-form.tsx` Zeile 308-325, `id="smtp-bug-report-recipient"`, `type="email"` | OK |
## 8. `docker-compose.prod.yml`
`grep TESSERA_BUGREPORT_TO docker-compose.prod.yml` -> `TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}` (Zeile 52, im `api.environment`-Block). `.env*` unveraendert (siehe Abschnitt 1).
## 9. Handbuecher
| Datei | Pruefung | Ergebnis |
|---|---|---|
| `docs/anleitung-anwender.md` | Abschnitt „Einen Fehler melden", Datenschutz-Satz, „drei Bedienelemente" | vorhanden (Zeilen 43, 160, 166) |
| `docs/anleitung-administration.md` | Feld „Fehlermeldungen an" unter Administrator -> SMTP, Fehlersuche-Zeile | vorhanden (Zeilen 204, 208, 241) |
| `docs/anleitung-betrieb.md` | `TESSERA_BUGREPORT_TO` in Konfigurationstabelle | vorhanden (Zeile 161, 340) |
| `docs/mandantentrennung-zugriffsklassifikation.md` | neue Zeile fuer den gebundenen `user`-Lesezugriff in `bug-reports.service.ts` | vorhanden (Zeile 664) plus Bereichs-Tabellen-Zeile (Zeile 176) |
## 10. CI und Container-Abbilder
| Pruefung | Befehl/Quelle | Ergebnis | Status |
|---|---|---|---|
| CI-Lauf fuer `77117de` | Gitea-API `.../actions/tasks?limit=5` (Token nur aus `git remote get-url --push origin` gelesen, nie ausgegeben) | Run 299/215: `Lint & Type Check`, `Tests`, `Build & Publish Images` je `status: success` fuer `head_sha 77117de3d0f1bb82df7b659f46fe26244ca6f164` | OK |
| `api:beta`-Abbild traegt den richtigen Commit | `docker run --rm --entrypoint node localhost:3002/schalli/tessera-ctl/api:beta -e "console.log(process.env.APP_VERSION, process.env.APP_CHANNEL)"` | `77117de beta` | OK |
| `web:beta`-Abbild enthaelt html-to-image im Client-Bundle | `docker run --rm --entrypoint sh localhost:3002/.../web:beta -c 'grep -rl "toPng\|html-to-image" apps/web/.next/static'` | Treffer in `chunks/3717.5ecd3f65b9b8111a.js` und `chunks/app/(portal)/layout-....js` | OK |
## 11. Push-Status
`git fetch -q && git status -sb | head -1` -> `## main...origin/main` (kein `[ahead`). OK.
## 12. Ledger `.planning/WINDOWS.md` #38
Eintrag #38 (`status: open`) beschreibt die Rule-1-Selbstkorrektur des Executors: `@Expose()` wurde auf `errors` im DTO ergaenzt, weil `class-transformer` `@Transform` sonst nur fuer im Rumpf VORHANDENE Schluessel aufruft (ohne die Korrektur haette ein Anwender ohne Browserfehler 400 statt 200 erhalten). Diese Korrektur ist bereits im selben Commit (`54121c1`) enthalten, verifiziert (`bug-reports.controller.spec.ts` Test 1 gruen, siehe Abschnitt 2) und im Code vorhanden (`bug-report.dto.ts` Zeile 71 `@Expose()`).
**Meine Einschaetzung:** Dies ist eine reine Protokollzeile eines bereits erledigten, verifizierten In-Scope-Fixes — kein offener technischer Mangel im Code. Der `status: open` bedeutet hier lediglich, dass niemand `gsd-tools windows fixed 38` ausgefuehrt hat, nicht dass am Code noch etwas fehlt. Zum Vergleich: Eintrag #37 (Single-Flight-Riegel prozessweit statt je Mandant) beschreibt eine tatsaechlich noch bestehende Einschraenkung im laufenden Code — #38 ist damit nicht vergleichbar und sollte administrativ geschlossen werden, ohne dass ein Folgeauftrag noetig ist.
## Vom Orchestrator im Browser zu pruefen
Dieser Verifizierer hat KEINE Container gestartet/gestoppt (Vorgabe). Folgende Schritte aus dem SUMMARY-Abschnitt „Fuer den Verifizierer" bleiben fuer den Orchestrator:
1. `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mailhog`, danach `docker compose up -d --build api web`.
2. Anmelden, Administrator -> SMTP: Host `mailhog`, Port `1025`, Verschluesselung „Keine", Absender `tessera@tessera.local`, „Fehlermeldungen an" `fehler@example.invalid`, speichern.
3. Auf einer Seite mit Inhalt (dunkles Erscheinungsbild) Kaefer-Knopf klicken: Vorschau darf den Dialog NICHT zeigen, Farben muessen stimmen; Beschreibung eintragen, „Senden" -> „Vielen Dank, die Meldung wurde gesendet."
4. `http://localhost:8025`: E-Mail mit Betreff `[Tessera Fehlermeldung] dev dev - /admin/users`, Anhang `fehlermeldung-<yyyymmdd-hhmm>.png` unter 2 MB, alle Kontextzeilen im Text.
5. Feld leeren, speichern, senden -> 409-Text mit Admin-Link.
6. Sechs Meldungen hintereinander -> sechste zeigt 429-Text.
7. `docker compose logs api | grep "Bug report"` -> je Meldung eine Zeile ohne Bild/Beschreibung.
## Angenommene Risiken
- **Drossel im Prozessspeicher:** gilt je API-Instanz, geht bei Neustart verloren. Fuer den Livestart mit einer Instanz korrekt; bei mehreren Instanzen waere die effektive Grenze `n x 5`. So im Plan akzeptiert, nicht Teil dieses Auftrags zu loesen.
- **`TESSERA_BUGREPORT_TO` auf dem Produktivserver:** die Zeile in `/opt/tessera/docker-compose.prod.yml` muss von Hand ergaenzt werden (wie `IMAGE_TAG`) — ohne diesen manuellen Schritt gilt dort ausschliesslich das UI-Feld, was fuer den Livebetrieb morgen ausreicht.
- **Ledger #38:** administrative Restarbeit (Eintrag als `fixed` markieren), kein Code-Risiko — siehe Abschnitt 12.
- **Commit-`b41be21`-Messmuster:** das im Auftrag vorgegebene `grep -c '|'` liefert wegen Pipe-Zeichen in der Commit-Botschaft selbst `7` statt `5`; die tatsaechliche Datei-Zaehlung (5) stimmt nachweislich mit den erwarteten Dateien ueberein — kein Befund, nur eine Schwaeche des Messinstruments (bereits im Plan als "Messinstrument, nicht Datei"-Muster fuer den Compose-Grep dokumentiert, hier dasselbe Phaenomen bei einem anderen Grep).
- **Browser-Beweis steht aus** (siehe `human_verification` oben) — laut Auftragstext ausdruecklich Aufgabe des Orchestrators nach dieser Verifikation, nicht dieses Verifizierers.
---
_Verifiziert: 2026-09-14T15:15Z_
_Verifizierer: Claude (gsd-verifier)_
## Nachtrag des Orchestrators — Browser-Check durchgefuehrt (2026-09-14, 15:15-15:17Z)
Umgebung: lokale Container aus `main` (`77117de`) frisch gebaut (`docker compose up -d --build api web`), `mailhog` aus `docker-compose.dev.yml`, Playwright MCP (Chromium 153). Vorher musste die Platte freigeraeumt werden (`docker builder prune` 9,25 GB, `docker image prune` 4,85 GB; der erste Bau scheiterte mit `no space left on device`).
| Schritt | Beobachtung |
|---|---|
| Administrator -> SMTP | Feld "Fehlermeldungen an" mit Hinweistext vorhanden; Host `mailhog`, Port 1025, Keine Verschluesselung, Absender `tessera@tessera.local`, Empfaenger `fehler@example.invalid` gespeichert (DB: `bugReportRecipient = fehler@example.invalid`) |
| Kopfzeile | Knopf "Fehler melden" (aria-label) links neben dem Erscheinungsbild-Knopf; Seitenleiste unten: Abzeichen `dev · Entwicklung`, Tooltip `API dev (dev)` |
| Klick auf /admin/users | Dialog mit Hinweistext, Vorschau, Haekchen (an), Feld "Was ist passiert?", Abbrechen/Senden — die Vorschau zeigt die Seite OHNE den Dialog |
| Senden mit Beschreibung | "Vielen Dank, die Meldung wurde gesendet." |
| mailhog `GET /api/v2/messages` | 1 Nachricht, Von `tessera@tessera.local`, An `fehler@example.invalid`, Betreff `[Tessera Fehlermeldung] dev dev - /admin/users`; Text mit Beschreibung, Seite, Zeitpunkt (Server/Browser), Benutzer `admin`, Rolle, Mandant, Web `dev (dev)`, API `Tessera API dev (dev)`, Browser, Fenster `1920x949`, "Letzte Fehlermeldungen im Browser (0)" |
| Anhang | `fehlermeldung-20260914-1516.png`, 85619 Bytes, PNG-Signatur korrekt; Bild 1600 px breit, Farben der Seite korrekt (OKLCH), ohne Dialog |
| API-Protokoll | `Bug report email sent to fehler@example.invalid (transport: tenant)` und `Bug report from admin (tenant ...) sent to ... — page /admin/users, screenshot 85619 bytes` — ohne Beschreibung, ohne Bild |
| Gegenprobe ohne Empfaenger | `bugReportRecipient` auf NULL gesetzt, Senden -> "Fuer Fehlermeldungen ist noch kein Postfach eingerichtet." + Admin-Hinweis mit Link "Zu den SMTP-Einstellungen" (409-Pfad) |
| Drossel (429) | im Browser NICHT wiederholt (sechs Mails); durch die Spec gedeckt, die der Verifizierer unabhaengig falsifiziert hat (MAX_PER_WINDOW 5 -> 6 macht Test 3 rot) |
Nach der Probe: Empfaenger in der lokalen DB wieder gesetzt, Playwright-Artefakte entfernt, Arbeitsbaum unveraendert bis auf die Akten dieses Quick-Tasks.
@@ -0,0 +1,54 @@
---
created: 2026-09-14
title: Lizenzmodell — Betreiber gibt Modul je Server mit Lizenzanzahl frei, Firmenadmin lizenziert an bis zu N Benutzer
area: module-registry
severity: enhancement
trigger: ganz zum Schluss, erst wenn alle Module intern laufen — Entscheidung des Users 2026-08-11, bekraeftigt 2026-09-14. Nicht vorlegen, nicht ansprechen, bis der User es selbst nennt.
relates_to: 2026-08-11-modulaktivierung-ohne-lizenzpruefung.md
---
## Wunsch des Users (2026-09-14, in seinen Worten)
> "Ich gebe auf einem Server Modul X frei mit Lizenzanzahl z.B. 5. Das heisst,
> der Admin der Firma kann dann das Modul auf 5 Benutzer lizenzieren."
Also zwei Ebenen:
1. **Betreiber-Ebene (der User selbst):** Auf einer Installation ("einem
Server") wird ein Modul freigegeben, zusammen mit einer **Lizenzanzahl**
(Beispiel: 5). Ohne diese Freigabe ist das Modul auf dieser Installation
nicht aktivierbar.
2. **Firmen-Ebene (Admin der Firma):** Innerhalb der Freigabe darf der
Firmen-Admin das Modul an **bis zu N Benutzer** vergeben (N = die
Lizenzanzahl aus Ebene 1). Der sechste Benutzer bekommt es nicht.
Der User hat am selben Tag den Gedanken geaeussert, dass die Mandantenfaehigkeit
ganz entfallen koennte und stattdessen **je Kunde ein eigener Docker-Container**
laeuft. In diesem Bild ist "ein Server" = "eine Kundeninstallation", und die
Freigabe aus Ebene 1 gilt je Installation. Beides — ein Container je Kunde oder
mehrere Kunden in einer Installation — ist mit diesem Lizenzmodell vereinbar;
die Entscheidung dazu steht aus und wird NICHT von uns angestossen (siehe
Memory `feedback-mandantenfaehigkeit-nicht-ansprechen`).
## Was heute existiert
- `Module` (Katalog) und `TenantModuleActivation` (an/aus je Mandant):
"aktiviert" und "lizenziert" sind dasselbe, jeder Mandanten-Admin kann sich
jedes Modul selbst freischalten — siehe den verwandten Zettel vom 2026-08-11.
- Die Freigaben-Matrix (`module-grants.service.ts`) verteilt bereits innerhalb
des Aktivierten an Gruppen und einzelne Benutzer — das ist die natuerliche
Stelle fuer die Obergrenze N aus Ebene 2 (Zaehlung der direkt und ueber
Gruppen erreichten Benutzer gegen die Lizenzanzahl).
## Offen, bevor gebaut wird (Produktfragen, erst dann stellen)
- Wie kommt die Freigabe aus Ebene 1 technisch auf die Installation —
Lizenzdatei, Schluessel, Eingabe durch den Betreiber in einer
Betreiber-Ansicht, Online-Abgleich?
- Zaehlt die Lizenzanzahl benannte Benutzer (fest zugewiesen) oder gleichzeitig
aktive?
- Laufzeit ja/nein, und was passiert beim Ablauf.
- Zaehlen Freigaben ueber Gruppen (eine Gruppe mit 20 Mitgliedern) gegen die
Anzahl, und wie wird ein Ueberlauf gemeldet?
Solange Tessera nur intern laeuft, ist der Ist-Zustand folgenlos.
+13
View File
@@ -1,3 +1,11 @@
# Versionsstempel (quick-260914-ku1): die Werte setzt .gitea/scripts/publish-images.sh
# per --build-arg; lokal greifen die Vorgaben (dev). Ein globales ARG liefert nur die
# Vorgabe -- jede nutzende Stufe wiederholt deshalb `ARG NAME` ohne Wert.
ARG APP_VERSION=dev
ARG APP_CHANNEL=dev
ARG APP_COMMIT=
ARG APP_BUILD_TIME=
FROM node:24-alpine AS base FROM node:24-alpine AS base
RUN corepack enable && corepack prepare pnpm@9 --activate RUN corepack enable && corepack prepare pnpm@9 --activate
@@ -20,6 +28,11 @@ RUN pnpm --filter=@tessera/api build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ARG APP_VERSION
ARG APP_CHANNEL
ARG APP_COMMIT
ARG APP_BUILD_TIME
ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME
RUN addgroup --system --gid 1001 nestjs && \ RUN addgroup --system --gid 1001 nestjs && \
adduser --system --uid 1001 nestjs && \ adduser --system --uid 1001 nestjs && \
mkdir -p /app/user-files && \ mkdir -p /app/user-files && \
@@ -0,0 +1,18 @@
-- Fehler-melden-Knopf (quick-260914-m97): Anwender schicken aus der
-- Kopfzeile ein Bildschirmfoto der aktuellen Seite samt Beschreibung und
-- Kontext als E-Mail an ein Postfach, das der Administrator unter
-- Administrator -> SMTP im Feld "Fehlermeldungen an" festlegt.
--
-- Warum in "SmtpConfig" und nicht in einer eigenen Tabelle: der Empfaenger
-- gehoert zum Mailversand des Mandanten -- ohne gespeicherte
-- SMTP-Einstellungen gibt es ohnehin keinen Transport, ueber den die
-- Meldung rausgehen koennte. Die bestehende Zeilenschutz-Regel
-- "tenant_isolation_policy" auf "SmtpConfig" (Migration 20260909140000)
-- gilt fuer die neue Spalte automatisch mit. Eine Systemleseregel ist
-- nicht noetig: die Route POST /bug-reports laeuft immer mit einem
-- angemeldeten Benutzer, also mit gesetztem Mandantenkontext.
--
-- Additiv und nullbar: Bestandszeilen bleiben unangetastet (Spalte ist
-- fuer sie NULL = kein Postfach, der Betriebs-Rueckfall TESSERA_BUGREPORT_TO
-- greift). Keine bestehende Migration wurde angefasst.
ALTER TABLE "SmtpConfig" ADD COLUMN "bugReportRecipient" TEXT;
+1
View File
@@ -339,6 +339,7 @@ model SmtpConfig {
username String? username String?
encryptedPassword String? // AES-256-GCM via CalendarCryptoService encryptedPassword String? // AES-256-GCM via CalendarCryptoService
fromAddress String fromAddress String
bugReportRecipient String? // Postfach fuer den Fehler-melden-Knopf (quick-260914-m97); leer = Rueckfall TESSERA_BUGREPORT_TO
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
+2
View File
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BugReportsModule } from './bug-reports/bug-reports.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard'; import { RolesGuard } from './auth/guards/roles.guard';
import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor'; import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor';
@@ -47,6 +48,7 @@ import { UserModule } from './user/user.module';
DkvModule, DkvModule,
FavoritesModule, FavoritesModule,
TendersModule, TendersModule,
BugReportsModule,
], ],
providers: [ providers: [
// Global JWT guard: all routes require auth unless @Public() // Global JWT guard: all routes require auth unless @Public()
@@ -0,0 +1,66 @@
import 'reflect-metadata';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { BugReportsController } from './bug-reports.controller';
import { BugReportDto } from './dto/bug-report.dto';
/**
* BugReportsController.spec — NEU (quick-260914-m97, Fehler-melden-Knopf).
*
* Drei Tests an der Grenze Browser -> API:
* 1. die globale Pipe (`whitelist: true, transform: true`, wie in
* `main.ts`) entfernt Fremdfelder wie `tenantId` (T-M97-06) und
* normalisiert `errors` (multer/append-field liefert EIN Feld als
* String, mehrere als Array, keins als undefined);
* 2. die DTO-Grenzen greifen (31 Eintraege, 4001 Zeichen -> 400);
* 3. die Route steht JEDEM angemeldeten Benutzer offen — kein
* `@Roles`-Metadatum, Pfad `bug-reports`.
*/
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const meta = { type: 'body' as const, metatype: BugReportDto };
const baseBody = {
page: '/x',
webVersion: 'v1',
webChannel: 'beta',
webCommit: '',
userAgent: 'UA',
viewport: '1x1',
clientTime: 't',
};
describe('BugReportsController (quick-260914-m97)', () => {
it('Test 1: whitelist entfernt tenantId; errors wird aus String/undefined/Array normalisiert', async () => {
const single = (await pipe.transform({ ...baseBody, errors: 'einzeln', tenantId: 'fremd' }, meta)) as any;
expect(Object.prototype.hasOwnProperty.call(single, 'tenantId')).toBe(false);
expect(single.errors).toEqual(['einzeln']);
const none = (await pipe.transform({ ...baseBody }, meta)) as any;
expect(none.errors).toEqual([]);
const many = (await pipe.transform({ ...baseBody, errors: ['a', 'b'] }, meta)) as any;
expect(many.errors).toEqual(['a', 'b']);
});
it('Test 2: Grenzen — 31 Eintraege oder 4001 Zeichen -> BadRequestException; 30 Eintraege und 4000 Zeichen gelingen', async () => {
const thirtyOne = Array.from({ length: 31 }, (_, i) => `e${i}`);
await expect(pipe.transform({ ...baseBody, errors: thirtyOne }, meta)).rejects.toThrow(BadRequestException);
await expect(
pipe.transform({ ...baseBody, description: 'x'.repeat(4001) }, meta),
).rejects.toThrow(BadRequestException);
const ok = (await pipe.transform(
{ ...baseBody, errors: thirtyOne.slice(0, 30), description: 'x'.repeat(4000) },
meta,
)) as any;
expect(ok.errors).toHaveLength(30);
expect(ok.description).toHaveLength(4000);
});
it('Test 3: nur angemeldet — kein @Roles-Metadatum auf submit, Controller-Pfad bug-reports', () => {
expect(Reflect.getMetadata(ROLES_KEY, BugReportsController.prototype.submit)).toBeUndefined();
expect(Reflect.getMetadata('path', BugReportsController)).toBe('bug-reports');
});
});
@@ -0,0 +1,39 @@
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { BugReportsService } from './bug-reports.service';
import { BugReportDto } from './dto/bug-report.dto';
/**
* POST /bug-reports — Fehler-melden-Knopf (quick-260914-m97).
*
* Offen fuer ALLE angemeldeten Rollen: bewusst KEIN Rollen-Dekorator
* (Muster `user.controller.ts`, Selbstbedienungs-Avatar — `RolesGuard`
* laesst bei leerer Rollenliste durch, der globale `JwtAuthGuard` verlangt
* weiterhin eine Sitzung).
*
* Multipart mit Groessenlimit JE ROUTE (T-M97-03): `FileInterceptor` nimmt
* genau eine Datei `screenshot` bis 4 MiB entgegen; multers
* `LIMIT_FILE_SIZE` wird von Nest auf 413 abgebildet. `main.ts` bleibt
* ohne globales Body-Limit.
*
* Mandant und Benutzer kommen NUR aus dem Sitzungsnachweis
* (`@CurrentUser()`), nie aus dem Rumpf (T-M97-06) — das DTO kennt keine
* solchen Felder, die globale Pipe entfernt Fremdfelder.
*/
@Controller('bug-reports')
export class BugReportsController {
constructor(private readonly service: BugReportsService) {}
@Post()
@UseInterceptors(
FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }),
)
async submit(
@CurrentUser() user: any,
@Body() dto: BugReportDto,
@UploadedFile() file?: any,
) {
return this.service.submit(user, dto, file);
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { MailModule } from '../mail/mail.module';
import { SettingsModule } from '../settings/settings.module';
import { BugReportsController } from './bug-reports.controller';
import { BugReportsService } from './bug-reports.service';
/**
* BugReportsModule — Fehler-melden-Knopf (quick-260914-m97).
*
* Braucht `SettingsService` (Empfaenger des Mandanten) und `MailService`
* (Versand mit Anhang ueber den Transport des Mandanten); `PrismaModule`
* ist global, `ConfigModule` ebenfalls.
*/
@Module({
imports: [SettingsModule, MailModule],
controllers: [BugReportsController],
providers: [BugReportsService],
})
export class BugReportsModule {}
@@ -0,0 +1,294 @@
import {
BadGatewayException,
BadRequestException,
ConflictException,
HttpException,
} from '@nestjs/common';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { BugReportsService } from './bug-reports.service';
/**
* BugReportsService.spec — NEU (quick-260914-m97, Fehler-melden-Knopf).
*
* Acht Tests, darunter die vier Falsifizierungen des Plans:
* (a) Drossel: der sechste Bericht in zehn Minuten -> 429, nach dem
* Fenster (Fake-Timer) wieder durch;
* (b) manipulierte Bilddatei ohne PNG-Kopf -> 400, nie versendet;
* (c) weder Feld noch Umgebungsvariable -> 409, Leerstring zaehlt als
* ungesetzt, nie versendet;
* (d) Fremdfelder im Rumpf (tenantId/userId) aendern NICHTS an der
* Mandantenkennung — Empfaenger, Benutzerzeile und Versand laufen
* ausschliesslich mit der Kennung aus dem Sitzungsnachweis.
*
* `forTenant` wird wie in `user.controller.spec.ts` durch einen gebundenen
* Fake-Klienten ersetzt, der nur Zeilen des eigenen Mandanten liefert;
* `nodemailer` kommt hier nicht vor — der Versand ist eine Attrappe von
* `MailService.sendBugReport`, dessen Verhalten `mail.service.spec.ts` pinnt.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
const PNG_1x1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
const sessionUser = { id: 'u1', username: 'anna', role: 'USER', tenantId: 't1' };
const baseDto = {
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}'],
};
interface FakeUserRow {
id: string;
tenantId: string;
username: string;
displayName: string | null;
email: string | null;
role: string;
}
function makeFakePrisma(rows: FakeUserRow[]) {
const users = new Map(rows.map((r) => [`${r.tenantId}/${r.id}`, { ...r }]));
const boundCalls: { tenantId: string; method: string }[] = [];
return {
__boundCalls: boundCalls,
__makeBoundClient(tenantId: string) {
return {
user: {
findUnique: async ({ where, select }: any) => {
boundCalls.push({ tenantId, method: 'findUnique' });
const row = users.get(`${tenantId}/${where.id}`);
if (!row) return null;
const out: any = {};
for (const key of Object.keys(select ?? {})) if (select[key]) out[key] = (row as any)[key];
return out;
},
},
};
},
};
}
function makeService(opts: {
recipient?: string | null;
env?: string | undefined;
rows?: FakeUserRow[];
sendImpl?: () => Promise<void>;
}) {
const prisma = makeFakePrisma(
opts.rows ?? [
{ id: 'u1', tenantId: 't1', username: 'anna', displayName: 'Anna Muster', email: 'anna@a.example.invalid', role: 'USER' },
],
);
const settingsService = {
getBugReportRecipient: vi.fn(async () => (opts.recipient === undefined ? 'fehler@a.example.invalid' : opts.recipient)),
};
const mailService = {
sendBugReport: vi.fn(opts.sendImpl ?? (async () => undefined)),
};
const configService = {
get: vi.fn((key: string) => (key === 'TESSERA_BUGREPORT_TO' ? opts.env : undefined)),
};
const service = new BugReportsService(
settingsService as any,
mailService as any,
configService as any,
prisma as any,
);
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
return { service, prisma, settingsService, mailService, configService };
}
const pngFile = () => ({ buffer: PNG_1x1, size: PNG_1x1.length, mimetype: 'image/png' });
beforeEach(() => {
vi.stubEnv('APP_VERSION', 'v9.9.9');
vi.stubEnv('APP_CHANNEL', 'live');
vi.stubEnv('APP_COMMIT', '');
});
afterEach(() => {
vi.unstubAllEnvs();
vi.mocked(forTenant).mockClear();
});
describe('BugReportsService (quick-260914-m97)', () => {
it('Test 1: Happy Path mit Bild — Betreff, Textrumpf mit allen Kontextzeilen aus Sitzung, Datenbankzeile und Umgebung, PNG-Anhang unveraendert, { sent: true }', async () => {
const { service, mailService } = makeService({});
const result = await service.submit(sessionUser, baseDto as any, pngFile());
expect(result).toEqual({ sent: true });
expect(mailService.sendBugReport).toHaveBeenCalledTimes(1);
const [tenantId, to, report] = mailService.sendBugReport.mock.calls[0] as any[];
expect(tenantId).toBe('t1');
expect(to).toBe('fehler@a.example.invalid');
expect(report.subject).toBe('[Tessera Fehlermeldung] v1.2.3 beta - /admin/users?tab=x');
for (const needle of [
'Knopf tut nichts',
'/admin/users?tab=x',
'Anna Muster (anna)',
'USER',
'anna@a.example.invalid',
't1',
'v1.2.3 (beta) abc1234',
'Tessera API v9.9.9 (live)',
'UA',
'1920x1080',
'[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}',
'Bildschirmfoto: im Anhang',
]) {
expect(report.text, `Text ohne "${needle}"`).toContain(needle);
}
expect(report.attachments).toHaveLength(1);
expect(report.attachments[0].filename).toMatch(/^fehlermeldung-\d{8}-\d{4}\.png$/);
expect(report.attachments[0].contentType).toBe('image/png');
expect(report.attachments[0].content.equals(PNG_1x1)).toBe(true);
});
it('Test 2: ohne Bild -> leere Anhangsliste, Text nennt "nicht beigefügt"; ohne Beschreibung steht "(keine Beschreibung)"', async () => {
const { service, mailService } = makeService({});
await service.submit(sessionUser, { ...baseDto, description: undefined } as any, undefined);
const report = (mailService.sendBugReport.mock.calls[0] as any[])[2];
expect(Array.isArray(report.attachments)).toBe(true);
expect(report.attachments).toHaveLength(0);
expect(report.text).toContain('Bildschirmfoto: nicht beigefügt');
expect(report.text).toContain('(keine Beschreibung)');
});
it('Test 3 (Falsifizierung a): fuenf Berichte gelingen, der sechste -> 429; anderer Benutzer gleichzeitig frei; nach 10 Minuten wieder frei', async () => {
vi.useFakeTimers();
try {
const { service, mailService } = makeService({});
for (let i = 0; i < 5; i++) {
await service.submit(sessionUser, baseDto as any, undefined);
}
let caught: unknown;
try {
await service.submit(sessionUser, baseDto as any, undefined);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HttpException);
expect((caught as HttpException).getStatus()).toBe(429);
expect(mailService.sendBugReport).toHaveBeenCalledTimes(5);
await expect(
service.submit({ ...sessionUser, id: 'u2', username: 'bert' }, baseDto as any, undefined),
).resolves.toEqual({ sent: true });
expect(mailService.sendBugReport).toHaveBeenCalledTimes(6);
vi.advanceTimersByTime(600_001);
await expect(service.submit(sessionUser, baseDto as any, undefined)).resolves.toEqual({ sent: true });
expect(mailService.sendBugReport).toHaveBeenCalledTimes(7);
} finally {
vi.useRealTimers();
}
});
it('Test 4 (Falsifizierung b): Datei ohne PNG-Kopf -> 400, sendBugReport nie gerufen; auch ein Buffer aus nur 7 PNG-Bytes -> 400', async () => {
const { service, mailService } = makeService({});
const fake = Buffer.from('nicht png, aber lang genug');
await expect(
service.submit(sessionUser, baseDto as any, { buffer: fake, size: fake.length, mimetype: 'image/png' }),
).rejects.toBeInstanceOf(BadRequestException);
const short = PNG_1x1.subarray(0, 7);
await expect(
service.submit(sessionUser, baseDto as any, { buffer: short, size: short.length, mimetype: 'image/png' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(mailService.sendBugReport).not.toHaveBeenCalled();
});
it('Test 5 (Falsifizierung c): weder Feld noch Variable -> 409 mit Hinweis auf "Fehlermeldungen an"; Leerstring in der Variable zaehlt als ungesetzt; nie versendet', async () => {
const a = makeService({ recipient: null, env: undefined });
let caught: unknown;
try {
await a.service.submit(sessionUser, baseDto as any, pngFile());
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ConflictException);
expect((caught as ConflictException).message).toContain('Fehlermeldungen an');
expect(a.mailService.sendBugReport).not.toHaveBeenCalled();
const b = makeService({ recipient: null, env: '' });
await expect(b.service.submit(sessionUser, baseDto as any, pngFile())).rejects.toBeInstanceOf(ConflictException);
expect(b.mailService.sendBugReport).not.toHaveBeenCalled();
});
it('Test 6: Umgebungs-Rueckfall TESSERA_BUGREPORT_TO greift ohne Feld; mit Feld UND Variable gewinnt das Feld', async () => {
const a = makeService({ recipient: null, env: 'ops@a.example.invalid' });
await a.service.submit(sessionUser, baseDto as any, undefined);
expect((a.mailService.sendBugReport.mock.calls[0] as any[])[1]).toBe('ops@a.example.invalid');
const b = makeService({ recipient: 'fehler@a.example.invalid', env: 'ops@a.example.invalid' });
await b.service.submit(sessionUser, baseDto as any, undefined);
expect((b.mailService.sendBugReport.mock.calls[0] as any[])[1]).toBe('fehler@a.example.invalid');
});
it('Test 7: Versandfehler -> 502 "E-Mail konnte nicht gesendet werden"; der Versuch zaehlt in der Drossel, sperrt aber nicht', async () => {
let calls = 0;
const { service, mailService } = makeService({
sendImpl: async () => {
calls += 1;
if (calls === 1) throw new Error('ECONNREFUSED');
},
});
let caught: unknown;
try {
await service.submit(sessionUser, baseDto as any, pngFile());
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(BadGatewayException);
expect((caught as BadGatewayException).message).toContain('E-Mail konnte nicht gesendet werden');
await expect(service.submit(sessionUser, baseDto as any, pngFile())).resolves.toEqual({ sent: true });
expect(mailService.sendBugReport).toHaveBeenCalledTimes(2);
});
it('Test 8 (Falsifizierung d): Fremdfelder tenantId/userId im Rumpf aendern nichts — Empfaenger, forTenant und Versand laufen mit der Sitzungskennung t1, die fremde Zeile taucht nicht auf', async () => {
const { service, settingsService, mailService } = makeService({
rows: [
{ id: 'u1', tenantId: 't1', username: 'anna', displayName: 'Anna Muster', email: 'anna@a.example.invalid', role: 'USER' },
{ id: 'u1', tenantId: 'fremd', username: 'anna', displayName: 'Fremde Anna', email: 'fremd@x.invalid', role: 'ADMIN' },
{ id: 'u-fremd', tenantId: 'fremd', username: 'eindringling', displayName: 'Eindringling', email: 'e@x.invalid', role: 'ADMIN' },
],
});
await service.submit(
sessionUser,
{ ...baseDto, tenantId: 'fremd', userId: 'u-fremd' } as any,
undefined,
);
expect(settingsService.getBugReportRecipient).toHaveBeenCalledWith('t1');
expect(vi.mocked(forTenant).mock.calls.every((c) => c[1] === 't1')).toBe(true);
expect(vi.mocked(forTenant).mock.calls.length).toBeGreaterThan(0);
const [tenantId, , report] = mailService.sendBugReport.mock.calls[0] as any[];
expect(tenantId).toBe('t1');
expect(report.text).toContain('Anna Muster (anna)');
expect(report.text).not.toContain('Fremde Anna');
expect(report.text).not.toContain('Eindringling');
expect(report.text).not.toContain('fremd@x.invalid');
});
});
@@ -0,0 +1,178 @@
import {
BadGatewayException,
BadRequestException,
ConflictException,
HttpException,
HttpStatus,
Injectable,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { formatAppVersionLine } from '../health/app-version';
import { MailService } from '../mail/mail.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { SettingsService } from '../settings/settings.service';
import { BugReportDto } from './dto/bug-report.dto';
/**
* BugReportsService — Fehler-melden-Knopf (quick-260914-m97).
*
* Zweck: ein angemeldeter Anwender schickt aus der Kopfzeile ein
* Bildschirmfoto der aktuellen Seite samt Beschreibung und Kontext; der
* Dienst baut daraus EINE E-Mail mit PNG-Anhang und verschickt sie ueber
* den Transport des Sitzungs-Mandanten an das eingestellte Postfach
* (`SmtpConfig.bugReportRecipient`, Rueckfall `TESSERA_BUGREPORT_TO`).
*
* Warum Multipart (Controller) statt JSON mit Base64: das Groessenlimit
* gilt dann NUR fuer diese Route (`FileInterceptor`, 4 MiB), `main.ts`
* bleibt ohne globales Body-Limit — ein globales JSON-Limit waere eine
* DoS-Flaeche fuer jede Route inklusive `/auth/login` (T-M97-03).
*
* Warum kein Speichern: die Meldung ist eine E-Mail an den Betreiber,
* nichts weiter. Tessera legt keine Tabelle dafuer an — kein Bild, keine
* Beschreibung landet in der Datenbank oder im Protokoll (T-M97-01).
*
* Drossel-Semantik: hoechstens 5 Berichte je Benutzer je 10 Minuten,
* gezaehlt im Speicher dieses Prozesses (keine Drossel-Bibliothek im
* Projekt). Ein Versuch zaehlt auch dann, wenn der Versand danach
* scheitert — Fehlversuche sperren nicht zusaetzlich, sie zaehlen nur.
*
* Sicherheit: T-M97-03 (Limit je Route + Drossel), T-M97-04
* (PNG-Signatur, fester Dateiname und Typ), T-M97-06 (Mandant und
* Benutzer ausschliesslich aus dem Sitzungsnachweis, Benutzerzeile ueber
* einen gebundenen Klienten — Zeile in
* docs/mandantentrennung-zugriffsklassifikation.md).
*/
const WINDOW_MS = 10 * 60 * 1000;
const MAX_PER_WINDOW = 5;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
interface SessionUser {
id: string;
username: string;
role: string;
tenantId: string;
}
interface UploadedPng {
buffer: Buffer;
size: number;
mimetype?: string;
}
@Injectable()
export class BugReportsService {
private readonly logger = new Logger(BugReportsService.name);
/** Zeitstempel der letzten Berichte je Benutzerkennung (Drossel). */
private readonly recent = new Map<string, number[]>();
constructor(
private readonly settingsService: SettingsService,
private readonly mailService: MailService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {}
async submit(user: SessionUser, dto: BugReportDto, file?: UploadedPng): Promise<{ sent: true }> {
// (1) Drossel: alte Zeitstempel verwerfen, Grenze pruefen, Versuch zaehlen.
const now = Date.now();
const stamps = (this.recent.get(user.id) ?? []).filter((t) => now - t < WINDOW_MS);
if (stamps.length >= MAX_PER_WINDOW) {
this.recent.set(user.id, stamps);
throw new HttpException(
'Zu viele Fehlermeldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
stamps.push(now);
this.recent.set(user.id, stamps);
// (2) Bild pruefen: nur echte PNG-Dateien (T-M97-04).
if (file) {
if (file.buffer.length < PNG_SIGNATURE.length || !file.buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
throw new BadRequestException('Das Bildschirmfoto ist keine gültige PNG-Datei.');
}
}
// (3) Empfaenger: Feld des Mandanten, sonst Umgebungs-Rueckfall (Leerstring = ungesetzt).
const to =
(await this.settingsService.getBugReportRecipient(user.tenantId)) ||
(this.configService.get<string>('TESSERA_BUGREPORT_TO') || '').trim() ||
null;
if (!to) {
throw new ConflictException(
'Für Fehlermeldungen ist noch kein Postfach eingerichtet. Ein Administrator legt es unter Administrator → SMTP im Feld „Fehlermeldungen an“ fest.',
);
}
// (4) Benutzerzeile: gebunden an den Sitzungs-Mandanten, nie an Rumpfdaten
// (T-M97-06; Zeile in docs/mandantentrennung-zugriffsklassifikation.md).
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
const row = await tenantPrisma.user.findUnique({
where: { id: user.id },
select: { username: true, displayName: true, email: true, role: true },
});
const username: string = row?.username ?? user.username;
const displayName: string = row?.displayName || username;
const email: string = row?.email ?? '-';
const role: string = row?.role ?? user.role;
// (5) Betreff
const pageShort = dto.page.slice(0, 120);
const subject = `[Tessera Fehlermeldung] ${dto.webVersion} ${dto.webChannel} - ${pageShort}`;
// (6) Text
const bytes = file ? file.buffer.length : 0;
const errors = dto.errors ?? [];
const text = [
'Ein Anwender hat über den Knopf „Fehler melden“ eine Meldung geschickt.',
'',
'Was ist passiert?',
dto.description && dto.description.trim().length > 0 ? dto.description : '(keine Beschreibung)',
'',
`Seite: ${dto.page}`,
`Zeitpunkt (Server): ${new Date().toISOString()}`,
`Zeitpunkt (Browser): ${dto.clientTime}`,
`Benutzer: ${displayName} (${username}), Rolle ${role}, E-Mail ${email}`,
`Mandant: ${user.tenantId}`,
`Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(),
`API: ${formatAppVersionLine()}`,
`Browser: ${dto.userAgent}`,
`Fenster: ${dto.viewport}`,
'',
`Letzte Fehlermeldungen im Browser (${errors.length}):`,
...(errors.length > 0 ? errors.map((e) => `- ${e}`) : ['- keine']),
'',
file ? `Bildschirmfoto: im Anhang (${bytes} Bytes)` : 'Bildschirmfoto: nicht beigefügt',
].join('\n');
// (7) Anhang: fester Name und Typ — der Client bestimmt beides nicht (T-M97-04).
const attachments = file
? [{ filename: `fehlermeldung-${formatStamp(new Date())}.png`, content: file.buffer, contentType: 'image/png' }]
: [];
// (8) Versand: Fehler sichtbar machen (502), nie still verschlucken.
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 Protokollzeile — nie Beschreibung, nie Bild (T-M97-07).
this.logger.log(
`Bug report from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`,
);
return { sent: true };
}
}
/** `yyyymmdd-hhmm` in UTC fuer den Anhangsnamen. */
function formatStamp(d: Date): string {
const p = (n: number, w = 2) => String(n).padStart(w, '0');
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}`;
}
@@ -0,0 +1,80 @@
import { Expose, Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
/**
* Rumpf von `POST /bug-reports` (quick-260914-m97, Fehler-melden-Knopf).
*
* Die Felder kommen als `multipart/form-data` (das Bild liegt als Datei
* `screenshot` daneben, siehe Controller) — multer liefert deshalb alle
* Textfelder als Strings. Ein EINZELNES wiederholtes Feld `errors` kommt
* als String, mehrere als Array, keines als undefined (append-field,
* gemessen zur Planungszeit); ohne die Normalisierung unten wuerde
* `@IsArray()` bei genau einer Fehlermeldung scheitern.
*
* Mandant und Benutzer stehen BEWUSST NICHT in diesem DTO (T-M97-06): der
* Dienst nimmt beides ausschliesslich aus dem Sitzungsnachweis
* (`@CurrentUser()`), und `whitelist: true` der globalen ValidationPipe
* entfernt jedes Fremdfeld, das ein Client hier trotzdem mitschickt.
*/
export class BugReportDto {
/** Freitext „Was ist passiert?“ — optional, hoechstens 4000 Zeichen. */
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string;
/** Pfad plus Suchteil der Seite, ohne Host. */
@IsString()
@MaxLength(2000)
page!: string;
@IsString()
@MaxLength(100)
webVersion!: string;
@IsString()
@MaxLength(20)
webChannel!: string;
/** Kurzer Commit-Hash; Leerstring ist erlaubt (lokaler Bau ohne Stempel). */
@IsString()
@MaxLength(64)
webCommit!: string;
@IsString()
@MaxLength(1000)
userAgent!: string;
/** `<Breite>x<Hoehe>` des Browserfensters. */
@IsString()
@MaxLength(50)
viewport!: string;
/** ISO-Zeitstempel des Browsers zum Sendezeitpunkt. */
@IsString()
@MaxLength(50)
clientTime!: string;
/**
* Die letzten Fehlermeldungen aus dem Browser-Ringpuffer, je
* `[<ISO>] <Art>: <Meldung>`. `@Expose()` sorgt dafuer, dass die
* Normalisierung auch laeuft, wenn das Feld im Rumpf GANZ fehlt
* (class-transformer ruft `@Transform` sonst nur fuer vorhandene
* Schluessel auf — gemessen: ohne `@Expose()` scheitert `@IsArray()`).
*/
@Expose()
@Transform(({ value }) =>
value === undefined || value === null ? [] : Array.isArray(value) ? value : [value],
)
@IsArray()
@ArrayMaxSize(30)
@IsString({ each: true })
@MaxLength(1000, { each: true })
errors!: string[];
}
+32
View File
@@ -0,0 +1,32 @@
import type { VersionResponse } from '@tessera/shared';
/**
* Versionsstempel der API (quick-260914-ku1).
*
* Woher die Werte kommen: das CI-Skript `.gitea/scripts/publish-images.sh`
* berechnet `APP_VERSION` (`git describe --tags --always`), `APP_CHANNEL`
* (`beta` fuer main, `live` fuer Tags v*), `APP_COMMIT` und `APP_BUILD_TIME`
* und uebergibt sie als `--build-arg`; die Runner-Stufe beider Dockerfiles
* setzt sie als `ENV`, sodass sie hier zur Laufzeit lesbar sind. Lokal ohne
* Build-Args greifen die Vorgaben `dev`/`dev`/``/``.
*
* Warum `||` statt `??`: Docker Compose reicht unbelegte Variablen als
* Leerstring weiter — ein leerer Wert muss wie ein fehlender zaehlen.
*
* `main.ts` protokolliert `formatAppVersionLine()` beim Start direkt nach der
* Port-Zeile, `HealthController.getVersion()` liefert `getAppVersion()`.
*/
export function getAppVersion(): VersionResponse {
return {
name: 'tessera',
version: process.env.APP_VERSION || 'dev',
channel: process.env.APP_CHANNEL || 'dev',
commit: process.env.APP_COMMIT || '',
buildTime: process.env.APP_BUILD_TIME || '',
};
}
export function formatAppVersionLine(v: VersionResponse = getAppVersion()): string {
const base = `Tessera API ${v.version} (${v.channel})`;
return v.commit ? `${base} ${v.commit}` : base;
}
@@ -0,0 +1,98 @@
import 'reflect-metadata';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator';
import { formatAppVersionLine } from './app-version';
import { HealthController } from './health.controller';
/**
* HealthController.spec — legt die Testlage fuer den Health-Bereich aus dem
* Nichts an (quick-260914-ku1, Aufgabe 1: vorher gab es keinen Spec).
*
* Gegenstand ist der Versionsstempel: `GET /health/version` liest
* `APP_VERSION`, `APP_CHANNEL`, `APP_COMMIT`, `APP_BUILD_TIME` aus der
* Laufzeit-Umgebung (gesetzt als `ENV` in der Runner-Stufe beider
* Dockerfiles, befuellt vom CI-Skript `.gitea/scripts/publish-images.sh`).
* Leere Zeichenketten zaehlen wie ungesetzt, weil Docker Compose unbelegte
* Variablen als Leerstring weiterreicht (siehe `migrate-and-start.sh`).
*
* Der Controller hat keine Abhaengigkeiten und wird direkt instanziiert.
*/
const ALL_VARS = ['APP_VERSION', 'APP_CHANNEL', 'APP_COMMIT', 'APP_BUILD_TIME'] as const;
afterEach(() => {
vi.unstubAllEnvs();
});
function stubAll(value: string) {
for (const name of ALL_VARS) {
vi.stubEnv(name, value);
}
}
function stubSample() {
vi.stubEnv('APP_VERSION', 'v1.2.3');
vi.stubEnv('APP_CHANNEL', 'live');
vi.stubEnv('APP_COMMIT', 'abc1234');
vi.stubEnv('APP_BUILD_TIME', '2026-09-14T12:00:00Z');
}
describe('HealthController — Versionsstempel (quick-260914-ku1)', () => {
it('Test 1 (check): liefert status ok und einen Zeitstempel, den Date.parse versteht', () => {
const controller = new HealthController();
const result = controller.check();
expect(result.status).toBe('ok');
expect(Number.isNaN(Date.parse(result.timestamp))).toBe(false);
});
it('Test 2 (Vorgaben): ohne die vier Variablen liefert getVersion() dev/dev und leere Kennungen', () => {
for (const name of ALL_VARS) {
vi.stubEnv(name, undefined);
}
const controller = new HealthController();
expect(controller.getVersion()).toEqual({
name: 'tessera',
version: 'dev',
channel: 'dev',
commit: '',
buildTime: '',
});
});
it('Test 3 (durchgereicht): die vier Variablen kommen woertlich an, name bleibt tessera', () => {
stubSample();
const controller = new HealthController();
expect(controller.getVersion()).toEqual({
name: 'tessera',
version: 'v1.2.3',
channel: 'live',
commit: 'abc1234',
buildTime: '2026-09-14T12:00:00Z',
});
});
it('Test 4 (Compose-Semantik): leere Zeichenketten zaehlen wie ungesetzt', () => {
stubAll('');
const controller = new HealthController();
expect(controller.getVersion()).toEqual({
name: 'tessera',
version: 'dev',
channel: 'dev',
commit: '',
buildTime: '',
});
});
it('Test 5 (formatAppVersionLine): mit Commit angehaengt, ohne Commit kein Leerzeichen am Ende', () => {
stubSample();
expect(formatAppVersionLine()).toBe('Tessera API v1.2.3 (live) abc1234');
stubAll('');
expect(formatAppVersionLine()).toBe('Tessera API dev (dev)');
});
it('Test 6 (bewusst oeffentlich, T-KU1-03): getVersion und check tragen @Public()', () => {
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.getVersion)).toBe(true);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, HealthController.prototype.check)).toBe(true);
});
});
+7 -6
View File
@@ -1,6 +1,7 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import type { HealthResponse } from '@tessera/shared'; import type { HealthResponse, VersionResponse } from '@tessera/shared';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { getAppVersion } from './app-version';
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
@@ -13,12 +14,12 @@ export class HealthController {
}; };
} }
// Bewusst oeffentlich (T-KU1-03): Betreiber-Kontrolle per `curl` auf dem
// Server ohne Anmeldung. Es werden keine Versionen von Systemkomponenten
// preisgegeben; das Repository ist privat. Gepinnt durch Spec-Test 6.
@Public() @Public()
@Get('version') @Get('version')
getVersion() { getVersion(): VersionResponse {
return { return getAppVersion();
version: process.env.npm_package_version ?? '0.0.1',
name: 'tessera',
};
} }
} }
+52 -1
View File
@@ -18,7 +18,14 @@ import { MailService } from './mail.service';
* protokolliert, `close()` trotzdem gerufen. * protokolliert, `close()` trotzdem gerufen.
* *
* `nodemailer` wird per `vi.mock` ersetzt (wie in settings.service.spec.ts) * `nodemailer` wird per `vi.mock` ersetzt (wie in settings.service.spec.ts)
* — kein echter Transport, lokal gibt es keinen `mailhog`. * — kein echter Transport, der lokale `mailhog` aus docker-compose.dev.yml
* ist nur fuer den Browser-Check gedacht.
*
* Erweitert in quick-260914-m97 (Fehler-melden-Knopf): Tests 5 und 6 pinnen
* `sendBugReport` — Anhaenge werden 1:1 an `sendMail` durchgereicht, und
* Fehler gehen bewusst NACH AUSSEN (der Anwender soll wissen, ob sein
* Bericht ankam), waehrend `sendPasswordResetEmail` weiterhin verschluckt
* (T-02-12 unveraendert, Gegenprobe im selben Test).
*/ */
let mockSendMail = vi.fn(async (_mail: unknown) => ({})); let mockSendMail = vi.fn(async (_mail: unknown) => ({}));
@@ -196,4 +203,48 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
expect(JSON.stringify(errorSpy.mock.calls[0])).not.toContain('geheim-a'); expect(JSON.stringify(errorSpy.mock.calls[0])).not.toContain('geheim-a');
expect(mockClose).toHaveBeenCalledTimes(1); expect(mockClose).toHaveBeenCalledTimes(1);
}); });
it('Test 5 (260914-m97): sendBugReport reicht to/subject/text und den PNG-Anhang unveraendert an sendMail durch, from = fromAddress des Mandanten, close() gerufen', async () => {
const settings = makeFakeSettings({ t1: configA });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
const png = Buffer.from([1, 2, 3]);
await service.sendBugReport('t1', 'fehler@a.example.invalid', {
subject: 'S',
text: 'T',
attachments: [{ filename: 'x.png', content: png, contentType: 'image/png' }],
});
expect(mockSendMail).toHaveBeenCalledTimes(1);
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.to).toBe('fehler@a.example.invalid');
expect(sent.subject).toBe('S');
expect(sent.text).toBe('T');
expect(sent.attachments).toHaveLength(1);
expect(sent.attachments[0].filename).toBe('x.png');
expect(sent.attachments[0].contentType).toBe('image/png');
expect(Buffer.isBuffer(sent.attachments[0].content)).toBe(true);
expect((sent.attachments[0].content as Buffer).equals(png)).toBe(true);
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('Test 6 (260914-m97): sendBugReport laesst Transportfehler DURCH (rejects), close() trotzdem; Gegenprobe: sendPasswordResetEmail verschluckt denselben Fehler weiterhin (T-02-12)', async () => {
mockSendMail = vi.fn(async () => {
throw new Error('ECONNREFUSED smtp-a.example.invalid');
});
const settings = makeFakeSettings({ t1: configA });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
const errorSpy = vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
await expect(
service.sendBugReport('t1', 'fehler@a.example.invalid', { subject: 'S', text: 'T', attachments: [] }),
).rejects.toThrow('ECONNREFUSED');
expect(mockClose).toHaveBeenCalledTimes(1);
await expect(
service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'),
).resolves.toBeUndefined();
expect(mockClose).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalled();
});
}); });
+63 -11
View File
@@ -37,11 +37,36 @@ import { SettingsService } from '../settings/settings.service';
* der Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration). * der Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration).
* *
* Sicherheit: das entschluesselte Kennwort existiert nur im Rumpf von * Sicherheit: das entschluesselte Kennwort existiert nur im Rumpf von
* `resolveTransport`/`sendViaTenantTransport` und wird nie protokolliert * `resolveTransport`/`deliver` und wird nie protokolliert
* (T-07-10/T-07-11); Protokollzeilen nennen nur Quelle (tenant/env) und * (T-07-10/T-07-11); Protokollzeilen nennen nur Quelle (tenant/env) und
* Empfaenger. * Empfaenger.
*
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
*/ */
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
export interface OutgoingAttachment {
filename: string;
content: Buffer;
contentType: string;
}
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
export interface OutgoingMail {
to: string;
subject: string;
text: string;
html?: string;
attachments?: OutgoingAttachment[];
}
/** Was `BugReportsService` liefert — Empfaenger und Mandant kommen getrennt. */
export type BugReportMail = Pick<OutgoingMail, 'subject' | 'text' | 'attachments'>;
interface ResolvedTransport { interface ResolvedTransport {
source: 'tenant' | 'env'; source: 'tenant' | 'env';
options: nodemailer.TransportOptions & Record<string, unknown>; options: nodemailer.TransportOptions & Record<string, unknown>;
@@ -128,15 +153,12 @@ export class MailService {
} }
/** /**
* Der eine Versandpfad: Transport je Versand aus `resolveTransport`, * Der eine Versandkern: Transport je Versand aus `resolveTransport`,
* Fehler verschluckt und protokolliert (T-02-12), `close()` im `finally` * `sendMail` mit optionalem HTML und Anhaengen, `close()` im `finally`
* (WR-01 — keine offenen Verbindungen). * (WR-01 — keine offenen Verbindungen). WIRFT bei Transportfehlern —
* ob der Fehler nach aussen geht, entscheidet der Aufrufer.
*/ */
private async sendViaTenantTransport( private async deliver(tenantId: string, mail: OutgoingMail, kind: string): Promise<void> {
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
let transport: nodemailer.Transporter | null = null; let transport: nodemailer.Transporter | null = null;
try { try {
const resolved = await this.resolveTransport(tenantId); const resolved = await this.resolveTransport(tenantId);
@@ -146,19 +168,49 @@ export class MailService {
to: mail.to, to: mail.to,
subject: mail.subject, subject: mail.subject,
text: mail.text, text: mail.text,
...(mail.html !== undefined ? { html: mail.html } : {}),
...(mail.attachments !== undefined ? { attachments: mail.attachments } : {}),
}); });
this.logger.log(`${kind} email sent to ${mail.to} (transport: ${resolved.source})`); this.logger.log(`${kind} email sent to ${mail.to} (transport: ${resolved.source})`);
} finally {
transport?.close();
}
}
/**
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
* bleibt fuer genau diese beiden Wege bestehen).
*/
private async sendViaTenantTransport(
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
try {
await this.deliver(tenantId, mail, kind);
} catch (error) { } catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12) // Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error( this.logger.error(
`Failed to send ${kind} email to ${mail.to}`, `Failed to send ${kind} email to ${mail.to}`,
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
} finally {
transport?.close();
} }
} }
/**
* Fehlermeldung eines Anwenders (quick-260914-m97) mit PNG-Anhang an das
* eingestellte Postfach des Mandanten. Fehler gehen BEWUSST nach aussen —
* anders als bei T-02-12: hier gibt es nichts zu verbergen (kein
* Anmeldeweg, kein Enumerationsrisiko), und der Anwender soll wissen, ob
* sein Bericht angekommen ist. `BugReportsService` uebersetzt den Fehler
* in eine 502-Antwort.
*/
async sendBugReport(tenantId: string, to: string, report: BugReportMail): Promise<void> {
await this.deliver(tenantId, { to, ...report }, 'Bug report');
}
/** /**
* Send a password reset email with a time-limited token link. * Send a password reset email with a time-limited token link.
* T-02-12: The caller always returns 200 regardless of whether this succeeds * T-02-12: The caller always returns 200 regardless of whether this succeeds
+2
View File
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { formatAppVersionLine } from './health/app-version';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@@ -31,6 +32,7 @@ async function bootstrap() {
await app.listen(3001); await app.listen(3001);
console.log('Tessera API running on port 3001'); console.log('Tessera API running on port 3001');
console.log(formatAppVersionLine());
} }
bootstrap(); bootstrap();
@@ -48,4 +48,15 @@ export class SmtpConfigDto {
@IsOptional() @IsOptional()
@IsEmail() @IsEmail()
testTo?: string; testTo?: string;
/**
* Postfach fuer den Fehler-melden-Knopf (quick-260914-m97). Optional:
* fehlt das Feld im PUT, bleibt der gespeicherte Wert; `null` loescht ihn.
* Absicht (T-M97-05): nur ein Administrator dieses Mandanten kann das
* Ziel aller Fehlermeldungen seines Mandanten setzen — der Weg fuehrt
* ausschliesslich ueber `PUT /settings/smtp` mit `@Roles(ADMIN, SUPER_ADMIN)`.
*/
@IsOptional()
@IsEmail()
bugReportRecipient?: string | null;
} }
@@ -40,6 +40,7 @@ interface FakeSmtpRow {
username: string | null; username: string | null;
encryptedPassword: string | null; encryptedPassword: string | null;
fromAddress: string; fromAddress: string;
bugReportRecipient?: string | null;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
} }
@@ -483,4 +484,71 @@ describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => {
expect(vi.mocked(forTenant).mock.calls.length).toBe(0); expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
}); });
}); });
describe('bugReportRecipient — Postfach fuer den Fehler-melden-Knopf (quick-260914-m97)', () => {
const rowWithRecipient: FakeSmtpRow = {
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-a',
encryptedPassword: 'enc(geheim)',
fromAddress: 'a@example.invalid',
bugReportRecipient: 'fehler@a.example.invalid',
};
it('Test A: getBugReportRecipient liefert den gespeicherten Wert ueber GENAU EINEN gebundenen findUnique; fremder Mandant oder null-Feld -> null', async () => {
const prisma = makeFakePrisma([
rowWithRecipient,
{ ...rowWithRecipient, id: 'smtp-c', tenantId: 't3', bugReportRecipient: null },
]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
vi.mocked(forTenant).mockClear();
const found = await service.getBugReportRecipient('t1');
expect(found).toBe('fehler@a.example.invalid');
expectBoundCall(prisma, 't1', 'findUnique');
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
expect(await service.getBugReportRecipient('t2')).toBeNull();
expect(await service.getBugReportRecipient('t3')).toBeNull();
});
it('Test B: saveSmtpConfig mit bugReportRecipient speichert den Wert; Rueckgabe traegt bugReportRecipient und KEIN encryptedPassword', async () => {
const prisma = makeFakePrisma();
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.saveSmtpConfig('t1', {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
bugReportRecipient: 'fehler@a.example.invalid',
} as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBe('fehler@a.example.invalid');
expect((result as any).bugReportRecipient).toBe('fehler@a.example.invalid');
expect((result as any).encryptedPassword).toBeUndefined();
});
it('Test C: DTO OHNE das Feld bewahrt den gespeicherten Wert, DTO mit null loescht ihn', async () => {
const prisma = makeFakePrisma();
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const base = {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
};
await service.saveSmtpConfig('t1', { ...base, bugReportRecipient: 'fehler@a.example.invalid' } as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBe('fehler@a.example.invalid');
await service.saveSmtpConfig('t1', { ...base } as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBe('fehler@a.example.invalid');
await service.saveSmtpConfig('t1', { ...base, bugReportRecipient: null } as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBeNull();
});
});
}); });
+23
View File
@@ -19,6 +19,7 @@ const SMTP_SAFE_SELECT = {
username: true, username: true,
// encryptedPassword: NEVER included — T-07-07 // encryptedPassword: NEVER included — T-07-07
fromAddress: true, fromAddress: true,
bugReportRecipient: true, // Postfach fuer den Fehler-melden-Knopf (quick-260914-m97)
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
} as const; } as const;
@@ -77,6 +78,10 @@ export class SettingsService {
username: dto.username ?? null, username: dto.username ?? null,
fromAddress: dto.fromAddress, fromAddress: dto.fromAddress,
...(encryptedPassword !== undefined ? { encryptedPassword } : {}), ...(encryptedPassword !== undefined ? { encryptedPassword } : {}),
// quick-260914-m97: fehlendes Feld = bewahren, null/leer = loeschen
...(dto.bugReportRecipient !== undefined
? { bugReportRecipient: dto.bugReportRecipient || null }
: {}),
}; };
const result = await tenantPrisma.smtpConfig.upsert({ const result = await tenantPrisma.smtpConfig.upsert({
@@ -89,6 +94,24 @@ export class SettingsService {
return result; return result;
} }
/**
* Postfach fuer den Fehler-melden-Knopf (quick-260914-m97): der Wert aus
* `SmtpConfig.bugReportRecipient` des Mandanten oder `null`, wenn keine
* Zeile existiert oder das Feld leer ist. Verwender: `BugReportsService`
* (der dort den Umgebungs-Rueckfall `TESSERA_BUGREPORT_TO` anhaengt).
*
* Mandantengebunden: EIN Klient `tenantPrisma`, `findUnique` mit
* schmalem `select` — das verschluesselte Kennwort wird hier nie geladen.
*/
async getBugReportRecipient(tenantId: string): Promise<string | null> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const row = await tenantPrisma.smtpConfig.findUnique({
where: { tenantId },
select: { bugReportRecipient: true },
});
return row?.bugReportRecipient ?? null;
}
/** /**
* Internal: Get the decrypted SMTP config for a tenant. * Internal: Get the decrypted SMTP config for a tenant.
* Used by DkvMailService/TenderMailService — and seit 260914-eym auch von * Used by DkvMailService/TenderMailService — and seit 260914-eym auch von
+19
View File
@@ -1,3 +1,11 @@
# Versionsstempel (quick-260914-ku1): die Werte setzt .gitea/scripts/publish-images.sh
# per --build-arg; lokal greifen die Vorgaben (dev). Ein globales ARG liefert nur die
# Vorgabe -- jede nutzende Stufe wiederholt deshalb `ARG NAME` ohne Wert.
ARG APP_VERSION=dev
ARG APP_CHANNEL=dev
ARG APP_COMMIT=
ARG APP_BUILD_TIME=
FROM node:24-alpine AS base FROM node:24-alpine AS base
RUN corepack enable && corepack prepare pnpm@9 --activate RUN corepack enable && corepack prepare pnpm@9 --activate
@@ -15,11 +23,22 @@ COPY apps/web/ ./apps/web/
COPY packages/shared/ ./packages/shared/ COPY packages/shared/ ./packages/shared/
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
ENV NEXT_PUBLIC_API_URL=/api-proxy ENV NEXT_PUBLIC_API_URL=/api-proxy
# Muss VOR dem Build stehen: Next.js bettet NEXT_PUBLIC_* zur Bauzeit ins Bundle ein.
# So spaet wie moeglich, damit die COPY-Schichten darueber im Cache bleiben.
ARG APP_VERSION
ARG APP_CHANNEL
ARG APP_COMMIT
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION NEXT_PUBLIC_APP_CHANNEL=$APP_CHANNEL NEXT_PUBLIC_APP_COMMIT=$APP_COMMIT
RUN pnpm --filter=@tessera/web build RUN pnpm --filter=@tessera/web build
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ARG APP_VERSION
ARG APP_CHANNEL
ARG APP_COMMIT
ARG APP_BUILD_TIME
ENV APP_VERSION=$APP_VERSION APP_CHANNEL=$APP_CHANNEL APP_COMMIT=$APP_COMMIT APP_BUILD_TIME=$APP_BUILD_TIME
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
COPY --from=builder /app/apps/web/public ./apps/web/public COPY --from=builder /app/apps/web/public ./apps/web/public
+1
View File
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@uiw/react-md-editor": "4.1.1", "@uiw/react-md-editor": "4.1.1",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"html-to-image": "1.11.13",
"jose": "^6.2.3", "jose": "^6.2.3",
"next": "^15.3.0", "next": "^15.3.0",
"next-intl": "^4.13.0", "next-intl": "^4.13.0",
@@ -0,0 +1,284 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import { clearErrorBuffer, recordError } from '@/lib/error-buffer';
import { computeCaptureSize } from '@/lib/bug-report-api';
/**
* bug-report-button.test — NEU (quick-260914-m97, Fehler-melden-Knopf).
*
* Elf Tests. Die Kernanforderung (Test 1): das Bild wird VOR dem Dialog
* aufgenommen — der `toPng`-Mock prueft waehrend seines Aufrufs, dass noch
* KEIN `role="dialog"` im DOM steht. `html-to-image` selbst laeuft in jsdom
* nicht (gemessen zur Planungszeit: `HTMLVideoElement is not defined`, kein
* Canvas-Backend) — deshalb der Mock; der Bildbeweis kommt aus dem Browser.
*
* Der next-intl-Mock liest die Texte aus der ECHTEN `de.json` (Muster
* `tessera-logo.test.tsx`), Erwartungen zitieren `de.bugReport.<key>`.
*/
const { mockToPng, mockFetch, mockUser } = vi.hoisted(() => ({
mockToPng: vi.fn(),
mockFetch: vi.fn(),
mockUser: { id: 'u1', username: 'anna', displayName: 'Anna', role: 'USER', tenantId: 't1' } as {
id: string;
username: string;
displayName: string | null;
role: string;
tenantId: string;
},
}));
vi.mock('html-to-image', () => ({
toPng: (...args: unknown[]) => mockToPng(...args),
}));
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations: (ns?: string) => (key: string) => lookup(ns ? `${ns}.${key}` : key) ?? key,
};
});
vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
<a href={href} className={className}>
{children}
</a>
),
}));
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (sel?: (s: { user: typeof mockUser }) => unknown) =>
sel ? sel({ user: mockUser }) : { user: mockUser },
}));
vi.mock('@/lib/app-version', () => ({
appVersion: { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' },
}));
const T = de.bugReport;
const DATA_URL = 'data:image/png;base64,iVBORw0KGgo=';
async function renderButton() {
const { BugReportButton } = await import('./bug-report-button');
render(<BugReportButton />);
}
async function openDialog(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: T.button }));
return screen.findByRole('dialog');
}
function jsonResponse(status: number, body: unknown = { statusCode: status }) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
beforeEach(() => {
mockUser.role = 'USER';
mockToPng.mockReset();
mockToPng.mockResolvedValue(DATA_URL);
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
cleanup();
clearErrorBuffer();
vi.unstubAllGlobals();
});
describe('BugReportButton (quick-260914-m97)', () => {
it('Test 1: Bild VOR dem Dialog — toPng laeuft ohne offenen Dialog, mit document.body, pixelRatio 1, skipFonts, 1600-px-Kante; danach Dialog mit Vorschau, Haekchen an, Textfeld leer', async () => {
Object.defineProperty(document.body, 'scrollWidth', { value: 3200, configurable: true });
Object.defineProperty(document.body, 'scrollHeight', { value: 1000, configurable: true });
mockToPng.mockImplementation(async () => {
expect(screen.queryByRole('dialog')).toBeNull();
return DATA_URL;
});
const user = userEvent.setup();
await renderButton();
const dialog = await openDialog(user);
expect(mockToPng).toHaveBeenCalledTimes(1);
const [node, opts] = mockToPng.mock.calls[0] as [unknown, Record<string, unknown>];
expect(node).toBe(document.body);
expect(opts).toMatchObject({ pixelRatio: 1, skipFonts: true, canvasWidth: 1600, canvasHeight: 500 });
expect(dialog).toBeInTheDocument();
const img = screen.getByAltText(T.screenshotAlt) as HTMLImageElement;
expect(img.getAttribute('src')).toBe(DATA_URL);
expect(screen.getByRole('checkbox', { name: T.attachScreenshot })).toBeChecked();
expect(screen.getByLabelText(T.descriptionLabel)).toHaveValue('');
});
it('Test 2: Senden mit Bild — multipart POST /bug-reports mit credentials, allen Feldern, Fehlerliste, PNG-Blob, ohne Content-Type-Header; danach Dankestext und Schliessen-Knopf', async () => {
recordError('fetch', 'GET /modules -> 500');
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.type(screen.getByLabelText(T.descriptionLabel), 'Knopf tut nichts');
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.sent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(String(url).endsWith('/bug-reports')).toBe(true);
expect(init.method).toBe('POST');
expect(init.credentials).toBe('include');
expect(init.body).toBeInstanceOf(FormData);
const body = init.body as FormData;
expect(body.get('description')).toBe('Knopf tut nichts');
expect(body.get('webVersion')).toBe('v1.2.3');
expect(body.get('webChannel')).toBe('beta');
expect(body.get('webCommit')).toBe('abc1234');
expect(String(body.get('page')).startsWith('/')).toBe(true);
expect(body.get('userAgent')).toBeTruthy();
expect(body.get('viewport')).toMatch(/^\d+x\d+$/);
expect(body.get('clientTime')).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(body.getAll('errors').some((e) => String(e).includes('GET /modules -> 500'))).toBe(true);
const shot = body.get('screenshot');
expect(shot).toBeInstanceOf(Blob);
expect((shot as Blob).type).toBe('image/png');
expect((shot as Blob).size).toBeGreaterThan(0);
const headers = (init.headers ?? {}) as Record<string, string>;
expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain('content-type');
expect(screen.getByRole('button', { name: T.close })).toBeInTheDocument();
});
it('Test 3: Haekchen aus -> kein Feld screenshot im Rumpf', async () => {
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('checkbox', { name: T.attachScreenshot }));
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.sent);
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
expect(body.has('screenshot')).toBe(false);
});
it('Test 4: 409 — Meldung "kein Postfach"; als ADMIN zusaetzlich Hinweis mit Link auf /admin/smtp, als USER kein Link', async () => {
mockUser.role = 'ADMIN';
mockFetch.mockResolvedValue(jsonResponse(409, { statusCode: 409, message: 'kein Postfach' }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorNotConfigured);
expect(screen.getByText(T.errorNotConfiguredAdminHint)).toBeInTheDocument();
const link = screen.getByRole('link', { name: T.errorNotConfiguredAdminLink });
expect(link.getAttribute('href')).toBe('/admin/smtp');
cleanup();
mockUser.role = 'USER';
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorNotConfigured);
expect(screen.queryByRole('link', { name: T.errorNotConfiguredAdminLink })).toBeNull();
expect(screen.queryByText(T.errorNotConfiguredAdminHint)).toBeNull();
});
it('Test 5: Escape schliesst den Dialog', async () => {
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('Test 6: Aufnahme scheitert -> Dialog oeffnet trotzdem mit Hinweis, Haekchen abgeschaltet und aus, Senden ohne screenshot', async () => {
mockToPng.mockRejectedValue(new Error('kein Canvas'));
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
expect(screen.getByText(T.screenshotUnavailable)).toBeInTheDocument();
const box = screen.getByRole('checkbox', { name: T.attachScreenshot });
expect(box).toBeDisabled();
expect(box).not.toBeChecked();
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.sent);
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
expect(body.has('screenshot')).toBe(false);
});
it('Test 7: 413 -> errorTooLarge; Knoepfe bleiben, erneutes Senden ruft fetch ein zweites Mal', async () => {
mockFetch.mockResolvedValue(jsonResponse(413));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorTooLarge);
await user.click(screen.getByRole('button', { name: T.send }));
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
});
it('Test 8: 429 -> errorTooMany, nicht errorTooLarge', async () => {
mockFetch.mockResolvedValue(jsonResponse(429));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorTooMany);
expect(screen.queryByText(T.errorTooLarge)).toBeNull();
});
it('Test 9: 502 -> errorSendFailed', async () => {
mockFetch.mockResolvedValue(jsonResponse(502));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorSendFailed);
});
it('Test 10: 500 und Netzwerkfehler -> errorGeneric, keine der vier spezifischen Meldungen', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(500));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorGeneric);
mockFetch.mockRejectedValueOnce(new Error('netz'));
await user.click(screen.getByRole('button', { name: T.send }));
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
await screen.findByText(T.errorGeneric);
for (const specific of [T.errorNotConfigured, T.errorTooMany, T.errorTooLarge, T.errorSendFailed]) {
expect(screen.queryByText(specific)).toBeNull();
}
});
});
describe('computeCaptureSize (quick-260914-m97, reine Funktion)', () => {
it('Test 11: laengste Kante hoechstens 1600 px, Seitenverhaeltnis bleibt, Mindestmass 1x1, maxEdge einstellbar', () => {
expect(computeCaptureSize(3200, 1000)).toEqual({ width: 1600, height: 500 });
expect(computeCaptureSize(800, 600)).toEqual({ width: 800, height: 600 });
expect(computeCaptureSize(1000, 4000)).toEqual({ width: 400, height: 1600 });
expect(computeCaptureSize(0, 0)).toEqual({ width: 1, height: 1 });
expect(computeCaptureSize(3200, 1000, 800)).toEqual({ width: 800, height: 250 });
});
});
@@ -0,0 +1,72 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { captureScreenshot } from '@/lib/bug-report-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { BugReportDialog } from './bug-report-dialog';
/**
* Fehler-melden-Knopf in der Kopfzeile (quick-260914-m97), Stil wie
* `ThemeToggle`. Kernanforderung: das Bild der Seite wird aufgenommen,
* BEVOR der Dialog erscheint — sonst waere der Dialog im Bild.
*/
export function BugReportButton() {
const t = useTranslations('bugReport');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const [capturing, setCapturing] = useState(false);
const [open, setOpen] = useState(false);
const [screenshot, setScreenshot] = useState<string | null>(null);
const handleClick = async () => {
if (capturing) return;
setCapturing(true);
// Reihenfolge ist die Kernanforderung: erst aufnehmen, dann oeffnen.
const shot = await captureScreenshot();
setScreenshot(shot);
setOpen(true);
setCapturing(false);
};
return (
<>
<button
type="button"
onClick={handleClick}
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"
>
{/* Kaefer-Symbol nach dem lucide-Symbol "bug" */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m8 2 1.88 1.88" />
<path d="M14.12 3.88 16 2" />
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
<path d="M12 20v-9" />
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
<path d="M6 13H2" />
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
<path d="M22 13h-4" />
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
</svg>
</button>
<BugReportDialog open={open} screenshot={screenshot} isAdmin={isAdmin} onClose={() => setOpen(false)} />
</>
);
}
@@ -0,0 +1,196 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { appVersion } from '@/lib/app-version';
import { dataUrlToBlob, sendBugReport } from '@/lib/bug-report-api';
import { formatErrorsForReport } from '@/lib/error-buffer';
/**
* Dialog des Fehler-melden-Knopfs (quick-260914-m97). Muster:
* `marketplace/components/ActivationDialog.tsx` (Overlay, `role="dialog"`,
* Escape, Fokus). Bekommt das bereits aufgenommene Bild als Data-URL —
* die Aufnahme passiert im Knopf, BEVOR dieser Dialog erscheint.
*/
interface BugReportDialogProps {
open: boolean;
screenshot: string | null;
isAdmin: boolean;
onClose: () => void;
}
type Status = 'ready' | 'sending' | 'sent' | 'failed';
export function BugReportDialog({ open, screenshot, isAdmin, onClose }: BugReportDialogProps) {
const t = useTranslations('bugReport');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [status, setStatus] = useState<Status>('ready');
const [failedStatus, setFailedStatus] = useState(0);
const [description, setDescription] = useState('');
const [attach, setAttach] = useState(screenshot !== null);
// Bei jedem Oeffnen frisch beginnen.
useEffect(() => {
if (open) {
setStatus('ready');
setFailedStatus(0);
setDescription('');
setAttach(screenshot !== null);
textareaRef.current?.focus();
}
}, [open, screenshot]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && status !== 'sending') onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, status, onClose]);
if (!open) return null;
const handleSend = async () => {
setStatus('sending');
const result = await 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,
});
if (result.ok) {
setStatus('sent');
} else {
setFailedStatus(result.status);
setStatus('failed');
}
};
const errorKey =
failedStatus === 409
? 'errorNotConfigured'
: failedStatus === 429
? 'errorTooMany'
: failedStatus === 413
? 'errorTooLarge'
: failedStatus === 502
? 'errorSendFailed'
: 'errorGeneric';
const busy = status === 'sending';
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
role="dialog"
aria-modal="true"
aria-labelledby="bug-report-title"
data-bug-report-ignore="true"
>
<div className="w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2">
{t('title')}
</h2>
{status === 'sent' ? (
<>
<p className="text-sm text-foreground mb-4">{t('sent')}</p>
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t('close')}
</button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground mb-4">{t('intro')}</p>
{screenshot ? (
<img
src={screenshot}
alt={t('screenshotAlt')}
className="max-h-48 w-auto rounded border border-border mb-3"
/>
) : (
<p className="text-sm text-muted-foreground mb-3">{t('screenshotUnavailable')}</p>
)}
<div className="flex items-center gap-2 mb-3">
<input
id="bug-report-attach"
type="checkbox"
className="h-4 w-4"
checked={attach}
disabled={screenshot === null || busy}
onChange={(e) => setAttach(e.target.checked)}
/>
<label htmlFor="bug-report-attach" className="text-sm text-foreground">
{t('attachScreenshot')}
</label>
</div>
<label htmlFor="bug-report-description" className="mb-1 block text-sm text-foreground">
{t('descriptionLabel')}
</label>
<textarea
id="bug-report-description"
ref={textareaRef}
rows={4}
maxLength={4000}
placeholder={t('descriptionPlaceholder')}
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground"
value={description}
disabled={busy}
onChange={(e) => setDescription(e.target.value)}
/>
{status === 'failed' && (
<div className="mt-3 text-sm text-destructive">
<p>{t(errorKey)}</p>
{failedStatus === 409 && isAdmin && (
<p className="mt-1">
{t('errorNotConfiguredAdminHint')}{' '}
<Link href="/admin/smtp" className="underline">
{t('errorNotConfiguredAdminLink')}
</Link>
</p>
)}
</div>
)}
<div className="mt-4 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50"
>
{t('cancel')}
</button>
<button
type="button"
onClick={handleSend}
disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{busy ? t('sending') : t('send')}
</button>
</div>
</>
)}
</div>
</div>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSidebarStore } from '@/lib/stores/sidebar-store'; import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { installErrorBuffer } from '@/lib/error-buffer';
import { Header } from '@/components/layout/header'; import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
@@ -13,6 +14,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
setMounted(true); setMounted(true);
}, []); }, []);
// Fehlerpuffer fuer den Fehler-melden-Knopf (quick-260914-m97): einmal je
// Seitenladung, SSR-sicher, idempotent (StrictMode-Doppeleffekt unschaedlich).
useEffect(() => {
installErrorBuffer();
}, []);
// Desktop: sidebar pushes content via margin-left // Desktop: sidebar pushes content via margin-left
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css) // Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
const sidebarWidth = isCollapsed const sidebarWidth = isCollapsed
@@ -0,0 +1,98 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* AppVersionBadge.test — die Versionszeile unten in der Seitenleiste
* (quick-260914-ku1). Vorlage: sidebar.test.tsx (next-intl-Mock, cleanup,
* dynamischer Import nach dem Setzen der Mocks).
*/
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string) => {
const map: Record<string, Record<string, string>> = {
sidebar: {
'channel.beta': 'Beta',
'channel.live': 'Live',
'channel.dev': 'Entwicklung',
},
};
return map[ns]?.[key] ?? key;
},
}));
let mockAppVersion = { version: 'v1.2.3', channel: 'beta' as 'beta' | 'live' | 'dev', commit: 'abc1234' };
const mockLoad = vi.fn();
vi.mock('@/lib/app-version', () => ({
get appVersion() {
return mockAppVersion;
},
loadApiVersion: () => mockLoad(),
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mockLoad.mockReset();
mockAppVersion = { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' };
});
async function importBadge() {
const mod = await import('./app-version-badge');
return mod.AppVersionBadge;
}
describe('AppVersionBadge (quick-260914-ku1)', () => {
it('Test 1 (Zeile): zeigt Version und uebersetzten Kanal', async () => {
mockLoad.mockResolvedValue(null);
const AppVersionBadge = await importBadge();
render(<AppVersionBadge />);
expect(screen.getByTestId('app-version')).toHaveTextContent('v1.2.3 · Beta');
});
it('Test 2 (Tooltip mit API): title nennt Commit und API-Version samt Kanal', async () => {
mockLoad.mockResolvedValue({
name: 'tessera',
version: 'v1.2.3',
channel: 'beta',
commit: 'abc1234',
buildTime: '',
});
const AppVersionBadge = await importBadge();
render(<AppVersionBadge />);
await waitFor(() => {
const title = screen.getByTestId('app-version').getAttribute('title') ?? '';
expect(title).toContain('Commit abc1234');
expect(title).toContain('API v1.2.3 (beta)');
});
});
it('Test 3 (Tooltip ohne API): title nennt den Commit, aber keinen API-Teil', async () => {
mockLoad.mockResolvedValue(null);
const AppVersionBadge = await importBadge();
render(<AppVersionBadge />);
await waitFor(() => {
expect(mockLoad).toHaveBeenCalled();
});
const title = screen.getByTestId('app-version').getAttribute('title') ?? '';
expect(title).toContain('Commit abc1234');
expect(title).not.toContain('API');
});
it('Test 4 (Kanal dev, kein Commit): zeigt dev · Entwicklung und hat kein title-Attribut', async () => {
mockAppVersion = { version: 'dev', channel: 'dev', commit: '' };
mockLoad.mockResolvedValue(null);
const AppVersionBadge = await importBadge();
render(<AppVersionBadge />);
await waitFor(() => {
expect(mockLoad).toHaveBeenCalled();
});
const el = screen.getByTestId('app-version');
expect(el).toHaveTextContent('dev · Entwicklung');
expect(el.hasAttribute('title')).toBe(false);
});
});
@@ -0,0 +1,37 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { type ApiVersionInfo, appVersion, loadApiVersion } from '@/lib/app-version';
/**
* Versionszeile unten in der Seitenleiste: `<Version> · <Kanal>`, z. B.
* `v1.0.0 · Beta` oder lokal `dev · Entwicklung` (quick-260914-ku1).
* Der Tooltip nennt den Web-Commit und, sobald geladen, die API-Version
* samt Kanal. "Commit" und "API" sind in beiden Sprachen gleich.
*/
export function AppVersionBadge() {
const t = useTranslations('sidebar');
const [api, setApi] = useState<ApiVersionInfo | null>(null);
useEffect(() => {
let active = true;
loadApiVersion().then((info) => {
if (active) setApi(info);
});
return () => {
active = false;
};
}, []);
const parts: string[] = [];
if (appVersion.commit) parts.push(`Commit ${appVersion.commit}`);
if (api) parts.push(`API ${api.version} (${api.channel})`);
const title = parts.length > 0 ? parts.join(' · ') : undefined;
return (
<span data-testid="app-version" className="block truncate text-xs text-muted-foreground" title={title}>
{appVersion.version} · {t(`channel.${appVersion.channel}`)}
</span>
);
}
@@ -8,6 +8,7 @@ import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { fetchCurrentUser, logout } from '@/lib/auth-actions'; import { fetchCurrentUser, logout } from '@/lib/auth-actions';
import { ThemeToggle } from '@/components/theme-toggle'; import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
export function Header() { export function Header() {
@@ -109,6 +110,7 @@ export function Header() {
{/* Right: Actions */} {/* Right: Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BugReportButton />
<ThemeToggle /> <ThemeToggle />
{/* User avatar dropdown */} {/* User avatar dropdown */}
@@ -64,6 +64,12 @@ vi.mock('@/components/layout/sidebar-footer', () => ({
SidebarFooter: () => <div data-testid="sidebar-footer" />, SidebarFooter: () => <div data-testid="sidebar-footer" />,
})); }));
// Das Abzeichen ruft `loadApiVersion()` und damit `fetch` — ohne Modul-Mock
// bekaeme es das Modul-Array aus `stubFetch()` (quick-260914-ku1).
vi.mock('@/components/layout/app-version-badge', () => ({
AppVersionBadge: () => <div data-testid="app-version-badge" />,
}));
const mockActiveModules = [ const mockActiveModules = [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' }, { id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' }, { id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
@@ -178,4 +184,15 @@ describe('Sidebar', () => {
expect(newCallCount).toBeGreaterThan(initialCallCount); expect(newCallCount).toBeGreaterThan(initialCallCount);
}); });
}); });
it('renders the version badge below the navigation', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
expect(screen.getByTestId('app-version-badge')).toBeInTheDocument();
});
}); });
@@ -8,6 +8,7 @@ import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store'; import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search'; import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -198,6 +199,13 @@ export function Sidebar() {
</button> </button>
</div> </div>
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
{!isCollapsed && (
<div className="border-t border-sidebar-border px-4 py-2">
<AppVersionBadge />
</div>
)}
</div> </div>
); );
@@ -0,0 +1,76 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import { fetchSmtp, saveSmtp } from '@/lib/settings-api';
import { SmtpSettingsForm } from './smtp-settings-form';
/**
* smtp-settings-form.test — NEU (quick-260914-m97). Das SMTP-Formular
* hatte bisher keine Testdatei. Zwei Tests fuer das neue Feld
* „Fehlermeldungen an“: Vorbelegung aus GET /settings/smtp und der
* PUT-Vertrag mit `saveSmtpConfig` (Wert bzw. `null` zum Loeschen).
*/
vi.mock('@/lib/settings-api', () => ({
fetchSmtp: vi.fn(),
saveSmtp: vi.fn(),
testSmtp: vi.fn(),
}));
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations: (ns?: string) => (key: string) => lookup(ns ? `${ns}.${key}` : key) ?? key,
};
});
const LABEL = de.settings.smtp.bugReportRecipient;
beforeEach(() => {
vi.mocked(fetchSmtp).mockResolvedValue({
host: 'h',
port: 587,
encryption: 'starttls',
fromAddress: 'a@b.invalid',
hasPassword: false,
bugReportRecipient: 'fehler@b.invalid',
});
vi.mocked(saveSmtp).mockResolvedValue({} as any);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('SmtpSettingsForm — Feld Fehlermeldungen an (quick-260914-m97)', () => {
it('Test 1: das Feld ist aus GET /settings/smtp vorbelegt', async () => {
render(<SmtpSettingsForm />);
const input = (await screen.findByLabelText(LABEL)) as HTMLInputElement;
await waitFor(() => expect(input.value).toBe('fehler@b.invalid'));
expect(input.type).toBe('email');
});
it('Test 2: PUT-Payload traegt den Wert; leeres Feld -> null', async () => {
const user = userEvent.setup();
render(<SmtpSettingsForm />);
const input = (await screen.findByLabelText(LABEL)) as HTMLInputElement;
await waitFor(() => expect(input.value).toBe('fehler@b.invalid'));
await user.clear(input);
await user.type(input, 'neu@b.invalid');
await user.click(screen.getByRole('button', { name: de.settings.smtp.save }));
await waitFor(() => expect(saveSmtp).toHaveBeenCalledTimes(1));
expect(vi.mocked(saveSmtp).mock.calls[0][0]).toMatchObject({ bugReportRecipient: 'neu@b.invalid' });
await user.clear(input);
await user.click(screen.getByRole('button', { name: de.settings.smtp.save }));
await waitFor(() => expect(saveSmtp).toHaveBeenCalledTimes(2));
expect(vi.mocked(saveSmtp).mock.calls[1][0]).toMatchObject({ bugReportRecipient: null });
});
});
@@ -23,6 +23,7 @@ interface FormState {
username: string; username: string;
password: string; // T-07-17: always starts blank; never pre-filled from server password: string; // T-07-17: always starts blank; never pre-filled from server
fromAddress: string; fromAddress: string;
bugReportRecipient: string; // quick-260914-m97: Postfach fuer den Fehler-melden-Knopf
} }
const DEFAULT_FORM: FormState = { const DEFAULT_FORM: FormState = {
@@ -32,6 +33,7 @@ const DEFAULT_FORM: FormState = {
username: '', username: '',
password: '', password: '',
fromAddress: '', fromAddress: '',
bugReportRecipient: '',
}; };
type TestFeedback = type TestFeedback =
@@ -70,6 +72,7 @@ export function SmtpSettingsForm() {
username: config.username ?? '', username: config.username ?? '',
password: '', // T-07-17: never pre-filled password: '', // T-07-17: never pre-filled
fromAddress: config.fromAddress, fromAddress: config.fromAddress,
bugReportRecipient: config.bugReportRecipient ?? '',
}); });
} }
}) })
@@ -95,6 +98,8 @@ export function SmtpSettingsForm() {
if (form.username.trim()) payload.username = form.username.trim(); if (form.username.trim()) payload.username = form.username.trim();
// T-07-17: only include password when user has typed a new one // T-07-17: only include password when user has typed a new one
if (form.password) payload.password = form.password; if (form.password) payload.password = form.password;
// quick-260914-m97: das Formular ist der einzige Klient — leer bedeutet loeschen (null)
payload.bugReportRecipient = form.bugReportRecipient.trim() || null;
return payload; return payload;
}; };
@@ -300,6 +305,26 @@ export function SmtpSettingsForm() {
</p> </p>
</div> </div>
{/* Fehlermeldungen an (quick-260914-m97) */}
<div>
<label htmlFor="smtp-bug-report-recipient" className={labelClass}>
{t('smtp.bugReportRecipient')}
</label>
<input
id="smtp-bug-report-recipient"
type="email"
placeholder="fehler@example.com"
className={inputClass}
value={form.bugReportRecipient}
onChange={(e) =>
setForm((prev) => ({ ...prev, bugReportRecipient: e.target.value }))
}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('smtp.bugReportRecipientHelp')}
</p>
</div>
{/* Test-E-Mail Empfänger */} {/* Test-E-Mail Empfänger */}
<div> <div>
<label htmlFor="smtp-test-to" className={labelClass}> <label htmlFor="smtp-test-to" className={labelClass}>
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* app-version.test — Versionsstempel der Web-Oberflaeche (quick-260914-ku1).
*
* `appVersion` wird beim Laden des Moduls aus `NEXT_PUBLIC_APP_*` gelesen und
* `loadApiVersion()` memoisiert die Antwort von `GET /health/version` —
* deshalb setzt jeder Test die Module zurueck und importiert dynamisch,
* nachdem Umgebung und `fetch` gestubbt sind (Muster: module-access-gate.test.tsx).
*/
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
async function importFresh() {
vi.resetModules();
return import('./app-version');
}
describe('app-version (quick-260914-ku1)', () => {
it('Test 1 (Vorgaben): ohne NEXT_PUBLIC_APP_* ist appVersion dev/dev ohne Commit', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', undefined);
vi.stubEnv('NEXT_PUBLIC_APP_CHANNEL', undefined);
vi.stubEnv('NEXT_PUBLIC_APP_COMMIT', undefined);
const mod = await importFresh();
expect(mod.appVersion).toEqual({ version: 'dev', channel: 'dev', commit: '' });
});
it('Test 2 (Umgebung): NEXT_PUBLIC_APP_VERSION/_CHANNEL/_COMMIT werden woertlich durchgereicht', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', 'v1.2.3');
vi.stubEnv('NEXT_PUBLIC_APP_CHANNEL', 'beta');
vi.stubEnv('NEXT_PUBLIC_APP_COMMIT', 'abc1234');
const mod = await importFresh();
expect(mod.appVersion).toEqual({ version: 'v1.2.3', channel: 'beta', commit: 'abc1234' });
});
it('Test 3 (Normalisierung): ein unbekannter Kanal wird zu dev', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', 'v1.2.3');
vi.stubEnv('NEXT_PUBLIC_APP_CHANNEL', 'gamma');
vi.stubEnv('NEXT_PUBLIC_APP_COMMIT', 'abc1234');
const mod = await importFresh();
expect(mod.appVersion.channel).toBe('dev');
});
it('Test 4 (Laden, memoisiert): zwei Aufrufe liefern das Objekt, fetch laeuft genau einmal mit Cookie', async () => {
const payload = {
name: 'tessera',
version: 'v1.2.3',
channel: 'beta',
commit: 'abc1234',
buildTime: 'x',
};
const fetchMock = vi.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(payload) }),
);
vi.stubGlobal('fetch', fetchMock);
const mod = await importFresh();
const first = await mod.loadApiVersion();
const second = await mod.loadApiVersion();
expect(first).toEqual(payload);
expect(second).toEqual(payload);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url.endsWith('/health/version')).toBe(true);
expect(options).toMatchObject({ credentials: 'include' });
});
it('Test 5 (still bei Fehler): Netzfehler und ok=false liefern null, nichts wird geworfen', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('netz'))));
const rejected = await importFresh();
await expect(rejected.loadApiVersion()).resolves.toBeNull();
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
);
const notOk = await importFresh();
await expect(notOk.loadApiVersion()).resolves.toBeNull();
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Versionsstempel der Web-Oberflaeche (quick-260914-ku1).
*
* Quelle fuer das Abzeichen unten in der Seitenleiste (`AppVersionBadge`)
* UND fuer den kommenden Fehler-melden-Knopf: beide brauchen die Fassung,
* die der Anwender gerade benutzt.
*
* Wichtig: Next.js ersetzt `process.env.NEXT_PUBLIC_*` nur dann zur Bauzeit
* im Browser-Bundle, wenn der Ausdruck woertlich mit vollem Namen im Code
* steht — kein Destructuring, kein `process.env[name]`. Sonst ist der Wert
* im Browser leer. Die Werte setzt `apps/web/Dockerfile` (builder-Stufe)
* aus den Build-Args des CI-Skripts `.gitea/scripts/publish-images.sh`;
* lokal greifen die Vorgaben `dev`.
*/
export type AppChannel = 'beta' | 'live' | 'dev';
export interface AppVersionInfo {
version: string;
channel: AppChannel;
commit: string;
}
/**
* Spiegel von `VersionResponse` aus `packages/shared`: `apps/web` haengt
* nicht von `@tessera/shared` ab, und ein neuer Import wuerde Lockfile und
* die deps-Stufe des Dockerfiles aendern. Die API-Wahrheit bleibt dort.
*/
export interface ApiVersionInfo {
name: string;
version: string;
channel: string;
commit: string;
buildTime: string;
}
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
function normalizeChannel(raw: string | undefined): AppChannel {
if (raw === 'beta' || raw === 'live') return raw;
return 'dev';
}
export const appVersion: AppVersionInfo = {
version: process.env.NEXT_PUBLIC_APP_VERSION || 'dev',
channel: normalizeChannel(process.env.NEXT_PUBLIC_APP_CHANNEL),
commit: process.env.NEXT_PUBLIC_APP_COMMIT || '',
};
let apiVersionPromise: Promise<ApiVersionInfo | null> | null = null;
/**
* Laedt `GET /health/version` genau einmal je Seitenladung (memoisiert);
* jeder Fehler (Netz, Nicht-2xx) ist still und liefert `null`.
*/
export function loadApiVersion(): Promise<ApiVersionInfo | null> {
if (!apiVersionPromise) {
apiVersionPromise = fetch(`${API_URL}/health/version`, { credentials: 'include' })
.then((res) => (res.ok ? (res.json() as Promise<ApiVersionInfo>) : null))
.catch(() => null);
}
return apiVersionPromise;
}
+109
View File
@@ -0,0 +1,109 @@
/**
* bug-report-api — Bildaufnahme und Versand des Fehler-melden-Knopfs
* (quick-260914-m97).
*
* `captureScreenshot` rastert `document.body` ueber `html-to-image`
* (SVG `foreignObject`, der Browser zeichnet selbst — deshalb stimmen die
* OKLCH-Farben von Tailwind 4, an denen `html2canvas` scheitert). Die
* Bibliothek wird erst beim Klick dynamisch geladen. Die laengste Kante
* ist auf 1600 px begrenzt (`computeCaptureSize`), `pixelRatio: 1`, keine
* Schrift-Einbettung (`skipFonts` — das Projekt hat keinen Webfont).
*
* `sendBugReport` schickt `multipart/form-data` an `POST /bug-reports`
* — das Bild als Datei, alle uebrigen Felder als Text — mit Cookie
* (`credentials: 'include'`) und OHNE eigenen Content-Type-Header: die
* Multipart-Grenze setzt der Browser selbst.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/**
* Zielmass fuer die Aufnahme: laengste Kante hoechstens `maxEdge`,
* Seitenverhaeltnis bleibt, nie kleiner als 1x1 (kein 0-Canvas).
* Reine Funktion, direkt getestet.
*/
export function computeCaptureSize(
width: number,
height: number,
maxEdge = 1600,
): { width: number; height: number } {
const w = Math.max(1, Math.round(width || 0));
const h = Math.max(1, Math.round(height || 0));
const scale = Math.min(1, maxEdge / Math.max(w, h));
return {
width: Math.max(1, Math.round(w * scale)),
height: Math.max(1, Math.round(h * scale)),
};
}
/**
* Nimmt die aktuelle Seite als PNG-Data-URL auf. Liefert `null` statt zu
* werfen: das Bild ist eine Beigabe, der Bericht geht auch ohne.
*/
export async function captureScreenshot(): Promise<string | null> {
try {
const { toPng } = await import('html-to-image');
const node = document.body;
const size = computeCaptureSize(node.scrollWidth, node.scrollHeight);
return await toPng(node, {
pixelRatio: 1,
skipFonts: true,
cacheBust: true,
canvasWidth: size.width,
canvasHeight: size.height,
filter: (n: Node) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true'),
});
} catch {
return null;
}
}
/** Base64-Teil einer Data-URL -> PNG-Blob (per `atob`, kein fetch). */
export function dataUrlToBlob(dataUrl: string): Blob {
const comma = dataUrl.indexOf(',');
const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return 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 type BugReportResult = { ok: true } | { ok: false; status: number };
export async function sendBugReport(p: BugReportPayload): Promise<BugReportResult> {
const body = new FormData();
body.append('description', p.description);
body.append('page', p.page);
body.append('webVersion', p.webVersion);
body.append('webChannel', p.webChannel);
body.append('webCommit', p.webCommit);
body.append('userAgent', p.userAgent);
body.append('viewport', p.viewport);
body.append('clientTime', p.clientTime);
for (const line of p.errors) body.append('errors', line);
if (p.screenshot) body.append('screenshot', p.screenshot, 'screenshot.png');
try {
const res = await fetch(`${API_URL}/bug-reports`, {
method: 'POST',
credentials: 'include',
body,
});
return res.ok ? { ok: true } : { ok: false, status: res.status };
} catch {
return { ok: false, status: 0 };
}
}
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
clearErrorBuffer,
formatErrorsForReport,
getRecentErrors,
installErrorBuffer,
recordError,
uninstallErrorBuffer,
} from './error-buffer';
/**
* error-buffer.test — NEU (quick-260914-m97, Fehler-melden-Knopf).
*
* Vier Tests fuer den Browser-Fehlerpuffer, darunter die Sicherheitsregel
* T-M97-02: der fetch-Wrapper notiert NUR fehlgeschlagene Antworten mit
* Methode, Pfad OHNE Suchteil, Status und 200 Zeichen des ANTWORT-Rumpfs —
* nie den Anfrage-Rumpf (Kennwoerter), nie Kopfzeilen, nie den Suchteil
* (Tokens). Test 2 pinnt `geheim` und `password` als NICHT im Puffer.
*/
afterEach(() => {
uninstallErrorBuffer();
clearErrorBuffer();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('error-buffer (quick-260914-m97)', () => {
it('Test 1: Ringpuffer haelt 20 Eintraege — 25 Meldungen -> die ersten fuenf fallen raus', () => {
for (let i = 0; i < 25; i++) recordError('error', `m${i}`);
const entries = getRecentErrors();
expect(entries).toHaveLength(20);
expect(entries[0].message).toBe('m5');
expect(entries[19].message).toBe('m24');
for (const e of entries) {
expect(e.kind).toBe('error');
expect(typeof e.message).toBe('string');
expect(() => new Date(e.at).toISOString()).not.toThrow();
expect(e.at).toBe(new Date(e.at).toISOString());
}
});
it('Test 2 (T-M97-02): fetch-Wrapper notiert nur !ok — Methode, Pfad ohne Suchteil, Status, Antwort-Auszug; nie Anfrage-Rumpf; Antwort bleibt lesbar; ok-Antworten werden nicht notiert', async () => {
let status = 500;
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response('{"statusCode":500,"message":"kaputt"}', { status }),
);
vi.stubGlobal('fetch', fetchMock);
installErrorBuffer();
const res = await fetch('/api/x?token=geheim', { method: 'POST', body: '{"password":"p"}' });
const entries = getRecentErrors();
expect(entries).toHaveLength(1);
expect(entries[0].kind).toBe('fetch');
expect(entries[0].message.startsWith('POST /api/x -> 500')).toBe(true);
expect(entries[0].message).toContain('kaputt');
expect(entries[0].message).not.toContain('geheim');
expect(entries[0].message).not.toContain('password');
expect(entries[0].message).not.toContain('token=');
await expect(res.json()).resolves.toEqual({ statusCode: 500, message: 'kaputt' });
status = 200;
await fetch('/api/y');
expect(getRecentErrors()).toHaveLength(1);
});
it('Test 3: console.error ruft weiterhin das Original und notiert die Argumente als Text', () => {
const orig = vi.spyOn(console, 'error').mockImplementation(() => {});
installErrorBuffer();
console.error('boom', { a: 1 });
expect(orig).toHaveBeenCalledTimes(1);
expect(orig).toHaveBeenCalledWith('boom', { a: 1 });
const entries = getRecentErrors();
expect(entries).toHaveLength(1);
expect(entries[0].kind).toBe('console.error');
expect(entries[0].message).toContain('boom');
});
it('Test 4: Installation ist idempotent (kein doppeltes Wrapping); formatErrorsForReport liefert "[<ISO>] fetch: ..."', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('nein', { status: 503 })));
installErrorBuffer();
installErrorBuffer();
await fetch('/api/z');
const entries = getRecentErrors();
expect(entries).toHaveLength(1);
const lines = formatErrorsForReport();
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] fetch: GET \/api\/z -> 503/);
});
});
+150
View File
@@ -0,0 +1,150 @@
/**
* error-buffer — Ringpuffer der letzten Fehlermeldungen im Browser
* (quick-260914-m97, Fehler-melden-Knopf).
*
* Zweck: wenn ein Anwender „Fehler melden“ klickt, kommen die letzten 20
* Fehler mit, die der Browser im Hintergrund gesehen hat — unbehandelte
* Ausnahmen, abgelehnte Promises, `console.error`-Aufrufe und fehlgeschlagene
* API-Antworten. Der Administrator sieht damit im Bericht, was VOR dem
* Klick schiefging, ohne Rueckfragen stellen zu muessen.
*
* Grenzen: 20 Eintraege, je hoechstens 1000 Zeichen, Antwort-Auszug 200
* Zeichen. Einmal je Seitenladung installiert (`installErrorBuffer`,
* idempotent ueber ein Guard-Symbol auf `window`), SSR-sicher (ohne
* `window` passiert nichts).
*
* Sicherheitsregel (T-M97-02): der fetch-Wrapper notiert NUR bei
* `!response.ok` — Methode, Pfad OHNE Suchteil, Status und die ersten 200
* Zeichen des ANTWORT-Rumpfs (aus einem `clone()`, die Antwort bleibt fuer
* den Aufrufer lesbar). Nie der Anfrage-Rumpf (Kennwoerter, Formulare),
* nie Kopfzeilen, nie Cookies (das Sitzungs-Cookie ist httpOnly und fuer
* JavaScript ohnehin unsichtbar), nie der Suchteil (Tokens in URLs).
*/
export type BufferedErrorKind = 'error' | 'unhandledrejection' | 'console.error' | 'fetch';
export interface BufferedError {
at: string;
kind: BufferedErrorKind;
message: string;
}
const MAX_ENTRIES = 20;
const MAX_MESSAGE = 1000;
const BODY_EXCERPT = 200;
const GUARD = '__tesseraErrorBufferInstalled';
const buffer: BufferedError[] = [];
let originalFetch: typeof fetch | null = null;
let originalConsoleError: typeof console.error | null = null;
let listeners: { error: (e: ErrorEvent) => void; rejection: (e: PromiseRejectionEvent) => void } | null = null;
export function recordError(kind: BufferedErrorKind, message: string): void {
const text = String(message ?? '');
buffer.push({
at: new Date().toISOString(),
kind,
message: text.length > MAX_MESSAGE ? text.slice(0, MAX_MESSAGE) : text,
});
while (buffer.length > MAX_ENTRIES) buffer.shift();
}
export function getRecentErrors(): BufferedError[] {
return buffer.map((e) => ({ ...e }));
}
export function clearErrorBuffer(): void {
buffer.length = 0;
}
/** Zeilen der Form `[<ISO>] <Art>: <Meldung>` fuer das Feld `errors` des Berichts. */
export function formatErrorsForReport(): string[] {
return buffer.map((e) => `[${e.at}] ${e.kind}: ${e.message}`);
}
function argToText(arg: unknown): string {
if (typeof arg === 'string') return arg;
if (arg instanceof Error) return arg.message;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}
function pathOf(input: RequestInfo | URL): string {
try {
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
return new URL(String(raw), window.location.href).pathname;
} catch {
return '?';
}
}
export function installErrorBuffer(): void {
if (typeof window === 'undefined') return;
const w = window as unknown as Record<string, unknown>;
if (w[GUARD]) return;
w[GUARD] = true;
const onError = (e: ErrorEvent) => {
recordError('error', `${e.message} @ ${e.filename}:${e.lineno}`);
};
const onRejection = (e: PromiseRejectionEvent) => {
const reason = e.reason as { message?: unknown } | undefined;
recordError('unhandledrejection', String(reason?.message ?? e.reason));
};
window.addEventListener('error', onError);
window.addEventListener('unhandledrejection', onRejection);
listeners = { error: onError, rejection: onRejection };
// console.error: ZUERST das Original mit denselben Argumenten, dann notieren.
const origConsoleError = console.error;
originalConsoleError = origConsoleError;
console.error = (...args: unknown[]) => {
origConsoleError.apply(console, args);
recordError('console.error', args.map(argToText).join(' '));
};
// fetch: nur fehlgeschlagene Antworten, nie der Anfrage-Rumpf (T-M97-02).
const origFetch = window.fetch;
originalFetch = origFetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const method = String(init?.method ?? 'GET').toUpperCase();
let response: Response;
try {
response = await origFetch(input, init);
} catch (err) {
recordError('fetch', `${method} ${pathOf(input)} -> Netzwerkfehler`);
throw err;
}
if (!response.ok) {
let excerpt = '';
try {
excerpt = (await response.clone().text()).slice(0, BODY_EXCERPT);
} catch {
excerpt = '';
}
recordError('fetch', `${method} ${pathOf(input)} -> ${response.status}${excerpt ? ` ${excerpt}` : ''}`);
}
return response;
};
}
/** Nur fuer Tests: Original-fetch/console.error zurueck, Guard loeschen. Kein Aufrufer im Produktionscode. */
export function uninstallErrorBuffer(): void {
if (typeof window === 'undefined') return;
const w = window as unknown as Record<string, unknown>;
if (!w[GUARD]) return;
if (originalFetch) window.fetch = originalFetch;
if (originalConsoleError) console.error = originalConsoleError;
if (listeners) {
window.removeEventListener('error', listeners.error);
window.removeEventListener('unhandledrejection', listeners.rejection);
}
originalFetch = null;
originalConsoleError = null;
listeners = null;
delete w[GUARD];
}
+8
View File
@@ -23,6 +23,8 @@ export interface SmtpConfig {
fromAddress: string; fromAddress: string;
/** True when an encrypted password is stored; the password value is never exposed. */ /** True when an encrypted password is stored; the password value is never exposed. */
hasPassword: boolean; hasPassword: boolean;
/** Postfach fuer den Fehler-melden-Knopf (quick-260914-m97); null = nicht gesetzt. */
bugReportRecipient: string | null;
} }
/** /**
@@ -39,6 +41,12 @@ export interface SaveSmtpPayload {
fromAddress: string; fromAddress: string;
/** If set, backend sends a real test email to this address instead of just verify(). */ /** If set, backend sends a real test email to this address instead of just verify(). */
testTo?: string; testTo?: string;
/**
* Postfach fuer den Fehler-melden-Knopf (quick-260914-m97). Vertrag mit
* `saveSmtpConfig`: `null` loescht den gespeicherten Wert, ein fehlendes
* Feld bewahrt ihn.
*/
bugReportRecipient?: string | null;
} }
// --- API functions --- // --- API functions ---
+31 -2
View File
@@ -104,7 +104,12 @@
"users": "Benutzer", "users": "Benutzer",
"tenants": "Mandanten", "tenants": "Mandanten",
"ldap": "LDAP", "ldap": "LDAP",
"modules": "Module" "modules": "Module",
"channel": {
"beta": "Beta",
"live": "Live",
"dev": "Entwicklung"
}
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -163,7 +168,9 @@
"testToHelp": "Optional — sendet eine echte Test-E-Mail an diese Adresse.", "testToHelp": "Optional — sendet eine echte Test-E-Mail an diese Adresse.",
"testTesting": "Verbindung wird getestet...", "testTesting": "Verbindung wird getestet...",
"testSuccess": "Verbindung erfolgreich", "testSuccess": "Verbindung erfolgreich",
"testFailed": "Verbindung fehlgeschlagen" "testFailed": "Verbindung fehlgeschlagen",
"bugReportRecipient": "Fehlermeldungen an",
"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."
} }
}, },
"widgets": { "widgets": {
@@ -478,6 +485,28 @@
"dark": "Dunkel", "dark": "Dunkel",
"system": "System" "system": "System"
}, },
"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."
},
"locale": { "locale": {
"de": "Deutsch", "de": "Deutsch",
"en": "English" "en": "English"
+31 -2
View File
@@ -104,7 +104,12 @@
"users": "Users", "users": "Users",
"tenants": "Tenants", "tenants": "Tenants",
"ldap": "LDAP", "ldap": "LDAP",
"modules": "Modules" "modules": "Modules",
"channel": {
"beta": "Beta",
"live": "Live",
"dev": "Development"
}
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -163,7 +168,9 @@
"testToHelp": "Optional — sends a real test email to this address.", "testToHelp": "Optional — sends a real test email to this address.",
"testTesting": "Testing connection...", "testTesting": "Testing connection...",
"testSuccess": "Connection successful", "testSuccess": "Connection successful",
"testFailed": "Connection failed" "testFailed": "Connection failed",
"bugReportRecipient": "Bug reports to",
"bugReportRecipientHelp": "Optional – mailbox that receives the reports users send via the \"Report a problem\" button, including the screenshot. Leave empty if the button should not send e-mails."
} }
}, },
"widgets": { "widgets": {
@@ -478,6 +485,28 @@
"dark": "Dark", "dark": "Dark",
"system": "System" "system": "System"
}, },
"bugReport": {
"button": "Report a problem",
"title": "Report a problem",
"intro": "Tessera has just taken a picture of this page – it shows exactly what you see. The picture, your description, the page, version, browser and the most recent error messages are sent by e-mail to your administrator.",
"screenshotAlt": "Screenshot preview",
"screenshotUnavailable": "No screenshot possible – the report will be sent without a picture.",
"attachScreenshot": "Attach screenshot",
"descriptionLabel": "What happened?",
"descriptionPlaceholder": "Optional: What did you do, what did you expect, what happened instead?",
"send": "Send",
"sending": "Sending…",
"cancel": "Cancel",
"close": "Close",
"sent": "Thank you, your report has been sent.",
"errorNotConfigured": "No mailbox has been set up for bug reports yet.",
"errorNotConfiguredAdminHint": "Set the address under Administrator → SMTP in the field \"Bug reports to\".",
"errorNotConfiguredAdminLink": "Go to the SMTP settings",
"errorTooMany": "Too many reports in a short time. Please try again in a few minutes.",
"errorTooLarge": "The picture is too large. Please send the report without the screenshot.",
"errorSendFailed": "The e-mail could not be sent. Please try again later or contact your administrator.",
"errorGeneric": "The report could not be sent."
},
"locale": { "locale": {
"de": "Deutsch", "de": "Deutsch",
"en": "English" "en": "English"
@@ -174,4 +174,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'RSS', 'RSS',
'RSSGenerator', 'RSSGenerator',
'SSL', 'SSL',
// 260914-m97: Fehler-melden-Knopf, Pflichtlabel "Was ist passiert?"
'passiert',
]; ];
+8 -2
View File
@@ -1,6 +1,8 @@
services: services:
web: web:
image: git.vicolab.de/schalli/tessera-ctl/web:latest # IMAGE_TAG in .env selects the delivery channel: beta (alpha, all new
# changes) or live (released versions only). Defaults to beta.
image: git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
@@ -20,7 +22,8 @@ services:
condition: service_healthy condition: service_healthy
api: api:
image: git.vicolab.de/schalli/tessera-ctl/api:latest # Same channel as web, see IMAGE_TAG above.
image: git.vicolab.de/schalli/tessera-ctl/api:${IMAGE_TAG:-beta}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3001:3001" - "3001:3001"
@@ -44,6 +47,9 @@ services:
TESSERA_SMTP_USER: ${TESSERA_SMTP_USER:-} TESSERA_SMTP_USER: ${TESSERA_SMTP_USER:-}
TESSERA_SMTP_PASSWORD: ${TESSERA_SMTP_PASSWORD:-} TESSERA_SMTP_PASSWORD: ${TESSERA_SMTP_PASSWORD:-}
TESSERA_SMTP_FROM: ${TESSERA_SMTP_FROM:-Tessera <noreply@tessera.local>} TESSERA_SMTP_FROM: ${TESSERA_SMTP_FROM:-Tessera <noreply@tessera.local>}
# Fallback mailbox for the in-app bug report button. Empty = only the
# per-tenant setting in Administrator -> SMTP ("Fehlermeldungen an") applies.
TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-}
TESSERA_APP_URL: ${APP_URL:-http://localhost:3000} TESSERA_APP_URL: ${APP_URL:-http://localhost:3000}
# Unset used to resolve to an empty value and only fail later, inside the # Unset used to resolve to an empty value and only fail later, inside the
# API, with a stack trace. Fail at compose level with a usable message # API, with a stack trace. Fail at compose level with a usable message
+5 -1
View File
@@ -201,10 +201,12 @@ Ein Hinweistext unterhalb der Matrix erinnert daran, dass ADMIN und SUPER_ADMIN
## 6. SMTP ## 6. SMTP
Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort und die Absenderadresse. Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde. Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde.
Über „Test-E-Mail an“ lässt sich optional eine echte Testnachricht an eine beliebige Adresse verschicken, um die Konfiguration vor dem produktiven Einsatz zu prüfen. Über „Test-E-Mail an“ lässt sich optional eine echte Testnachricht an eine beliebige Adresse verschicken, um die Konfiguration vor dem produktiven Einsatz zu prüfen.
**Fehlermeldungen an** ist eine optionale Adresse für den Knopf „Fehler melden“, den alle Anwender rechts in der Kopfleiste sehen. Sobald hier eine Adresse gespeichert ist, wirkt der Knopf: Ein Klick schickt ein Bildschirmfoto der aktuellen Seite samt Beschreibung des Anwenders, Adresse der Seite, Version und Kanal von Tessera, Browser, angemeldetem Benutzer (Name, Benutzername, Rolle) und den letzten Fehlermeldungen des Browsers als E-Mail an diese Adresse — der Betreff beginnt mit „[Tessera Fehlermeldung]“, das Bild hängt als PNG an. Der Knopf ist immer sichtbar; ohne Adresse erhalten Anwender beim Senden den Hinweis, dass noch kein Postfach eingerichtet ist (Administratoren sehen zusätzlich einen Link hierher). Je Benutzer sind höchstens fünf Meldungen in zehn Minuten möglich; Bilder über 4 MB werden abgewiesen. Der Versand nutzt dieselben SMTP-Zugangsdaten wie alle anderen E-Mails des Mandanten. Für Installationen ohne gespeicherte SMTP-Einstellungen kennt der Betrieb einen Rückfall über die Umgebungsvariable `TESSERA_BUGREPORT_TO` (Betriebshandbuch, Kapitel 3). Bitte beachten: Das Bild zeigt alles, was der Anwender gerade sieht — wählen Sie das Postfach entsprechend.
Ohne funktionierende SMTP-Konfiguration versendet Tessera keine E-Mails. Das betrifft insbesondere: Ohne funktionierende SMTP-Konfiguration versendet Tessera keine E-Mails. Das betrifft insbesondere:
- den Versand von Passwort-Reset-Mails an Benutzer, die ihr Passwort vergessen haben; - den Versand von Passwort-Reset-Mails an Benutzer, die ihr Passwort vergessen haben;
@@ -236,5 +238,7 @@ Ein Mandant lässt sich **nicht löschen, solange er noch aktive Benutzer hat**
| Ein neu importierter/erstellter Benutzer hat unerwartet Zugriff auf ein Modul, das eigentlich niemandem freigegeben sein sollte. | Der Benutzer wurde automatisch Mitglied der Standardgruppe (siehe Kapitel 3), und dieser Gruppe wurde beim Aktivieren eines Moduls über „Sofort freigeben“ Zugriff erteilt. Freigaben-Matrix prüfen und ggf. die Standardgruppen-Freigabe für das betreffende Modul entfernen. | | Ein neu importierter/erstellter Benutzer hat unerwartet Zugriff auf ein Modul, das eigentlich niemandem freigegeben sein sollte. | Der Benutzer wurde automatisch Mitglied der Standardgruppe (siehe Kapitel 3), und dieser Gruppe wurde beim Aktivieren eines Moduls über „Sofort freigeben“ Zugriff erteilt. Freigaben-Matrix prüfen und ggf. die Standardgruppen-Freigabe für das betreffende Modul entfernen. |
| Der Name einer AD-gebundenen Gruppe „springt“ nach jedem Sync-Lauf auf den AD-Namen zurück, obwohl ein anderer Name gewünscht ist. | Erwartetes Verhalten: Der Anzeigename einer AD-gebundenen Gruppe wird bei jedem Sync mit dem AD-Wert überschrieben. Für einen dauerhaft abweichenden Anzeigenamen den „Internen Namen“ im Bearbeiten-Dialog der Gruppe setzen – dieses Feld wird von der Synchronisation nie berührt. | | Der Name einer AD-gebundenen Gruppe „springt“ nach jedem Sync-Lauf auf den AD-Namen zurück, obwohl ein anderer Name gewünscht ist. | Erwartetes Verhalten: Der Anzeigename einer AD-gebundenen Gruppe wird bei jedem Sync mit dem AD-Wert überschrieben. Für einen dauerhaft abweichenden Anzeigenamen den „Internen Namen“ im Bearbeiten-Dialog der Gruppe setzen – dieses Feld wird von der Synchronisation nie berührt. |
| Ein AD-Gruppen-Import schlägt für eine bestimmte Gruppe mit einem Namenskonflikt fehl. | Es existiert bereits eine lokale (manuelle) Gruppe mit demselben Namen. Diese lokale Gruppe umbenennen oder – falls es sich tatsächlich um dieselbe Gruppe handeln soll – vor dem Import einen internen Namen dafür vergeben, dann erneut importieren. | | Ein AD-Gruppen-Import schlägt für eine bestimmte Gruppe mit einem Namenskonflikt fehl. | Es existiert bereits eine lokale (manuelle) Gruppe mit demselben Namen. Diese lokale Gruppe umbenennen oder – falls es sich tatsächlich um dieselbe Gruppe handeln soll – vor dem Import einen internen Namen dafür vergeben, dann erneut importieren. |
| Anwender melden, der Knopf „Fehler melden“ sage, es sei kein Postfach eingerichtet. | Feld „Fehlermeldungen an“ unter Administrator → SMTP ausfüllen und speichern (die 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 ausführen und das Serverprotokoll der API prüfen (Zeile „Bug report mail failed“). |
| Eine Test- oder Benachrichtigungs-E-Mail kommt nicht an. | SMTP-Konfiguration unter Administrator → SMTP prüfen (Host, Port, Verschlüsselungsart, Zugangsdaten, Absenderadresse) und über „Verbindung testen“ mit einer Test-Empfängeradresse erneut prüfen. | | Eine Test- oder Benachrichtigungs-E-Mail kommt nicht an. | SMTP-Konfiguration unter Administrator → SMTP prüfen (Host, Port, Verschlüsselungsart, Zugangsdaten, Absenderadresse) und über „Verbindung testen“ mit einer Test-Empfängeradresse erneut prüfen. |
| Ein Mandant lässt sich nicht löschen. | Der Mandant hat noch mindestens einen aktiven Benutzer. Alle Benutzer des Mandanten zunächst deaktivieren oder umziehen, dann erneut löschen. | | Ein Mandant lässt sich nicht löschen. | Der Mandant hat noch mindestens einen aktiven Benutzer. Alle Benutzer des Mandanten zunächst deaktivieren oder umziehen, dann erneut löschen. |
+17 -2
View File
@@ -16,7 +16,8 @@ Diese Anleitung richtet sich an alle Kolleginnen und Kollegen, die Tessera im Ar
- [Zertifikat-Manager](#zertifikat-manager) - [Zertifikat-Manager](#zertifikat-manager)
- [Domaincheck](#domaincheck) - [Domaincheck](#domaincheck)
7. [Persönliche Einstellungen](#persönliche-einstellungen) 7. [Persönliche Einstellungen](#persönliche-einstellungen)
8. [Häufige Stolpersteine](#häufige-stolpersteine) 8. [Einen Fehler melden](#einen-fehler-melden)
9. [Häufige Stolpersteine](#häufige-stolpersteine)
--- ---
@@ -39,7 +40,8 @@ Falls Ihr Administrator beim Anlegen Ihres Kontos eine Passwort-Änderung erzwun
Die Portal-Oberfläche gliedert sich in drei feste Bereiche: Die Portal-Oberfläche gliedert sich in drei feste Bereiche:
**Kopfleiste (oben)** **Kopfleiste (oben)**
Links steht das Tessera-Logo, in der Mitte der aktuelle Seitentitel. Rechts finden Sie zwei Bedienelemente: Links steht das Tessera-Logo, in der Mitte der aktuelle Seitentitel. Rechts finden Sie drei Bedienelemente:
- Einen Knopf **Fehler melden** (Käfer-Symbol) — siehe [Einen Fehler melden](#einen-fehler-melden).
- Einen Schalter zum Umschalten zwischen hellem und dunklem Erscheinungsbild (siehe [Persönliche Einstellungen](#persönliche-einstellungen)). - Einen Schalter zum Umschalten zwischen hellem und dunklem Erscheinungsbild (siehe [Persönliche Einstellungen](#persönliche-einstellungen)).
- Ihr Benutzersymbol (Avatar oder Ihr Anfangsbuchstabe). Ein Klick öffnet das **Benutzermenü** mit: - Ihr Benutzersymbol (Avatar oder Ihr Anfangsbuchstabe). Ein Klick öffnet das **Benutzermenü** mit:
- Ihrem Namen und Ihrer Rolle (Benutzer, Admin oder Super-Admin), - Ihrem Namen und Ihrer Rolle (Benutzer, Admin oder Super-Admin),
@@ -155,6 +157,18 @@ Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. G
**Sprache:** Unten in der Seitenleiste finden Sie die Sprachumschaltung zwischen Deutsch und Englisch. **Sprache:** Unten in der Seitenleiste finden Sie die Sprachumschaltung zwischen Deutsch und Englisch.
## Einen Fehler melden
Wenn etwas in Tessera nicht so funktioniert, wie Sie es erwarten, müssen Sie niemandem lange erklären, was Sie gesehen haben: Klicken Sie oben rechts in der Kopfleiste auf den Knopf **Fehler melden** (Käfer-Symbol). Tessera nimmt sofort ein Bild der aktuellen Seite auf — genau das, was Sie gerade sehen — und öffnet danach ein kleines Fenster mit einer Vorschau dieses Bildes.
In dem Fenster können Sie unter **Was ist passiert?** freiwillig eine Beschreibung eintragen. Je konkreter, desto schneller kann Ihnen geholfen werden: Was haben Sie getan, was haben Sie erwartet, und was ist stattdessen geschehen? Sie können das Feld auch leer lassen und nur das Bild schicken.
Das Häkchen **Bildschirmfoto beifügen** ist vorbelegt. Nehmen Sie es heraus, wenn auf der Seite etwas zu sehen ist, das nicht in einer E-Mail landen soll. **Bitte beachten Sie: Das Bild zeigt alles, was auf der Seite sichtbar ist — auch Namen, Zahlen oder Inhalte anderer Personen.** Die Meldung geht dann ohne Bild, aber mit allen übrigen Angaben.
Mit **Senden** gehen folgende Angaben als E-Mail an Ihren Administrator: das Bild (falls angehakt), Ihre Beschreibung, die Adresse der Seite, Versionsnummer und Kanal von Tessera, Ihr Browser und die Fenstergröße, der Zeitpunkt, Ihr Name, Benutzername und Ihre Rolle sowie die letzten Fehlermeldungen, die Ihr Browser im Hintergrund gesehen hat. Passwörter oder Eingaben in Formularen werden nicht mitgeschickt — außer dem, was im Bild sichtbar ist. In Tessera selbst wird nichts gespeichert; die Meldung existiert nur als E-Mail im Postfach, das Ihr Administrator eingerichtet hat.
Nach dem Senden erscheint „Vielen Dank, die Meldung wurde gesendet." Falls das nicht klappt, sagt Ihnen Tessera, warum: Entweder ist noch kein Postfach für Fehlermeldungen eingerichtet (dann sprechen Sie Ihren Administrator an), oder Sie haben in kurzer Zeit zu viele Meldungen geschickt (höchstens fünf in zehn Minuten), oder die E-Mail konnte gerade nicht gesendet werden (dann versuchen Sie es später noch einmal). Mit **Abbrechen** oder der Escape-Taste schließen Sie das Fenster, ohne etwas zu senden.
## Häufige Stolpersteine ## Häufige Stolpersteine
- **Die Anmeldung schlägt fehl, obwohl Passwort und E-Mail stimmen.** Prüfen Sie, ob Sie im Feld „Benutzername" tatsächlich Ihren Benutzernamen eingegeben haben — nicht Ihre E-Mail-Adresse. Das ist mit Abstand der häufigste Grund für eine scheinbar kaputte Anmeldung. - **Die Anmeldung schlägt fehl, obwohl Passwort und E-Mail stimmen.** Prüfen Sie, ob Sie im Feld „Benutzername" tatsächlich Ihren Benutzernamen eingegeben haben — nicht Ihre E-Mail-Adresse. Das ist mit Abstand der häufigste Grund für eine scheinbar kaputte Anmeldung.
@@ -163,4 +177,5 @@ Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. G
- **Nach der Anmeldung werden Sie sofort zur Passwort-Änderung gezwungen.** Das ist eine Sicherheitsmaßnahme, die der Administrator beim Anlegen Ihres Kontos aktiviert hat — vergeben Sie einfach ein neues Passwort, um fortzufahren. - **Nach der Anmeldung werden Sie sofort zur Passwort-Änderung gezwungen.** Das ist eine Sicherheitsmaßnahme, die der Administrator beim Anlegen Ihres Kontos aktiviert hat — vergeben Sie einfach ein neues Passwort, um fortzufahren.
- **Die Seitenleiste zeigt „Keine Module".** Für Sie sind noch keine Module freigegeben. Das ist normal für neu angelegte Konten — wenden Sie sich an Ihren Administrator. - **Die Seitenleiste zeigt „Keine Module".** Für Sie sind noch keine Module freigegeben. Das ist normal für neu angelegte Konten — wenden Sie sich an Ihren Administrator.
- **Im Ausschreibungs-Radar erscheinen nur sehr wenige Treffer.** Aktuell werden nur EU-weite Oberschwellen-Ausschreibungen erfasst; kleinere Unterschwellen-Vergaben fehlen noch. Das Hinweisbanner auf der Modulseite erklärt das. - **Im Ausschreibungs-Radar erscheinen nur sehr wenige Treffer.** Aktuell werden nur EU-weite Oberschwellen-Ausschreibungen erfasst; kleinere Unterschwellen-Vergaben fehlen noch. Das Hinweisbanner auf der Modulseite erklärt das.
- **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.
- **Zahlen, die Sie über „Meine Quellen" im Ausschreibungs-Radar eingebracht haben, tauchen in der Trefferliste aller Kollegen auf.** Das ist beabsichtigt — die Trefferliste ist für das ganze Unternehmen gemeinsam, nicht postfachbezogen getrennt. - **Zahlen, die Sie über „Meine Quellen" im Ausschreibungs-Radar eingebracht haben, tauchen in der Trefferliste aller Kollegen auf.** Das ist beabsichtigt — die Trefferliste ist für das ganze Unternehmen gemeinsam, nicht postfachbezogen getrennt.
+186 -9
View File
@@ -19,6 +19,7 @@ Betrieb der bereits laufenden Installation, nicht deren automatisierten Build.
6. [Sicherung und Wiederherstellung](#6-sicherung-und-wiederherstellung) 6. [Sicherung und Wiederherstellung](#6-sicherung-und-wiederherstellung)
7. [Protokolle und Fehlersuche](#7-protokolle-und-fehlersuche) 7. [Protokolle und Fehlersuche](#7-protokolle-und-fehlersuche)
8. [Abgrenzung zur CI/CD-Pipeline](#8-abgrenzung-zur-cicd-pipeline) 8. [Abgrenzung zur CI/CD-Pipeline](#8-abgrenzung-zur-cicd-pipeline)
9. [Zwei Kanäle: Live und Beta](#9-zwei-kanäle-live-und-beta)
--- ---
@@ -29,8 +30,8 @@ Tessera besteht aus drei Containern, definiert in `docker-compose.yml` (Basis) u
| Dienst | Image / Build | Host-Port | Zweck | | Dienst | Image / Build | Host-Port | Zweck |
|--------|---------------|-----------|-------| |--------|---------------|-----------|-------|
| `web` | `apps/web/Dockerfile` (Basis) bzw. `git.vicolab.de/schalli/tessera-ctl/web:latest` (Prod) | 3000 | Next.js-Frontend (Portal, Dashboard) | | `web` | `apps/web/Dockerfile` (Basis) bzw. `git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG}` (Prod; `beta` oder `live`, siehe Kapitel 9) | 3000 | Next.js-Frontend (Portal, Dashboard) |
| `api` | `apps/api/Dockerfile` (Basis) bzw. `git.vicolab.de/schalli/tessera-ctl/api:latest` (Prod) | 3001 | NestJS-Backend (REST-API, Prisma/PostgreSQL-Zugriff) | | `api` | `apps/api/Dockerfile` (Basis) bzw. `git.vicolab.de/schalli/tessera-ctl/api:${IMAGE_TAG}` (Prod; `beta` oder `live`, siehe Kapitel 9) | 3001 | NestJS-Backend (REST-API, Prisma/PostgreSQL-Zugriff) |
| `db` | `postgres:16-alpine` | kein Host-Port | PostgreSQL-Datenbank | | `db` | `postgres:16-alpine` | kein Host-Port | PostgreSQL-Datenbank |
Zusätzlich existiert `docker-compose.dev.yml` (Bind-Mounts für Live-Reload, Zusätzlich existiert `docker-compose.dev.yml` (Bind-Mounts für Live-Reload,
@@ -157,8 +158,10 @@ Zugangsdaten.
| `TESSERA_ADMIN_PASSWORD` | ja (für den Seed) | – | Initiales Passwort des Super-Admin. | | `TESSERA_ADMIN_PASSWORD` | ja (für den Seed) | – | Initiales Passwort des Super-Admin. |
| `TESSERA_FORCE_CHANGE` | nein | `true` | Erzwingt Passwortwechsel beim ersten Login des geseedeten Admin-Accounts. | | `TESSERA_FORCE_CHANGE` | nein | `true` | Erzwingt Passwortwechsel beim ersten Login des geseedeten Admin-Accounts. |
| `TESSERA_SMTP_HOST` / `_PORT` / `_SECURE` / `_USER` / `_PASSWORD` / `_FROM` | nein (aber ohne Host kein Mailversand) | Host leer, Port `587`, `_SECURE=false` | SMTP-Relay für ausgehende Mails (Passwort-Reset, Benachrichtigungen). | | `TESSERA_SMTP_HOST` / `_PORT` / `_SECURE` / `_USER` / `_PASSWORD` / `_FROM` | nein (aber ohne Host kein Mailversand) | Host leer, Port `587`, `_SECURE=false` | SMTP-Relay für ausgehende Mails (Passwort-Reset, Benachrichtigungen). |
| `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. |
| `APP_URL` | empfohlen | `http://localhost:3001` (für `NEXT_PUBLIC_API_URL`) / `http://localhost:3000` (für `TESSERA_APP_URL`) | Öffentliche Basis-URL der Web-Oberfläche. Wird serverseitig u. a. für in E-Mails generierte Links verwendet (`TESSERA_APP_URL`). | | `APP_URL` | empfohlen | `http://localhost:3001` (für `NEXT_PUBLIC_API_URL`) / `http://localhost:3000` (für `TESSERA_APP_URL`) | Öffentliche Basis-URL der Web-Oberfläche. Wird serverseitig u. a. für in E-Mails generierte Links verwendet (`TESSERA_APP_URL`). |
| `API_INTERNAL_URL` | fest verdrahtet | `http://api:3001` | Adresse, unter der `web` die API **innerhalb** des Docker-Netzes erreicht; dorthin schreibt Next.js die `/api-proxy/*`-Rewrites um. In der Regel nicht ändern. | | `API_INTERNAL_URL` | fest verdrahtet | `http://api:3001` | Adresse, unter der `web` die API **innerhalb** des Docker-Netzes erreicht; dorthin schreibt Next.js die `/api-proxy/*`-Rewrites um. In der Regel nicht ändern. |
| `IMAGE_TAG` | empfohlen | `beta` | Welcher Kanal auf diesem Server läuft: `beta` (alle Neuerungen, alpha) oder `live` (nur freigegebene Versionen, tessera.ctl.de). Siehe Kapitel 9. |
Hinweis zu `NEXT_PUBLIC_API_URL`: Diese Variable wird beim Image-Build bereits fest Hinweis zu `NEXT_PUBLIC_API_URL`: Diese Variable wird beim Image-Build bereits fest
auf `/api-proxy` gesetzt (`ENV NEXT_PUBLIC_API_URL=/api-proxy` in auf `/api-proxy` gesetzt (`ENV NEXT_PUBLIC_API_URL=/api-proxy` in
@@ -191,7 +194,7 @@ docker compose -f docker-compose.prod.yml up -d --force-recreate api web
**Verifizierte Falle:** `docker compose up -d` **ohne** `--force-recreate` ersetzt **Verifizierte Falle:** `docker compose up -d` **ohne** `--force-recreate` ersetzt
einen bereits laufenden Container **nicht**, wenn Compose der Meinung ist, an der einen bereits laufenden Container **nicht**, wenn Compose der Meinung ist, an der
Service-Definition habe sich nichts geändert – auch wenn `pull` gerade ein neues Service-Definition habe sich nichts geändert – auch wenn `pull` gerade ein neues
Image unter demselben Tag (`:latest`) heruntergeladen hat. Der alte Container läuft Image unter demselben Etikett (`beta` bzw. `live`) heruntergeladen hat. Der alte Container läuft
dann unverändert mit dem alten Code weiter, ohne Fehlermeldung. Das Deployment dann unverändert mit dem alten Code weiter, ohne Fehlermeldung. Das Deployment
wirkt erfolgreich, ist es aber nicht. Dieser Fehler ist dem Team schon mehrfach wirkt erfolgreich, ist es aber nicht. Dieser Fehler ist dem Team schon mehrfach
passiert. Deshalb: nach jedem `pull` **immer** `--force-recreate` verwenden (nur passiert. Deshalb: nach jedem `pull` **immer** `--force-recreate` verwenden (nur
@@ -204,12 +207,15 @@ Erstellungszeitpunkt des zugehörigen Images vergleichen. Der Container muss
```bash ```bash
docker inspect -f '{{.State.StartedAt}}' tessera-api-1 docker inspect -f '{{.State.StartedAt}}' tessera-api-1
docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/api:latest docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/api:beta
docker inspect -f '{{.State.StartedAt}}' tessera-web-1 docker inspect -f '{{.State.StartedAt}}' tessera-web-1
docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/web:latest docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/web:beta
``` ```
(Auf dem Live-Server statt `:beta` jeweils `:live` einsetzen – das Etikett, das in
der `.env` als `IMAGE_TAG` steht, siehe Kapitel 9.)
(Container-Namen mit `docker compose -f docker-compose.prod.yml ps` prüfen, falls (Container-Namen mit `docker compose -f docker-compose.prod.yml ps` prüfen, falls
sie auf dem Server abweichen.) Liegt `StartedAt` **vor** `Created` des Images, läuft sie auf dem Server abweichen.) Liegt `StartedAt` **vor** `Created` des Images, läuft
noch die alte Version – dann `--force-recreate` nachholen. noch die alte Version – dann `--force-recreate` nachholen.
@@ -317,8 +323,10 @@ docker compose -f docker-compose.prod.yml logs -f db
**Gesunder Start sieht so aus:** `db` wird `healthy`, danach startet `api` und **Gesunder Start sieht so aus:** `db` wird `healthy`, danach startet `api` und
protokolliert die durchgelaufenen Prisma-Migrationen sowie zuletzt protokolliert die durchgelaufenen Prisma-Migrationen sowie zuletzt
`Tessera API running on port 3001` (aus `apps/api/src/main.ts`). Erst danach startet `Tessera API running on port 3001` und direkt darunter eine Zeile wie
`web`, weil `depends_on: api: condition: service_healthy` das erzwingt. `Tessera API v1.0.0 (live) abc1234` (beides aus `apps/api/src/main.ts`) – Version,
Kanal und Kurzkennung des Standes, der gerade läuft (siehe Kapitel 9). Erst danach
startet `web`, weil `depends_on: api: condition: service_healthy` das erzwingt.
| Symptom | Wahrscheinliche Ursache | Prüfen / Beheben | | Symptom | Wahrscheinliche Ursache | Prüfen / Beheben |
|---|---|---| |---|---|---|
@@ -329,6 +337,7 @@ protokolliert die durchgelaufenen Prisma-Migrationen sowie zuletzt
| Neue Version scheint nicht anzukommen, obwohl `pull` gelaufen ist | Klassische `up -d`-Falle ohne `--force-recreate` (siehe Kapitel 4) | `StartedAt` des Containers gegen `Created` des Images vergleichen, ggf. `--force-recreate` nachholen. | | Neue Version scheint nicht anzukommen, obwohl `pull` gelaufen ist | Klassische `up -d`-Falle ohne `--force-recreate` (siehe Kapitel 4) | `StartedAt` des Containers gegen `Created` des Images vergleichen, ggf. `--force-recreate` nachholen. |
| Initialer Admin-Login funktioniert nicht nach Änderung von `TESSERA_ADMIN_PASSWORD` | Seed läuft nur, wenn der Benutzername noch **nicht** existiert; bestehende Accounts werden nicht überschrieben | Passwort über die Anwendung selbst (bzw. direkt in der Datenbank) ändern, nicht über die `.env`-Variable. | | Initialer Admin-Login funktioniert nicht nach Änderung von `TESSERA_ADMIN_PASSWORD` | Seed läuft nur, wenn der Benutzername noch **nicht** existiert; bestehende Accounts werden nicht überschrieben | Passwort über die Anwendung selbst (bzw. direkt in der Datenbank) ändern, nicht über die `.env`-Variable. |
| Mails werden nicht versendet | `TESSERA_SMTP_HOST` leer (Prod-Default) | SMTP-Variablen vollständig setzen und Container neu erstellen. | | Mails werden nicht versendet | `TESSERA_SMTP_HOST` leer (Prod-Default) | SMTP-Variablen vollständig setzen und Container neu erstellen. |
| Fehlermeldungen der Anwender kommen nicht an | Kein Postfach gesetzt (weder „Fehlermeldungen an“ unter Administrator → SMTP noch `TESSERA_BUGREPORT_TO`), oder der SMTP-Versand des Mandanten scheitert | 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). |
| Avatare/DKV-Exporte nach einem Deploy verschwunden | Die verwendete Compose-Datei mountet `user-files/` nicht als Volume – im Repository-Stand seit dieser Version behoben, betrifft nur eine Installation mit abweichender Compose-Datei | Die zwei Zeilen aus Kapitel 6 in die verwendete Compose-Datei eintragen (auf dem Server: `/opt/tessera/docker-compose.yml`, vorher sichern) und `api` neu erstellen. | | Avatare/DKV-Exporte nach einem Deploy verschwunden | Die verwendete Compose-Datei mountet `user-files/` nicht als Volume – im Repository-Stand seit dieser Version behoben, betrifft nur eine Installation mit abweichender Compose-Datei | Die zwei Zeilen aus Kapitel 6 in die verwendete Compose-Datei eintragen (auf dem Server: `/opt/tessera/docker-compose.yml`, vorher sichern) und `api` neu erstellen. |
## 8. Abgrenzung zur CI/CD-Pipeline ## 8. Abgrenzung zur CI/CD-Pipeline
@@ -342,5 +351,173 @@ Berechtigungen des `act_runner`-Docker-Socket-Mounts) aufgesetzt wird, ist bewus
nicht Teil dieses Dokuments – das steht vollständig in nicht Teil dieses Dokuments – das steht vollständig in
[`docs/ci-cd-setup.md`](./ci-cd-setup.md). Die Grenze zwischen beiden Dokumenten: [`docs/ci-cd-setup.md`](./ci-cd-setup.md). Die Grenze zwischen beiden Dokumenten:
Sobald ein neues Image lokal vorliegt oder in einer Registry verfügbar ist, beginnt Sobald ein neues Image lokal vorliegt oder in einer Registry verfügbar ist, beginnt
dieses Betriebshandbuch (Kapitel 4); alles davor – wie das Image entsteht – gehört dieses Betriebshandbuch (Kapitel 4 und 9); alles davor – wie das Image entsteht –
in das CI/CD-Runbook. gehört in das CI/CD-Runbook.
## 9. Zwei Kanäle: Live und Beta
Seit September 2026 gibt es Tessera in zwei Ausgaben, die getrennt voneinander
laufen. Dieses Kapitel erklärt, was das bedeutet, welche Zeile auf welchem Server
stehen muss, wie eine Version freigegeben wird, wie ein dringender Fehler auf Live
behoben wird, und wie Sie jederzeit sehen, welche Fassung gerade läuft.
### Was ein Kanal ist
Ein Kanal ist eine Ausgabe von Tessera, die auf einem bestimmten Server läuft und
nach eigenen Regeln neue Stände bekommt. Es gibt zwei:
- **Beta** – alles Neue, sofort nach jeder Änderung. Läuft unter
`alpha.tessera.ctl.de`. Das Etikett (die Kennzeichnung des Docker-Images in der
Registry) heißt `beta`. Das ältere Etikett `latest` ist nur ein zweiter Name für
genau dasselbe Beta-Image; es bleibt vorerst bestehen, damit nichts kaputtgeht,
und kann später wegfallen.
- **Live** – nur freigegebene Versionen mit einer Nummer. Läuft unter
`tessera.ctl.de` auf dem neuen Server. Das Etikett heißt `live`; zusätzlich trägt
jede freigegebene Version ihre Nummer als eigenes Etikett (`v1.0.0`, `v1.0.1`, …),
damit man jederzeit auch einen älteren Stand gezielt holen kann.
Die Versionsnummer kommt aus der Freigabe (in Git heißt das „Tag“ – eine Markierung
an einem bestimmten Stand), nicht aus einer Datei im Code. Zwischen zwei Freigaben
zeigt die Beta eine Kennung wie `v1.0.0-12-gabc1234`: das bedeutet „12 Änderungen
nach Version 1.0.0, Stand abc1234“. Vor der allerersten Freigabe steht dort nur die
Kurzkennung des Standes (sieben Zeichen, z. B. `abc1234`).
### Die eine Zeile je Server
Welchen Kanal ein Server bekommt, entscheidet **eine einzige Zeile** in der Datei
`/opt/tessera/.env`:
- auf **alpha** (Beta): `IMAGE_TAG=beta`
- auf dem **neuen Live-Server**: `IMAGE_TAG=live`
Fehlt die Zeile ganz, nimmt die Compose-Datei von selbst `beta`. Für den Live-Server
ist die Zeile also Pflicht, sonst zieht er die Beta.
Weil `/opt/tessera` keine Arbeitskopie des Repositorys ist (siehe Kapitel 3,
„Konfigurationsdrift“), muss die Compose-Datei auf dem Server einmal von Hand
angepasst werden. Auf alpha ist das die Datei `/opt/tessera/docker-compose.prod.yml`
(die `.env` dort verweist mit `COMPOSE_FILE` auf sie). Zuerst eine Sicherung:
```bash
cd /opt/tessera
cp docker-compose.prod.yml docker-compose.prod.yml.bak.$(date +%Y%m%d)
```
Dann die zwei `image:`-Zeilen (eine beim Dienst `web`, eine beim Dienst `api`) auf
diese Form bringen – der einzige Unterschied zu heute ist das Ende der Zeile:
```yaml
image: git.vicolab.de/schalli/tessera-ctl/web:${IMAGE_TAG:-beta}
```
```yaml
image: git.vicolab.de/schalli/tessera-ctl/api:${IMAGE_TAG:-beta}
```
Danach wie in Kapitel 4:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --force-recreate api web
```
Hinweis: Die Vorlage `.env.prod.example` im Repository enthält die Zeile
`IMAGE_TAG` noch nicht. Wer eine neue `.env` aus der Vorlage anlegt, ergänzt die
Zeile von Hand.
### Eine Version freigeben
Das Freigeben erledigt Claude; Sie sagen nur „Version X freigeben“. Zur Einordnung,
was dabei passiert:
```bash
git checkout live
git merge --ff-only main
git tag -a vX.Y.Z -m "Tessera X.Y.Z"
git push origin live vX.Y.Z
```
Der zweite Befehl übernimmt den Stand der Beta in den Live-Zweig. Wenn er sich
weigert, ist eine frühere Korrektur (siehe Hotfix, Schritt 5) noch nicht zurück in
`main` – dann wird erst das nachgeholt. Der Push löst die Pipeline zweimal aus: der
Zweig `live` wird nur geprüft, der Tag `vX.Y.Z` wird gebaut und als `live` und
`vX.Y.Z` abgelegt. Das dauert etwa vier bis sechs Minuten.
Danach spielen Sie die Version auf dem Live-Server ein – Kapitel 4 gilt unverändert:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --force-recreate api web
```
**Erstfreigabe v1.0.0:** Den Zweig `live` gibt es noch nicht. Er entsteht beim
ersten Mal aus `main` (`git checkout -b live main`), bekommt den Tag `v1.0.0` und
wird zusammen mit dem Tag gepusht. Das erfolgt, sobald der Knopf „Fehler melden“
eingebaut ist – nicht in diesem Durchlauf.
### Einen Fehler auf Live beheben (Hotfix)
Ein Hotfix ist eine kleine Korrektur, die auf Live landen muss, **ohne** die
Neuerungen der Beta mitzunehmen. Ablauf (Claude führt die Git-Schritte aus, Sie
spielen ein):
1. Den Live-Stand holen: `git checkout live && git pull`.
2. Einen Korrekturzweig `hotfix/<kurzer-name>` von `live` anlegen.
3. Die Korrektur machen und die Tests laufen lassen.
4. Nach `live` mergen, die nächste Nummer vergeben (`vX.Y.(Z+1)`, also z. B.
`v1.0.1` nach `v1.0.0`) und beides pushen: `git push origin live vX.Y.(Z+1)`.
Danach spielen Sie die Version auf dem Live-Server ein (Befehle wie oben).
5. Die Korrektur in die Beta übernehmen: `git checkout main && git merge live`.
Vorher wird geprüft, ob die Korrektur dort noch zusammenpasst (Konflikte, Tests),
dann `git push` – die Beta bekommt sie mit dem nächsten Pipeline-Lauf.
**Keine Datenbankänderung als Hotfix.** Der Grund in Alltagssprache:
Datenbankänderungen (Migrationen) tragen einen Zeitstempel im Namen und werden in
dieser Reihenfolge ausgeführt. Die Beta hat womöglich schon neuere Änderungen
eingespielt. Eine Hotfix-Änderung mit noch späterem Zeitstempel landet beim
Übernehmen in die Beta hinter Änderungen, die sie eigentlich nicht kennt – das ist
der eine Fall, der beim Zusammenführen still kaputtgehen kann. Braucht eine Korrektur
eine Datenbankänderung, wird sie als reguläre Version über `main` freigegeben.
### Woran Sie erkennen, welche Version läuft
Drei Wege, vom einfachsten zum genauesten:
1. **In der Oberfläche:** Unten in der Seitenleiste steht `v1.0.0 · Live` bzw.
`v1.0.0-12-gabc1234 · Beta`. Wenn Sie die Maus darüber halten, erscheinen die
Kurzkennung des Standes und die Version, die der Server meldet. Weichen
Oberfläche und Server voneinander ab, wurde nur einer der beiden Container neu
erstellt – dann Kapitel 4 anwenden (`--force-recreate api web`).
2. **Auf dem Server per Abfrage:**
```bash
curl -s http://localhost:3001/health/version
```
Die Antwort enthält die Felder `version`, `channel` (`beta` oder `live`),
`commit` (Kurzkennung) und `buildTime` (wann das Image gebaut wurde).
3. **Im Protokoll:**
```bash
docker compose -f docker-compose.prod.yml logs api | grep "Tessera API"
```
Zeigt die Startzeile `Tessera API v1.0.0 (live) abc1234` (siehe Kapitel 7).
### Den neuen Live-Server einrichten
Kapitel 2 gilt vollständig. Die Abweichungen gegenüber alpha:
- In der `.env` steht `IMAGE_TAG=live`.
- Eigene, neu erzeugte Geheimnisse: `JWT_SECRET`, `TESSERA_ENCRYPTION_KEY`,
`DB_PASSWORD` und das Admin-Passwort. Nichts davon von alpha übernehmen.
- Eine eigene, leere Datenbank. Die API legt beim ersten Start den ersten Admin an
(Kapitel 2, Schritt 4). Die alpha-Datenbank wird **nicht** kopiert – es sei denn,
das wird ausdrücklich gewünscht. In diesem Fall gilt Kapitel 6 (Wiederherstellung)
**und** der Live-Server muss denselben `TESSERA_ENCRYPTION_KEY` wie alpha
bekommen, sonst sind alle gespeicherten Zugangsdaten unbrauchbar.
- `APP_URL=https://tessera.ctl.de`.
- Der erste `pull` holt das Etikett `live`. Vor der Erstfreigabe v1.0.0 gibt es
dieses Etikett noch nicht – deshalb erst freigeben, dann installieren. Für einen
Probelauf davor kann vorübergehend `IMAGE_TAG=beta` stehen; danach auf `live`
umstellen und `pull` + `up -d --force-recreate api web` wiederholen.
+81 -19
View File
@@ -80,11 +80,24 @@ docker ps --filter name=gitea-runner
### Gitea Secrets (fuer die CI-Pipeline) ### Gitea Secrets (fuer die CI-Pipeline)
In Gitea unter **Repository > Settings > Actions > Secrets** koennen Secrets In Gitea unter **Repository > Settings > Actions > Secrets** werden die Secrets
fuer die Pipeline konfiguriert werden. Aktuell werden keine Secrets in der fuer die Pipeline konfiguriert. Benoetigt wird genau eines:
Pipeline benoetigt, da Images lokal gebaut und deployed werden (kein Registry-Push).
Falls kuenftig Deploy-Pfade oder Credentials benoetigt werden: | Secret | Beschreibung |
|--------|--------------|
| `REGISTRY_TOKEN` | Gitea-Zugangstoken (Access Token) mit Schreibrecht auf Pakete (`package: write`). Wird im Job `publish` fuer `docker login localhost:3002 --password-stdin` verwendet. |
Das Token erscheint nie im Log: es wird per `--password-stdin` uebergeben und
Gitea maskiert Secret-Werte in der Job-Ausgabe. Das Veroeffentlichungs-Skript
`.gitea/scripts/publish-images.sh` kennt das Token nicht; der Login bleibt im
Workflow.
Der Push geht ueber `localhost:3002` (Gitea laeuft auf demselben Rechner wie der
Runner), weil der Nginx Proxy Manager vor `git.vicolab.de` grosse Image-Blobs
blockt. Das Pullen auf den Servern laeuft ueber `git.vicolab.de`
(`docker-compose.prod.yml`).
Weitere Secrets bei Bedarf:
1. In Gitea **Settings > Actions > Secrets** den Secret anlegen 1. In Gitea **Settings > Actions > Secrets** den Secret anlegen
2. In `.gitea/workflows/ci.yml` ueber `${{ secrets.SECRET_NAME }}` referenzieren 2. In `.gitea/workflows/ci.yml` ueber `${{ secrets.SECRET_NAME }}` referenzieren
@@ -94,27 +107,62 @@ hardcoden.
## 4. Pipeline-Ueberblick ## 4. Pipeline-Ueberblick
Die CI/CD-Pipeline (`.gitea/workflows/ci.yml`) wird bei jedem Push auf `main` Die CI/CD-Pipeline (`.gitea/workflows/ci.yml`) laeuft bei jedem Push auf die
ausgefuehrt und besteht aus drei aufeinander aufbauenden Jobs: Zweige `main` und `live` sowie bei jedem Tag `v*` (z. B. `v1.0.0`) und besteht
aus drei aufeinander aufbauenden Jobs:
1. **quality** -- Lint (Biome) und TypeScript Type-Check 1. **quality** -- Lint und TypeScript Type-Check (Lint ist derzeit ein Leerlauf,
siehe WINDOWS #35; der Type-Check ist echt)
2. **test** -- Vitest Unit- und Integrationstests 2. **test** -- Vitest Unit- und Integrationstests
3. **build-deploy** -- Docker Images bauen und Services neu starten 3. **publish** -- Docker Images mit Versionsstempel bauen und in die Gitea-Registry
veroeffentlichen
Ablauf: `quality` -> `test` -> `build-deploy` (jeder Job nur bei Erfolg des Ablauf: `quality` -> `test` -> `publish` (jeder Job nur bei Erfolg des
vorherigen). vorherigen). Der Job `publish` besteht aus drei Schritten: `actions/checkout@v4`
mit `fetch-depth: 0` (volle Historie samt Tags, sonst liefert `git describe`
nichts), Login in die Registry (siehe Abschnitt 3) und der Aufruf von
`.gitea/scripts/publish-images.sh`.
### Kein Registry-Push ### Zwei Kanaele: Etiketten je Anlass
Images werden **lokal auf dem Server gebaut** und nicht in eine Registry Das Skript `.gitea/scripts/publish-images.sh` entscheidet allein anhand
gepusht (D-13). Da der Runner und die Applikation auf demselben Server laufen, `GITHUB_REF`, ob und unter welchen Etiketten veroeffentlicht wird:
baut die Pipeline die Images direkt mit `docker compose build` und startet die
Services mit `docker compose up -d` neu.
Vorteile: | Anlass | Kanal (`APP_CHANNEL`) | Etiketten in der Registry |
- Keine Registry-Infrastruktur noetig |--------|----------------------|---------------------------|
- Schnellerer Deploy (kein Push/Pull ueber Netzwerk) | Push auf `main` | `beta` | `beta` und `latest` (`latest` ist nur ein Alias fuer `beta` und entfaellt spaeter) |
- Einfachere Konfiguration | Tag `vX.Y.Z` | `live` | `live` und `vX.Y.Z` |
| Push auf `live` ohne Tag | -- | keine; der Lauf prueft nur (`quality`, `test`), das Skript endet mit "nichts zu tun" |
Das Kanalmodell fuer den Betrieb (welcher Server welches Etikett zieht, Freigabe,
Hotfix) steht in `docs/anleitung-betrieb.md`, Kapitel 9.
### Versionsstempel
Das Skript berechnet vier Werte und gibt sie als `--build-arg` an beide
Dockerfiles (`apps/web/Dockerfile`, `apps/api/Dockerfile`):
| Build-Arg | Quelle |
|-----------|--------|
| `APP_VERSION` | `git describe --tags --always` (ohne Tag: kurzer Commit-SHA) |
| `APP_CHANNEL` | `beta` oder `live`, siehe Tabelle oben |
| `APP_COMMIT` | `git rev-parse --short HEAD` |
| `APP_BUILD_TIME` | `date -u`, ISO-Format |
Die API liest die Werte zur Laufzeit (`GET /health/version`, Startzeile im Log).
Das Web-Image bettet `NEXT_PUBLIC_APP_*` beim Build in das Browser-Bundle ein --
deshalb laeuft die `builder`-Stufe des Web-Images jetzt bei jedem Pipeline-Lauf
neu, die Laufzeit liegt eher bei 4-6 statt 2 Minuten.
Lokale Probe ohne Docker-Aufruf:
```bash
GITHUB_REF=refs/tags/v1.2.3 sh .gitea/scripts/publish-images.sh --print-plan
```
Hinweis: Die fruehere Entscheidung D-13 (Images lokal bauen, keine Registry) ist
ueberholt -- seit der Einfuehrung der Gitea-Registry werden Images gepusht und von
den Servern per `docker compose pull` geholt.
## 5. Sicherheitshinweise ## 5. Sicherheitshinweise
@@ -146,6 +194,12 @@ Workflow-Dateien in `.gitea/workflows/` werden direkt aus dem Repository
geladen. Da nur vertrauenswuerdiger Code gepusht wird (D-11, Claude als geladen. Da nur vertrauenswuerdiger Code gepusht wird (D-11, Claude als
einziger Committer), ist das Risiko einer manipulierten Pipeline minimal. einziger Committer), ist das Risiko einer manipulierten Pipeline minimal.
Ein Tag-Push `v*` ist der Freigabe-Hebel fuer Live: wer ihn setzen darf, kann
das `live`-Etikett neu belegen. Heute hat nur das Konto `schalli` Schreibrecht
(0 Kollaborateure, keine Branch-Regeln). Kommen weitere Konten dazu, in Gitea
unter **Repository > Settings > Branches / Tags** eine Tag-Schutzregel fuer `v*`
und einen Branch-Schutz fuer `live` anlegen (T-KU1-04).
## 6. Fehlerbehebung ## 6. Fehlerbehebung
### Runner registriert sich nicht ### Runner registriert sich nicht
@@ -167,3 +221,11 @@ einziger Committer), ist das Risiko einer manipulierten Pipeline minimal.
erreichbar ist erreichbar ist
2. Docker Daemon Status pruefen: `docker info` 2. Docker Daemon Status pruefen: `docker info`
3. Disk Space pruefen: `df -h` 3. Disk Space pruefen: `df -h`
### Stempel zeigt `dev` oder nur eine Kurzkennung statt des Tags
1. Im Job `publish` pruefen, dass `actions/checkout@v4` mit `fetch-depth: 0`
auscheckt -- ohne Tags liefert `git describe --tags --always` nur den SHA
2. Pruefen, ob der Tag wirklich gepusht wurde: `git ls-remote --tags origin`
3. `dev` bedeutet: das Image wurde ohne Build-Args gebaut (lokal statt ueber
das Skript) -- das ist fuer lokale Builds normal
@@ -173,6 +173,7 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg | | calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet | | tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile | | favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
| settings | 0 | 3 | 0 | **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | | settings | 0 | 3 | 0 | **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
| **Summe** | **61** | **179** | **5** | **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | | **Summe** | **61** | **179** | **5** | **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
@@ -660,6 +661,7 @@ werden.
|---|---|---|---|---| |---|---|---|---|---|
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | | apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). | | apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). |
| 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. |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. | | apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. | | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
+13
View File
@@ -4,3 +4,16 @@ export interface HealthResponse {
status: string; status: string;
timestamp: string; timestamp: string;
} }
/**
* Antwort von `GET /health/version` (quick-260914-ku1).
* `channel` ist in der Praxis `beta` | `live` | `dev`; die Wahrheit der
* Version ist der Git-Tag (`git describe`), nicht eine package.json.
*/
export interface VersionResponse {
name: string;
version: string;
channel: string;
commit: string;
buildTime: string;
}
+8
View File
@@ -184,6 +184,9 @@ importers:
fflate: fflate:
specifier: ^0.8.3 specifier: ^0.8.3
version: 0.8.3 version: 0.8.3
html-to-image:
specifier: 1.11.13
version: 1.11.13
jose: jose:
specifier: ^6.2.3 specifier: ^6.2.3
version: 6.2.3 version: 6.2.3
@@ -3394,6 +3397,9 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html-to-text@10.0.0: html-to-text@10.0.0:
resolution: {integrity: sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==} resolution: {integrity: sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
@@ -8928,6 +8934,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@noble/hashes' - '@noble/hashes'
html-to-image@1.11.13: {}
html-to-text@10.0.0: html-to-text@10.0.0:
dependencies: dependencies:
'@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0) '@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0)