docs(quick-260925-bow): Was-ist-neu-Fenster
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m13s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 09:01:48 +02:00
parent 59e9c34fa7
commit acfffa3097
3 changed files with 495 additions and 1 deletions
@@ -0,0 +1,326 @@
---
phase: quick-260925-bow
plan: 01
quick_id: 260925-bow
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260925-bow]
files_modified:
- packages/shared/src/index.ts
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql (neu)
- apps/api/src/health/app-version.ts
- apps/api/src/health/release-version.spec.ts (neu)
- apps/api/src/user/dto/release-seen.dto.ts (neu)
- apps/api/src/user/user.controller.ts
- apps/api/src/user/user.controller.spec.ts
- apps/api/src/user/user.service.ts
- apps/api/src/user/user.service.spec.ts
- apps/api/src/user/admin-seed.service.ts
- apps/api/src/user/admin-seed.service.spec.ts
- apps/web/src/lib/release-notes.ts (neu)
- apps/web/src/lib/release-notes.test.ts (neu)
- apps/web/src/lib/release-notice-actions.ts (neu)
- apps/web/src/lib/release-notice-actions.test.ts (neu)
- apps/web/src/components/release-notice/release-notice-dialog.tsx (neu)
- apps/web/src/components/release-notice/release-notice-dialog.test.tsx (neu)
- apps/web/src/components/release-notice/release-notice-host.tsx (neu)
- apps/web/src/components/release-notice/release-notice-host.test.tsx (neu)
- apps/web/src/components/changelog/changelog-view.tsx
- apps/web/src/components/layout/app-shell.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/lib/changelog.ts (nur Kopfkommentar)
- apps/web/next.config.ts (nur Kommentar)
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
- CHANGELOG.md
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "Wer sich nach einem Versionswechsel zum ersten Mal im Portal anmeldet (laufende freigegebene Version liegt über der zuletzt gesehenen), sieht einmalig ein Fenster „Neu in Version X.Y.Z“ mit den Gruppen „Neu“, „Verbessert“ (aus Geändert) und „Behoben“ aus CHANGELOG.md, neueste Version zuerst (D-02, D-03, D-07)"
- "„Verstanden“, das Schließen-Kreuz, Escape, ein Klick auf den abgedunkelten Hintergrund oder auf „Alle Änderungen ansehen“ schließen das Fenster und merken die Version dauerhaft pro Benutzer in der Datenbank – im Browser und in der Desktop-App erscheint es danach nicht mehr; gemerkt wird erst beim Schließen, nie beim Öffnen (D-01, D-05)"
- "Wer mehrere Versionen verpasst hat, sieht höchstens die drei neuesten; bei mehr steht ein Satz mit der Zahl der weiteren Versionen im Fenster; unten steht immer der Link „Alle Änderungen ansehen“ zu /changelog (D-03)"
- "Bestandsbenutzer ohne gemerkten Stand sehen nur die laufende Version; Benutzer, die ein Administrator anlegt, die der LDAP-Abgleich anlegt, und der Erst-Administrator bekommen bei der Anlage die laufende Version eingetragen und sehen kein Fenster bis zur nächsten Version (D-04)"
- "Ohne gültige freigegebene Versionsnummer (lokaler Stand „dev“, bloßer Commit-Stempel) erscheint nie ein Fenster; auf der Anmeldeseite und auf /change-password erscheint es auch nicht; der Erststart-Dialog der Desktop-App und die Bildschirmfoto-Funktion von „Fehler melden“ bleiben unberührt (D-02, D-08)"
- "Der Server nimmt als „gesehen“ nur eine wohlgeformte Version X.Y.Z an, die nicht über der laufenden liegt, senkt einen gemerkten Stand nie ab und ändert ausschließlich die Zeile des angemeldeten Benutzers in dessen Mandanten (D-05)"
- "Der Text der Änderungsliste bleibt im Server-Bundle; in den öffentlich abrufbaren Client-Chunks unter /_next/static steht er nicht (Bestandsregel aus quick-260916-dcz)"
artifacts:
- path: "packages/shared/src/index.ts"
provides: "parseReleaseVersion, compareReleaseVersions, ReleaseNoticeResponse — eine Implementierung für API und Web (D-02, D-06)"
contains: "export function parseReleaseVersion"
- path: "apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql"
provides: "nullbare Spalte User.lastSeenReleaseVersion (D-01)"
contains: "lastSeenReleaseVersion"
- path: "apps/api/src/health/app-version.ts"
provides: "getRunningRelease() — einzige Quelle der laufenden Version (API-APP_VERSION)"
contains: "export function getRunningRelease"
- path: "apps/api/src/user/user.controller.ts"
provides: "GET /users/me/release-notice, POST /users/me/release-seen"
contains: "me/release-seen"
- path: "apps/web/src/lib/release-notes.ts"
provides: "selectReleaseNotice — reine Auswahl der Versionsabschnitte (Bereich, Deckel 3, null, unparsbar)"
contains: "export function selectReleaseNotice"
- path: "apps/web/src/lib/release-notice-actions.ts"
provides: "Server-Aktionen fetchReleaseNotice / markReleaseSeenAction ('use server')"
contains: "'use server'"
- path: "apps/web/src/components/release-notice/release-notice-dialog.tsx"
provides: "barrierefreies Fenster (role=dialog, aria-modal, Fokusfalle, Escape)"
contains: "aria-modal"
- path: "apps/web/src/components/release-notice/release-notice-host.tsx"
provides: "lädt die Nachricht einmal je Seitenladung im Portal-Rahmen und merkt beim Schließen"
contains: "markReleaseSeenAction"
key_links:
- from: "apps/web/src/components/layout/app-shell.tsx"
to: "apps/web/src/components/release-notice/release-notice-host.tsx"
via: "<ReleaseNoticeHost /> im Portal-Rahmen (nur (portal)-Layout, nie /login)"
pattern: "ReleaseNoticeHost"
- from: "apps/web/src/lib/release-notice-actions.ts"
to: "GET /users/me/release-notice"
via: "fetch mit Session-Cookie über API_INTERNAL_URL, danach selectReleaseNotice(changelogMarkdown, …)"
pattern: "users/me/release-notice"
- from: "apps/web/src/components/release-notice/release-notice-host.tsx"
to: "POST /users/me/release-seen"
via: "markReleaseSeenAction(notice.currentRelease) im onClose"
pattern: "markReleaseSeenAction"
- from: "apps/api/src/user/user.service.ts + admin-seed.service.ts"
to: "apps/api/src/health/app-version.ts"
via: "lastSeenReleaseVersion: getRunningRelease() bei jeder Benutzeranlage"
pattern: "getRunningRelease"
---
# Quick 260925-bow — „Was ist neu“-Fenster beim ersten Anmelden nach einem Versionswechsel
<objective>
Nach einem Versionswechsel zeigt Tessera jedem Benutzer beim ersten Laden des Portals einmal ein
Fenster mit den für ihn wichtigen Änderungen (Neu / Verbessert / Behoben) aus CHANGELOG.md. Der
gesehene Stand wird pro Benutzer in der Datenbank gemerkt, damit das Fenster im Browser und in der
Desktop-App genau einmal erscheint.
Purpose: Anwender erfahren ohne Suchen, was sich geändert hat und welche Fehler behoben sind
(Nutzerwunsch vom 25.09.).
Output: neue Spalte + Migration, zwei API-Endpunkte, reine Versions- und Auswahlfunktionen mit
Tests, Server-Aktionen, barrierefreies Fenster im Portal-Rahmen, Doku, CHANGELOG-Eintrag.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
## Verbindliche Entscheidungen des Orchestrators (hier nummeriert, in den Aufgaben zitiert)
- **D-01** Pro Benutzer in der DB: neue nullbare Spalte `User.lastSeenReleaseVersion String?` (Prisma-Migration). Gilt damit für Browser und Desktop-App.
- **D-02** Nur freigegebene Versionen zählen: die laufende Version kommt aus APP_VERSION (führendes `v` und den Describe-Anhang `-N-g<sha>` entfernen, z. B. `v10.2.3-5-gabc1234` → `10.2.3`). Nicht parsebar (`dev`, bloßer Commit-SHA) → nie ein Fenster.
- **D-03** Einmal nach der Anmeldung im Portal-Rahmen (nicht auf der Anmeldeseite), wenn laufende Version > gemerkte. Inhalt: jede freigegebene Version aus CHANGELOG mit gemerkt < Version ≤ laufend, neueste zuerst, nur die Abschnitte Neu / Geändert / Behoben (andere Abschnitte und leere weglassen). Höchstens 3 Versionen; bei mehr ein Satz plus Link „Alle Änderungen ansehen“ zu /changelog. Unten immer ein Link zu /changelog.
- **D-04** `lastSeen === null` (Bestandsbenutzer beim ersten Ausrollen): nur der Abschnitt der laufenden Version. Neu angelegte Benutzer bekommen bei der Anlage die laufende Version eingetragen (alle Anlagewege). Die API braucht dafür die Version selbst — eine einzige Quelle wählen und dokumentieren.
- **D-05** Schließen („Verstanden“, Escape, Klick auf den Hintergrund) merkt über einen kleinen angemeldeten Endpunkt (`POST /users/me/release-seen` mit der Version); der Server prüft Wohlgeformtheit und „nicht größer als die laufende Version“. Erst beim Schließen merken, nicht beim Öffnen.
- **D-06** Versionsvergleich: numerischer semver-Vergleich, reine Funktion, mit Unit-Tests.
- **D-07** Barrierefreies Fenster (role="dialog", aria-modal, Fokusfalle, Anfangsfokus auf Überschrift oder Schließen-Knopf, Escape schließt, reduzierte Bewegung). Vorbilder: `widget-catalog-modal.tsx`, `bug-report-dialog.tsx`. Tailwind-4-Tokens, Dunkelmodus. Titel „Neu in Version 1.4.0“; Gruppen „Neu“, „Verbessert“ (für Geändert), „Behoben“; Einträge mit demselben Renderer wie die Seite /changelog. Sie-Form, Schlüssel in de.json + en.json.
- **D-08** Nicht auf der Anmeldeseite; darf den Erststart-Dialog der Desktop-App nicht blockieren; darf die Bildschirmfoto-Funktion von „Fehler melden“ nicht stören (offen sein ist in Ordnung).
- **D-09** Tests: Parser/Auswahl (Bereich, Deckel 3, null, unparsebar), semver-Vergleich, Fenster rendern/schließen merkt, nicht gezeigt wenn aktuell, Endpunkt-Validierung + Mandanten-/Benutzerbindung, Anlagewege setzen das Feld. Volle API- und Web-Suiten, `turbo type-check lint`, Biome-Warnungen Web ≤ 53, API ≤ 82 (Stand vorher gemessen: 53 / 82), RLS-Bestandsaufnahme-Spec + `docs/mandantentrennung-zugriffsklassifikation.md` nachziehen.
- **D-10** CHANGELOG „Unveröffentlicht → Neu“-Eintrag; Erwähnung im Anwenderhandbuch.
- **D-11** Browserprüfung macht der Orchestrator (siehe `<verification>`), nicht der Executor.
## Einzige Quelle der laufenden Version (Entscheidung zu D-04, von Claude getroffen)
**Die API-Umgebungsvariable `APP_VERSION`, gelesen über `getRunningRelease()` in
`apps/api/src/health/app-version.ts`.** Begründung: zwei der drei Anlagewege laufen ohne jede
Web-Anfrage (LDAP-Abgleich per Zeitplan, Erst-Administrator beim API-Start) und können die Version
nur aus der API kennen; die Prüfung „nicht größer als laufend“ in `POST /users/me/release-seen` ebenso.
Das Web wertet für diese Funktion seine eigene `NEXT_PUBLIC_APP_VERSION` NICHT aus, sondern nimmt
`currentRelease` aus der Antwort von `GET /users/me/release-notice`. Beide Abbilder bekommen im
CI denselben `APP_VERSION`-Wert (`.gitea/scripts/publish-images.sh`, eine Schleife für web und
api), deshalb passen Änderungsliste (im Web-Abbild) und Version (aus der API) im Betrieb zusammen.
Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds, entsteht einfach
kein Fenster (und nichts wird gemerkt). Parse- und Vergleichsfunktion stehen EINMAL in
`packages/shared/src/index.ts` und werden von API und Web importiert.
## Bestand, den der Executor kennen muss (vom Planer gelesen)
- `apps/web/src/lib/changelog.ts`: `changelogMarkdown` (Bauzeit-Text aus `TESSERA_CHANGELOG_MD`), `filterChangelogForChannel`. Dieses Modul darf nur Server-Code importieren — sonst landet der Text in öffentlichen Client-Chunks. Eine `'use server'`-Datei ist Server-Code (Client-Komponenten bekommen nur eine Aktions-Referenz).
- `apps/web/src/components/changelog/changelog-view.tsx`: `ChangelogView` rendert Markdown mit `MDEditor.Markdown` + `rehype-sanitize`, Farbmodus nach Mount. Wird wiederverwendet (D-07).
- `apps/web/src/components/layout/app-shell.tsx`: Portal-Rahmen (nur im `(portal)`-Layout; `/login` liegt in `(auth)` ohne AppShell). Anmeldung navigiert per `window.location.href` → AppShell wird frisch gemountet.
- `apps/web/src/lib/auth-actions.ts` + `auth-actions.test.ts`: Muster für Server-Aktionen (Cookie `session` → `Cookie: session=…` an `API_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'`) und deren Tests (`next/headers` gemockt, `fetch` per `vi.stubGlobal`).
- `apps/web/src/middleware.ts` leitet bei `mustChangePassword` auf `/change-password` (liegt IM Portal-Rahmen).
- `apps/api/src/health/app-version.ts`: `getAppVersion()` liest `process.env.APP_VERSION || 'dev'` zur Laufzeit (Tests: `vi.stubEnv`, Vorbild `health.controller.spec.ts`).
- `packages/shared/src/index.ts`: rohes TypeScript ohne Bauschritt, die API lädt es im Betrieb über das Type-Stripping von Node 24 → nur löschbare Syntax (keine `enum`, kein `namespace`, keine Parameter-Eigenschaften), keine relativen Importe in neue Dateien (CJS-`require` findet keine `.ts`-Endung) — deshalb alles direkt in `index.ts`. Das Web importiert bereits Laufzeitwerte daraus (`widget-registry.tsx`).
- `apps/api/src/user/user.controller.ts`: `@Controller('users')` + `@UseGuards(RolesGuard)`; Selbstbedienungswege ohne `@Roles` (Vorbild `PATCH me/accent-color`: `forTenant(this.prisma, currentUser.tenantId)` → `tenantPrisma.user.update({ where: { id: currentUser.id } … })`). Globale `ValidationPipe({ whitelist: true, transform: true })` → Body braucht eine DTO-Klasse mit class-validator-Dekoratoren, sonst werden Felder entfernt. Route-Reihenfolge: neue statische `me/…`-Routen VOR `@Get(':id')` einfügen (Projektregel gegen 404-Shadowing).
- `apps/api/src/user/user.controller.spec.ts`: Zwei-Klienten-Attrappe (`forTenant` gemockt → `prisma.__makeBoundClient(tenantId)`, `scopedFindUnique`/`scopedUpdate` filtern nach Mandant, `boundCallLog`).
- Benutzer-Anlagewege (per grep `user\.create` vollständig ermittelt): `UserService.create()` in `apps/api/src/user/user.service.ts` (einziger Erzeugungspunkt für Admin-Anlage `POST /users` UND beide LDAP-Wege `LdapService.upsertMappedUser`/`importUsersByDn`) und `AdminSeedService` in `apps/api/src/user/admin-seed.service.ts` (Erst-Administrator). `apps/api/scripts/rls-scratch-check.mjs` legt nur Wegwerf-Testbenutzer in einer Prüf-DB an — kein Produktweg, bleibt unverändert.
- Migrationen laufen beim API-Start (`apps/api/scripts/migrate-and-start.sh`). Letzte vorhandene: `20260924120000_dashboard_image_drop_data`. Die Anmelde-Funktionen `auth_lookup_*` liefern eine feste Spaltenliste (`RETURNS TABLE`) — eine neue Spalte berührt sie nicht.
- RLS-Buchführung: `apps/api/src/prisma/rls-access-inventory.spec.ts` prüft Paare (Datei, Modell); das Paar `user.controller.ts | user | gebunden` existiert. Die Übersichtszeile `| user | 8 | 14 | 0 |` und die Summenzeile `| **Summe** | **61** | **213** | **6** |` in `docs/mandantentrennung-zugriffsklassifikation.md` werden mit der Gate-Schleife nachgerechnet (siehe Aufgabe 2).
- Stilregeln neuer UI-Dateien (aus quick-260924-i8v übernommen, Gate in Aufgabe 3): keine Versal- oder Sperrschrift-Klassen, keine Mittelpunkt- oder Pfeilzeichen in Texten, kein rohes HTML-Einfügen.
- Commits je Aufgabe mit `feat(260925-bow)` / `test(260925-bow)` / `docs(260925-bow)`; `.planning/**` committet der Executor nicht. Nicht pushen, kein Tag, keine Freigabe.
<!-- planner-discipline-allow: lib/changelog -->
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Aufgabe 1 (Tracer): Bestandsbenutzer mit altem Stand sieht das Fenster, Schließen merkt die Version — DB → API → Server-Aktion → Fenster im Portal</name>
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql, apps/api/src/health/app-version.ts, apps/api/src/health/release-version.spec.ts, apps/api/src/user/dto/release-seen.dto.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.controller.spec.ts, apps/web/src/lib/release-notes.ts, apps/web/src/lib/release-notes.test.ts, apps/web/src/lib/release-notice-actions.ts, apps/web/src/lib/release-notice-actions.test.ts, apps/web/src/components/release-notice/release-notice-dialog.tsx, apps/web/src/components/release-notice/release-notice-dialog.test.tsx, apps/web/src/components/release-notice/release-notice-host.tsx, apps/web/src/components/release-notice/release-notice-host.test.tsx, apps/web/src/components/changelog/changelog-view.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>
- apps/web/src/lib/changelog.ts, apps/web/src/lib/changelog.test.ts (Abschnittszerlegung, CRLF-Normalisierung, Testform)
- apps/web/src/components/changelog/changelog-view.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx (Hintergrund als echte Schaltfläche, role=dialog, Escape) und apps/web/src/components/bug-report/bug-report-dialog.tsx (Knopfklassen `bg-primary … text-primary-foreground`)
- apps/web/src/lib/auth-actions.ts (fetchSessionState) und apps/web/src/lib/auth-actions.test.ts
- apps/web/src/components/layout/app-shell.tsx, apps/web/src/components/layout/header.tsx (Server-Aktion aus useEffect, Ref-Sperre gegen StrictMode-Doppeleffekt)
- packages/shared/src/index.ts (Warnkommentar über WIDGET_TYPES), apps/api/src/health/app-version.ts, apps/api/src/health/health.controller.spec.ts
- apps/api/src/user/user.controller.ts (Selbstbedienungswege ab `me/avatar`), apps/api/src/user/user.controller.spec.ts, apps/api/src/user/dto/create-user.dto.ts
- apps/api/prisma/schema.prisma (model User), apps/api/prisma/migrations/20260702000000_add_user_accent_color/migration.sql (Form einer Spaltenergänzung)
</read_first>
<behavior>
- Versionen (shared, getestet in apps/api/src/health/release-version.spec.ts): parseReleaseVersion liefert für `v10.2.3` → `10.2.3`, `10.2.3` → `10.2.3`, `v10.2.3-5-gabc1234` → `10.2.3`, `10.2.3-12-g0123456789abcdef` → `10.2.3`, führende Nullen `010.02.3` → `10.2.3`; `null` für `dev`, leer, bloßen SHA `abc1234`, `10.2`, `10.2.3-rc.1`, `10.2.3-dirty`, Leerzeichen am Rand, Eingaben über 64 Zeichen. compareReleaseVersions: `1.10.0` > `1.9.0` (numerisch, nicht lexikografisch), `2.0.0` > `1.99.99`, gleich → 0, `v10.2.3` gegen `10.2.3` → 0; wirft bei nicht parsebarer Eingabe. getRunningRelease mit `vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234')` → `10.2.3`, mit `dev` oder ungesetzt → null
- API GET /users/me/release-notice: liefert `{ currentRelease, lastSeenReleaseVersion }` des angemeldeten Benutzers, gelesen über den an `currentUser.tenantId` gebundenen Klienten (boundCallLog), fremder Benutzer desselben Ids in anderem Mandanten ist unsichtbar → NotFoundException
- API POST /users/me/release-seen: `10.2.3` bei laufend `10.2.3` → gespeichert, Antwort nennt den gespeicherten Stand; Formate `v10.2.3`, `10.2`, `abc`, `10.2.3-5-gabc1234`, Nicht-String → BadRequestException; Version über der laufenden → BadRequestException; laufend nicht parsebar (`dev`) → BadRequestException; gemerkt `10.2.3`, gesendet `10.1.0` → bleibt `10.2.3` (nie absenken); gemerkter Wert unparsebar → wird überschrieben; zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden ändert sich
- Web selectReleaseNotice (release-notes.test.ts, eigene Markdown-Fixtures): current null oder unparsebar → null; lastSeen null → nur der Abschnitt der laufenden Version; lastSeen ≥ current → null; lastSeen `1.3.0`, current `1.4.0` → Versionen 1.4.0 und 1.3.1 (neueste zuerst), omittedCount 0; lastSeen `1.0.0` bei fünf Versionen darüber → die drei neuesten, omittedCount 2; lastSeen unparsebar → wie null; „Unveröffentlicht“ mit Punkten wird nie ausgewählt; Versionen über current (Web neuer als API) werden ausgelassen; laufende Version ohne Abschnitt in der Liste → null; nur Neu/Geändert/Behoben in fester Reihenfolge new, changed, fixed unabhängig von der Dateireihenfolge; „Entfernt“ und leere Abschnitte fehlen; eine Version nur mit „Entfernt“ fällt ganz weg; CRLF wird normalisiert
- Web Server-Aktionen: ohne Cookie → null und kein fetch; API 200 → Ergebnis von selectReleaseNotice auf dem (gemockten) changelogMarkdown; API nicht-ok oder Netzfehler → null; markReleaseSeenAction schickt POST mit `Content-Type: application/json`, Cookie und `{ version }`, liefert `{ success: false }` bei nicht-ok
- Fenster: Titel „Neu in Version 1.4.0“ (Schlüssel), role=dialog mit aria-modal und aria-labelledby auf die Überschrift, Anfangsfokus auf der Überschrift; Gruppenüberschriften aus den Schlüsseln new/changed/fixed; Versionsunterüberschriften nur bei mehr als einer Version; Satz über weitere Versionen nur bei omittedCount > 0; Link zu /changelog immer vorhanden; „Verstanden“, Kreuz, Escape, Hintergrund und Link rufen onClose genau einmal; Tab vom letzten fokussierbaren Element springt zum ersten, Umschalt+Tab vom ersten zum letzten
- Host: ohne Nachricht rendert er nichts; mit Nachricht erscheint das Fenster, markReleaseSeenAction wird beim Öffnen NICHT aufgerufen; nach Schließen verschwindet das Fenster und markReleaseSeenAction wurde genau einmal mit currentRelease aufgerufen; auf `/change-password` wird fetchReleaseNotice nicht aufgerufen, nach dem Wechsel auf `/` genau einmal; StrictMode-Doppeleffekt führt nicht zu zwei Abrufen
</behavior>
<action>
Umsetzung in dieser Reihenfolge, jeweils Test zuerst (rot), dann Code (grün):
1. **Versionen (D-02, D-06)** in `packages/shared/src/index.ts` direkt (keine neue Datei, nur löschbare Syntax; den Warnkommentar über `WIDGET_TYPES` um den Hinweis ergänzen, dass diese Funktionen der zweite Laufzeit-Import der API sind): `parseReleaseVersion(raw: string): string | null` mit dem Muster optionales `v`, drei Zifferngruppen zu je 1–6 Ziffern, optional genau der Describe-Anhang `-<Zahl>-g<4–40 Hex-Zeichen>`, ganze Zeichenkette verankert, kein Trimmen, Länge über 64 ergibt null; Rückgabe kanonisch `Number(a).Number(b).Number(c)`. `compareReleaseVersions(a: string, b: string): number` parst beide, wirft `Error` bei null, vergleicht die drei Zahlen der Reihe nach und liefert -1/0/1. Dazu `export interface ReleaseNoticeResponse { currentRelease: string | null; lastSeenReleaseVersion: string | null }`. Tests in `apps/api/src/health/release-version.spec.ts` (API-Suite, weil `packages/shared` keinen eigenen Testlauf hat; Vorbild `widget-module-map.spec.ts`).
2. **Laufende Version der API (einzige Quelle, siehe Kontext):** in `apps/api/src/health/app-version.ts` `export function getRunningRelease(): string | null` = `parseReleaseVersion(getAppVersion().version)`, Laufzeit-Import aus `@tessera/shared`, Kopfkommentar ergänzen (warum die API die Quelle ist). Tests im selben Spec.
3. **Spalte (D-01):** `lastSeenReleaseVersion String?` im `model User` (neben `accentColor`), Migration `apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql` mit Kopfkommentar (quick-260925-bow, wozu die Spalte dient, null = Bestandsbenutzer) und genau `ALTER TABLE "User" ADD COLUMN "lastSeenReleaseVersion" TEXT;`. Danach `pnpm --filter @tessera/api exec prisma generate`. Kein Standardwert, kein Backfill (D-04: null ist gewollt).
4. **Endpunkte (D-05)** in `apps/api/src/user/user.controller.ts`, beide VOR `@Get(':id')`, ohne `@Roles` (jeder angemeldete Benutzer), beide über `forTenant(this.prisma, currentUser.tenantId)` mit `where: { id: currentUser.id }` — kein Kennungsparameter aus der Anfrage. `GET me/release-notice`: `findUnique` mit `select: { lastSeenReleaseVersion: true }`, fehlt die Zeile, dann `NotFoundException`, sonst `ReleaseNoticeResponse` mit `currentRelease: getRunningRelease()`. `POST me/release-seen` mit `@HttpCode(HttpStatus.OK)` und neuer DTO `apps/api/src/user/dto/release-seen.dto.ts` (`ReleaseSeenDto`, `version` mit `@IsString()`, `@MaxLength(32)`, `@Matches` auf die kanonische Form X.Y.Z): im Rumpf zusätzlich prüfen (Unit-Tests rufen die Methode ohne Pipe): `typeof version === 'string'` und `parseReleaseVersion(version) === version`, sonst `BadRequestException`; ist `getRunningRelease()` null, dann `BadRequestException` (keine freigegebene Version); ist `compareReleaseVersions(version, running) > 0`, dann `BadRequestException`. Dann gemerkten Stand lesen (`findUnique`, fehlt er, dann `NotFoundException`); nur wenn der gemerkte Wert null oder unparsebar ist oder die neue Version größer ist, `update` mit `data: { lastSeenReleaseVersion: version }`; Antwort `{ success: true, lastSeenReleaseVersion: <gespeicherter Stand> }`. JSDoc je Methode mit Bezug auf quick-260925-bow und die Mandantenbindung. Tests im bestehenden `user.controller.spec.ts` mit der vorhandenen Zwei-Klienten-Attrappe, `APP_VERSION` per `vi.stubEnv` (in `afterEach` `vi.unstubAllEnvs()`).
5. **Auswahl (D-03, D-04)** in neuer reiner Datei `apps/web/src/lib/release-notes.ts` (importiert NICHT das Changelog-Modul und keine React-/Next-Module, damit Typen daraus auch in Client-Komponenten sicher sind): Typen `ReleaseSectionKind = 'new' | 'changed' | 'fixed'`, `ReleaseNotesSection { kind; markdown }` (nur die Zeilen unter der Gruppenüberschrift, Leerzeilen am Rand entfernt), `ReleaseNotesVersion { version; sections }`, `ReleaseNotice { currentRelease; versions; omittedCount }`, Konstante `RELEASE_NOTICE_MAX_VERSIONS = 3`. `parseChangelogReleases(markdown)`: CRLF normalisieren, an `## `-Überschriften zerlegen, Version = erstes Wort der Überschrift durch `parseReleaseVersion` (aus `@tessera/shared`) — „Unveröffentlicht“ und alles Unparsebare fällt weg; darin `### `-Gruppen genau „Neu“ als new, „Geändert“ als changed, „Behoben“ als fixed, andere Gruppen verwerfen, Gruppe ohne Listenpunkt (`^\s*[-*] `) verwerfen, Ausgabe in fester Reihenfolge new/changed/fixed, Versionen ohne Gruppe verwerfen. `selectReleaseNotice(markdown, currentRelease, lastSeen)`: Regeln wie im behavior-Block; Sortierung absteigend per `compareReleaseVersions` (nicht der Dateireihenfolge vertrauen); ergibt sich keine Version, Rückgabe null.
6. **Server-Aktionen** in neuer Datei `apps/web/src/lib/release-notice-actions.ts` mit `'use server'` (eigene Datei, damit `auth-actions.ts` die Änderungsliste nicht importiert): `fetchReleaseNotice(): Promise<ReleaseNotice | null>` liest das Cookie `session` wie `fetchSessionState`, ruft `GET ${API_URL}/users/me/release-notice` mit `cache: 'no-store'`, prüft die Antwortform (beide Felder string oder null, sonst null) und gibt `selectReleaseNotice(changelogMarkdown, body.currentRelease, body.lastSeenReleaseVersion)` zurück; jeder Fehler still mit Rückgabe null. `markReleaseSeenAction(version: string): Promise<{ success: boolean }>` schickt `POST ${API_URL}/users/me/release-seen`. `changelogMarkdown` kommt aus `@/lib/changelog` — erlaubt, weil Server-Code. Tests nach Vorbild `auth-actions.test.ts`, `@/lib/changelog` per `vi.mock` mit eigenem Markdown.
7. **Renderer wiederverwenden (D-07):** `ChangelogView` bekommt eine optionale Eigenschaft `variant?: 'card' | 'plain'` (Vorgabe `card`, Seite /changelog unverändert); `plain` lässt Rahmen, Hintergrund und Innenabstand weg, `data-testid` bleibt. Die bestehenden Tests der Seite müssen unverändert grün bleiben.
8. **Fenster (D-03, D-07, D-08)** `apps/web/src/components/release-notice/release-notice-dialog.tsx` (`'use client'`), Eigenschaften `{ notice: ReleaseNotice; onClose: () => void }`. Aufbau nach `widget-catalog-modal.tsx`: äußerer `fixed inset-0 z-50`-Container, Hintergrund als echte Schaltfläche mit aria-label (Schlüssel `releaseNotice.close`) und `bg-black/50`, Dialog `role="dialog"`, `aria-modal="true"`, `aria-labelledby` auf die Überschrift (`useId`), `bg-card border border-border rounded-lg shadow-xl`, `w-full max-w-lg mx-4 max-h-[85vh] flex flex-col`. Kopf: `h2` „Neu in Version {version}“ mit `tabIndex={-1}` und Anfangsfokus per Ref beim Mount, daneben Schließen-Kreuz (SVG aus dem Katalog-Fenster, aria-label `common.close`). Mitte: Einleitungssatz, dann scrollbarer Bereich (`overflow-y-auto`, `tabIndex={0}`, aria-label) mit je Version (Unterüberschrift „Version {version}“ nur bei mehr als einer Version) je Gruppe eine `h3` (`text-sm font-semibold text-foreground`) und `<ChangelogView markdown={section.markdown} variant="plain" />`. Fuß: bei `omittedCount > 0` der Satz `moreVersions` (ICU-Plural), links `next/link` „Alle Änderungen ansehen“ auf `/changelog` (Klick ruft onClose, Navigation läuft normal weiter), rechts Hauptknopf „Verstanden“ mit den Knopfklassen aus `bug-report-dialog.tsx`. Tastatur: `keydown`-Listener auf `document` — Escape ruft onClose; Tab/Umschalt+Tab zyklisch innerhalb der aktuell fokussierbaren Elemente des Dialogs (`a[href]`, `button:not([disabled])`, `[tabindex]:not([tabindex="-1"])`, dynamisch abgefragt, weil die Markdown-Ausgabe Links enthalten kann); liegt der Fokus auf der Überschrift, springt Tab auf das erste Element. Beim Unmount den Fokus auf das zuvor aktive Element zurückgeben. Keine Einblendanimation; falls doch ein Übergang nötig ist, nur mit `motion-safe:`-Präfix. Nur Tailwind-Tokens (`bg-card`, `text-foreground`, `text-muted-foreground`, `border-border`, `bg-primary`), damit Dunkelmodus automatisch stimmt. Stilregeln aus dem Kontext beachten.
9. **Host** `apps/web/src/components/release-notice/release-notice-host.tsx` (`'use client'`): Zustand `notice`, Ref-Sperre „schon abgefragt“; `useEffect` auf `usePathname()`: ist die Sperre gesetzt oder beginnt der Pfad mit `/change-password`, nichts tun; sonst Sperre setzen und `fetchReleaseNotice()` aufrufen, Ergebnis in den Zustand (Fehler still). Das Fenster per `React.lazy` + `Suspense fallback={null}` laden, damit der Markdown-Renderer nur geladen wird, wenn wirklich eine Nachricht da ist. `onClose`: zuerst `setNotice(null)` (Fenster sofort weg), dann `void markReleaseSeenAction(notice.currentRelease)` — schlägt das Merken fehl, erscheint das Fenster beim nächsten Laden erneut (gewollt, nicht stumm verloren). In `app-shell.tsx` `<ReleaseNoticeHost />` nach `</main>` einfügen (nur dort; Anmeldeseite hat keine AppShell, der Erststart-Dialog der Desktop-App ist die lokale `apps/desktop/src/setup.html` vor dem Portal und wird nicht berührt; die Bildschirmfoto-Funktion rastert `document.body` und nimmt ein offenes Fenster einfach mit).
10. **Texte (D-07)** neuer Namensraum `releaseNotice` in `de.json` und `en.json` mit identischem Schlüsselsatz: `title` („Neu in Version {version}“ / „New in version {version}“), `intro` („Tessera wurde aktualisiert. Das hat sich für Sie geändert:“ / „Tessera has been updated. Here is what changed for you:“), `versionHeading` („Version {version}“), `section.new` („Neu“ / „New“), `section.changed` („Verbessert“ / „Improved“), `section.fixed` („Behoben“ / „Fixed“), `moreVersions` (DE: „Dazu kommen Änderungen aus {count, plural, one {# älteren Version} other {# älteren Versionen}}.“, EN: „There are also changes from {count, plural, one {# earlier version} other {# earlier versions}}.“), `showAll` („Alle Änderungen ansehen“ / „View all changes“), `confirm` („Verstanden“ / „Got it“), `close` („Fenster schließen“ / „Close window“), `contentLabel` („Änderungen“ / „Changes“). Echte Umlaute, Sie-Form.
Commit(s): `feat(260925-bow): …` und `test(260925-bow): …` (TDD-Reihenfolge darf in einzelnen Commits sichtbar sein).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec prisma generate >/dev/null && pnpm --filter @tessera/api exec prisma validate && node -e "const s=require('./packages/shared/src/index.ts'); if (s.parseReleaseVersion('v10.2.3-5-gabc1234')!=='10.2.3' || s.parseReleaseVersion('dev')!==null || s.compareReleaseVersions('1.10.0','1.9.0')<=0) process.exit(1)" && pnpm --filter @tessera/api exec vitest run release-version user.controller && pnpm --filter @tessera/web exec vitest run release-notes release-notice changelog && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -lE '^import .*lib/changelog' apps/web/src/lib/release-notes.ts apps/web/src/components/release-notice/*.tsx)" && grep -q "^'use server'" apps/web/src/lib/release-notice-actions.ts && test "$(grep -rl '<ReleaseNoticeHost' apps/web/src --include=*.tsx | grep -v '\.test\.tsx$')" = "apps/web/src/components/layout/app-shell.tsx" && awk '/me\/release-notice|me\/release-seen/{n=NR} /@Get\(.:id.\)/{if(!g)g=NR} END{exit !(n && g && n<g)}' apps/api/src/user/user.controller.ts</automated>
</verify>
<done>Mit gesetzter APP_VERSION liefert die API die laufende Version und den gemerkten Stand, nimmt nur gültige, nicht zu hohe Versionen als gesehen an und bindet beides an den angemeldeten Benutzer im eigenen Mandanten; das Web wählt die richtigen Abschnitte (Bereich, Deckel 3, null, unparsebar), zeigt im Portal-Rahmen ein barrierefreies Fenster und merkt erst beim Schließen. Gezielte API- und Web-Tests grün, beide type-checks grün, Node lädt die gemeinsamen Funktionen ohne Bauschritt.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Neue Benutzer sehen kein Verlaufsfenster — alle Anlagewege tragen die laufende Version ein; RLS-Buchführung nachziehen</name>
<files>apps/api/src/user/user.service.ts, apps/api/src/user/user.service.spec.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/admin-seed.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>
- apps/api/src/user/user.service.ts (`create()` samt Kopfkommentar „EINZIGER Erzeugungspunkt“), apps/api/src/user/user.service.spec.ts (describe „create — Standardgruppen-Mitgliedschaft“)
- apps/api/src/user/admin-seed.service.ts (Erstanlage), apps/api/src/user/admin-seed.service.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md: Abschnitt „Übersicht je Bereich“ (Zeile `| user |` und `| **Summe** |`), Fundstellenzeile `apps/api/src/user/user.controller.ts | user`
</read_first>
<behavior>
- UserService.create mit `APP_VERSION=v10.2.3-3-gabc1234` → die an `tenantPrisma.user.create` übergebenen Daten enthalten `lastSeenReleaseVersion: '10.2.3'`; mit `APP_VERSION=dev` bzw. ungesetzt → `lastSeenReleaseVersion: null`
- Der Wert lässt sich über die Parameter von create() nicht von außen setzen (kein neues Feld in der Signatur)
- AdminSeedService legt den Erst-Administrator mit `lastSeenReleaseVersion` der laufenden Version an (`10.2.3` bzw. null bei `dev`)
- LDAP-Anlage: beide LDAP-Wege gehen über UserService.create (grep-Nachweis, kein eigener `user.create` in apps/api/src/ldap)
</behavior>
<action>
Per D-04: In `UserService.create()` in den `data` von `tenantPrisma.user.create` das Feld `lastSeenReleaseVersion: getRunningRelease()` ergänzen (Import aus `../health/app-version`), NICHT als Parameter der Methode — der Wert ist eine Eigenschaft des Servers, nicht des Aufrufers. Kopfkommentar von `create()` um einen Absatz ergänzen: neue Benutzer (Admin-Anlage, beide LDAP-Wege) bekommen die laufende freigegebene Version eingetragen, damit sie kein „Was ist neu“-Fenster mit Altlasten sehen (quick-260925-bow); `null` auf Ständen ohne freigegebene Version. Dasselbe in `AdminSeedService` bei der Erstanlage des Administrators (dort ebenfalls kurzer Kommentar). Tests zuerst: in `user.service.spec.ts` und `admin-seed.service.spec.ts` je ein Fall mit `vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234')` und ein Fall mit `dev`, `vi.unstubAllEnvs()` im `afterEach`.
RLS-Buchführung (D-09): Aufgabe 1 hat in `user.controller.ts` neue gebundene Rohtreffer `tenantPrisma.user.` hinzugefügt (erwartet +3: ein `findUnique` im GET, `findUnique` + `update` im POST). Das Paar (Datei, Modell) bleibt `gebunden`, die Spec braucht keine neue Zeile. Mit der Gate-Schleife nachrechnen (je Bereichsverzeichnis `grep -ro` auf `this.prisma.<Modell>`, `tenantPrisma.<Modell>.`, `systemPrisma.<Modell>.`, ohne spec-Dateien; Summe über alle Bereiche) und eintragen: Zeile `| user | … |` mit den gemessenen Werten und einem vorangestellten Vermerk im etablierten Stil (**quick-260925-bow:** +N gebunden in `user.controller.ts`, „Was ist neu“-Fenster, `GET me/release-notice` und `POST me/release-seen`, nachgemessen mit der Gate-Schleife; danach „Vorher:“ und der bisherige Text); Summenzeile: Werte ersetzen, den Vermerk **quick-260925-bow:** an den Anfang der Hinweisspalte stellen und den bisherigen Text mit „Vorher:“ anhängen (die Gates erwarten den Vermerk jeweils direkt nach den Zahlen); in der Fundstellenzeile `apps/api/src/user/user.controller.ts | user` die Aufzählung der Selbstbedienungszugriffe um die beiden neuen Wege ergänzen (weiterhin `forTenant()`, `where: { id: currentUser.id }`). Werte messen, nicht aus diesem Plan abschreiben.
Commit(s): `feat(260925-bow): …`, `test(260925-bow): …`, `docs(260925-bow): RLS-Buchfuehrung …`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run user.service admin-seed rls-access-inventory && test -z "$(grep -rnE '(tenantPrisma|this\.prisma|tx)\.user\.create' apps/api/src/ldap --include=*.ts | grep -v '\.spec\.ts')" && grep -q 'getRunningRelease' apps/api/src/user/user.service.ts && grep -q 'getRunningRelease' apps/api/src/user/admin-seed.service.ts && K=docs/mandantentrennung-zugriffsklassifikation.md && U=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/user | grep -v spec | wc -l | tr -d ' ') && B=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/user | grep -v spec | wc -l | tr -d ' ') && S=$(grep -ro "systemPrisma\.[a-zA-Z]*\." apps/api/src/user | grep -v spec | wc -l | tr -d ' ') && { grep -qE "^\| user \| ${U} \| ${B} \| ${S} \| \*\*quick-260925-bow" "$K" || { echo "ZEILE user nennt nicht ${U}/${B}/${S} mit Vermerk"; exit 1; }; } && TU=0 && TB=0 && TS=0 && for d in apps/api/src/*/; do u=$(grep -ro "this\.prisma\.[a-zA-Z]*" "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); b=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); s=$(grep -ro "systemPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); TU=$((TU+u)); TB=$((TB+b)); TS=$((TS+s)); done && echo "ABGELEITET ${TU}/${TB}/${TS}" && { grep -qE "^\| \*\*Summe\*\* \| \*\*${TU}\*\* \| \*\*${TB}\*\* \| \*\*${TS}\*\* \| \*\*quick-260925-bow" "$K" || { echo "SUMMENZEILE nennt nicht ${TU}/${TB}/${TS} mit Vermerk"; exit 1; }; } && grep -E '^\| apps/api/src/user/user\.controller\.ts \| user \|' "$K" | grep -q 'release'</automated>
</verify>
<done>Jeder Anlageweg (Admin-Anlage, LDAP-Abgleich und -Import über UserService.create, Erst-Administrator) trägt die laufende freigegebene Version ein, auf dev-Ständen null; Tests dafür grün; RLS-Bestandsaufnahme-Spec grün; Übersichts-, Summen- und Fundstellenzeile nachgemessen und mit Vermerk fortgeschrieben.</done>
</task>
<task type="auto">
<name>Aufgabe 3: Doku, CHANGELOG, Kommentare zur Importregel; volle Suiten, Lint, Biome-Grenzen und Nachweis, dass die Änderungsliste nicht in Client-Chunks landet</name>
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md, apps/web/src/lib/changelog.ts, apps/web/next.config.ts</files>
<read_first>
- CHANGELOG.md (Kopf bis „## 1.4.0“; „## Unveröffentlicht“ ist derzeit leer)
- docs/anleitung-anwender.md, Abschnitt „## Was ist neu“
- docs/anleitung-entwicklung.md, Absatz „**Änderungsliste (`CHANGELOG.md`):**“ (enthält die Regel, welche Datei das Changelog-Modul importieren darf)
- Kopfkommentar von apps/web/src/lib/changelog.ts und Kommentarblock oben in apps/web/next.config.ts
</read_first>
<action>
Per D-10: Unter `## Unveröffentlicht` in CHANGELOG.md eine Gruppe `### Neu` mit einem Punkt in Alltagssprache, Sie-Form, echte Umlaute, ohne Dateinamen/Fachbegriffe, sinngemäß: Nach einem Versionswechsel zeigt Tessera bei Ihrer ersten Anmeldung ein Fenster mit den wichtigsten Änderungen der neuen Version – neue Funktionen, Verbesserungen und behobene Fehler; haben Sie mehrere Versionen verpasst, erscheinen die drei neuesten; „Verstanden“ schließt das Fenster, es erscheint erst mit der nächsten Version wieder, im Browser wie in der Desktop-App; die vollständige Liste bleibt unter „Was ist neu“. Das Wort „Versionswechsel“ muss im Punkt vorkommen (Gate).
Anwenderhandbuch `docs/anleitung-anwender.md`, Abschnitt „Was ist neu“: neuen Absatz (Sie-Form) — das Fenster nach einem Versionswechsel, was es zeigt (Neu / Verbessert / Behoben, höchstens drei Versionen, Link „Alle Änderungen ansehen“), wie es sich schließt, dass es pro Benutzer nur einmal je Version erscheint (auch in der Desktop-App), dass neu angelegte Konten es erst mit der nächsten Version sehen, und dass Beta-Punkte unter „Noch nicht freigegeben“ darin nicht vorkommen. Das Wort „Versionswechsel“ muss im Abschnitt vorkommen (Gate).
Entwicklerdoku `docs/anleitung-entwicklung.md`, im Absatz zur Änderungsliste: die Importregel erweitern (Server-Code darf das Changelog-Modul importieren: `page.tsx` UND die `'use server'`-Datei `release-notice-actions.ts`; Client-Komponenten nie, auch nicht `release-notes.ts`), danach ein kurzer Absatz zum Fenster: einzige Quelle der laufenden Version ist `APP_VERSION` der API (`getRunningRelease()`), Begründung (LDAP-Abgleich und Erst-Administrator laufen ohne Web), Endpunkte `GET /users/me/release-notice` und `POST /users/me/release-seen` (Validierung, nie absenken), Spalte `User.lastSeenReleaseVersion` (null = Bestandsbenutzer, zeigt nur die laufende Version), gemeinsame Funktionen `parseReleaseVersion`/`compareReleaseVersions` in `packages/shared`, Folge für die Freigabe: erst ein Tag `vX.Y.Z` (bzw. dessen Describe-Stand auf Beta) löst das Fenster aus, Punkte unter „Unveröffentlicht“ nie; lokal mit `dev` erscheint nie ein Fenster (zum Ausprobieren `APP_VERSION` als Build-Arg setzen). Die Kopfkommentare in `apps/web/src/lib/changelog.ts` und `apps/web/next.config.ts` an dieselbe erweiterte Importregel anpassen (nur Kommentar, kein Code).
Dann die vollen Prüfungen (D-09). Der Web-Build für den Chunk-Nachweis dauert einige Minuten (Befehl mit großzügiger Zeitgrenze ausführen); Build-Ausgaben (`apps/web/.next`, ggf. geändertes `apps/web/next-env.d.ts`) nicht committen — `next-env.d.ts` bei Änderung per `git checkout --` zurücksetzen. Scheitert `next build` lokal aus Gründen außerhalb dieser Änderung, das in der SUMMARY mit der Fehlermeldung festhalten und den Importnachweis aus Aufgabe 1 als Ersatz benennen — nicht stillschweigend überspringen. Biome-Formatierung neuer Dateien mit `biome check --write` angleichen; keine neuen `any`, Nicht-null-Behauptungen oder Biome-Ausnahmen in neuen Dateien.
Commit: `docs(260925-bow): …`.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm turbo run type-check lint && W=$(pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+' || true) && A=$(pnpm --filter @tessera/api exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+' || true) && echo "Biome web=${W:-0} api=${A:-0}" && test "${W:-0}" -le 53 && test "${A:-0}" -le 82 && test -z "$(grep -vE '^\s*(//|\*|/\*|\{/\*)' apps/web/src/components/release-notice/release-notice-dialog.tsx apps/web/src/components/release-notice/release-notice-host.tsx | grep -nE 'uppercase|tracking-widest|·|→|dangerouslySetInnerHTML')" && node -e 'for (const f of ["de","en"]) { const m = require("./apps/web/src/messages/" + f + ".json").releaseNotice; if (!m) throw new Error(f + ": releaseNotice fehlt"); for (const k of ["title","intro","versionHeading","moreVersions","showAll","confirm","close","contentLabel"]) if (typeof m[k] !== "string" || !m[k]) throw new Error(f + ": fehlt releaseNotice." + k); for (const k of ["new","changed","fixed"]) if (!m.section || !m.section[k]) throw new Error(f + ": fehlt releaseNotice.section." + k); if (/·|→/.test(JSON.stringify(m))) throw new Error(f + ": verbotenes Zeichen"); }' && awk '/^## Unveröffentlicht/{f=1; next} /^## /{f=0} f' CHANGELOG.md | grep -q '^### Neu' && awk '/^## Unveröffentlicht/{f=1; next} /^## /{f=0} f' CHANGELOG.md | grep -q 'Versionswechsel' && awk '/^## Was ist neu/{f=1; next} /^## /{f=0} f' docs/anleitung-anwender.md | grep -q 'Versionswechsel' && grep -q 'release-notice-actions' docs/anleitung-entwicklung.md && grep -q 'getRunningRelease' docs/anleitung-entwicklung.md && LOG=$(mktemp) && { pnpm --filter @tessera/web build >"$LOG" 2>&1 || { tail -40 "$LOG"; exit 1; }; } && P='hochgeladene Bilder liegen jetzt im Dateibereich des Servers' && test -n "$(grep -rl "$P" apps/web/.next/server)" && test -z "$(grep -rl "$P" apps/web/.next/static)" && { git checkout -- apps/web/next-env.d.ts 2>/dev/null || true; } && git diff --quiet -- apps/web/next-env.d.ts</automated>
</verify>
<done>CHANGELOG, Anwender- und Entwicklerdoku beschreiben das Fenster; Kommentare zur Importregel stimmen; volle API- und Web-Suite grün, `turbo type-check lint` grün, Biome-Warnungen Web ≤ 53 und API ≤ 82, Stil- und i18n-Gate leer bzw. vollständig; nach `next build` steht der Änderungslistentext im Server-Bundle, aber nicht unter `.next/static`.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser/Desktop-App → Web-Server-Aktion | Client ruft `fetchReleaseNotice`/`markReleaseSeenAction`; Eingabe `version` ist unvertrauenswürdig |
| Web → API (`/users/me/release-*`) | Session-Cookie authentifiziert; Body `{ version }` unvertrauenswürdig |
| API → PostgreSQL (User-Zeile) | Zeilenschutz je Mandant über `forTenant()` |
| Änderungsliste (Bauzeit-Text) → Client-Bundles | Text darf nicht in öffentlich abrufbare `/_next/static`-Chunks |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-BOW-01 | Tampering | `POST /users/me/release-seen` | low | mitigate | DTO (`@IsString`, `@MaxLength(32)`, `@Matches` kanonisch) plus Rumpfprüfung `parseReleaseVersion(v) === v`, `≤ getRunningRelease()`, `null`-laufend → 400; nie absenken. Wirkung beschränkt auf das eigene Fenster |
| T-BOW-02 | Elevation of Privilege | `GET/POST /users/me/release-*` | medium | mitigate | Kein Kennungsparameter; `where: { id: currentUser.id }` über `forTenant(this.prisma, currentUser.tenantId)`; Test mit zwei Benutzern in zwei Mandanten (Aufgabe 1) |
| T-BOW-03 | Information Disclosure | `release-notice-actions.ts` / Client-Chunks | low | mitigate | Changelog-Modul nur aus `'use server'`-Datei und `page.tsx`; `release-notes.ts` und Fenster-Dateien ohne diesen Import (Gate Aufgabe 1); Build-Nachweis `.next/static` enthält den Text nicht (Gate Aufgabe 3) |
| T-BOW-04 | Tampering (XSS) | `ReleaseNoticeDialog` | low | mitigate | Markdown ausschließlich über `ChangelogView` (`MDEditor.Markdown` + `rehype-sanitize`), kein rohes HTML-Einfügen (Stil-Gate Aufgabe 3); Quelle ist die versionierte CHANGELOG.md |
| T-BOW-05 | Denial of Service | Versionsparser | low | mitigate | Eingabelänge ≤ 64 im Parser, `@MaxLength(32)` in der DTO, verankerter Ausdruck ohne verschachtelte Wiederholungen |
| T-BOW-06 | Information Disclosure | `GET /users/me/release-notice` | low | accept | Nennt nur die laufende Version, die `GET /health/version` ohnehin öffentlich liefert (T-KU1-03), und den eigenen gemerkten Stand |
| T-BOW-07 | Tampering | Migration `20260925120000_user_last_seen_release` | low | accept | Reines `ADD COLUMN` nullbar ohne Standardwert, kein Datenumbau; `auth_lookup_*` liefern feste Spaltenlisten und bleiben unberührt |
</threat_model>
<verification>
Executor (automatisiert, siehe Aufgaben): gezielte Tests je Schicht (Aufgabe 1), Anlagewege + RLS-Buchführung (Aufgabe 2), volle API- und Web-Suite, `pnpm turbo run type-check lint`, Biome-Warnungen Web ≤ 53 / API ≤ 82, Stil- und i18n-Gate, Chunk-Nachweis per `next build` (Aufgabe 3).
Orchestrator (D-11, Browserprüfung mit Playwright am lokalen Stack, NICHT Aufgabe des Executors):
1. Lokal mit einer freigegebenen Versionsnummer bauen, damit überhaupt ein Fenster entstehen kann (lokal steht sonst `dev`): `docker compose build --build-arg APP_VERSION=1.4.0-1-g0000000 api web && docker compose up -d --force-recreate api web` — die Migration läuft beim API-Start.
2. `docker exec tessera-ctl-db-1 psql -U tessera -d tessera -c "UPDATE \"User\" SET \"lastSeenReleaseVersion\"='1.0.0' WHERE username='admin'"` → Anmelden: Fenster „Neu in Version 1.4.0“ mit 1.4.0, 1.3.1, 1.3.0, Satz „Dazu kommen Änderungen aus 2 älteren Versionen.“, Link zu /changelog; Überschrift hat den Fokus; Tab bleibt im Fenster; Dunkelmodus prüfen.
3. „Verstanden“ → DB zeigt `1.4.0`; Neuladen → kein Fenster.
4. `lastSeenReleaseVersion = NULL` → nur Abschnitt 1.4.0, keine Versionsunterüberschrift. `= '1.4.0'` → kein Fenster. Escape und Hintergrundklick schließen ebenfalls und merken.
5. Anmeldeseite zeigt nie ein Fenster; „Fehler melden“ mit offenem Fenster funktioniert (Bildschirmfoto enthält das Fenster).
</verification>
<success_criteria>
- Bestandsbenutzer mit älterem Stand sehen nach einem Versionswechsel genau einmal das Fenster mit Neu / Verbessert / Behoben der verpassten Versionen (höchstens drei, neueste zuerst, Hinweis auf weitere), Schließen merkt dauerhaft pro Benutzer.
- Neu angelegte Benutzer (Admin, LDAP, Erst-Administrator) und dev-Stände sehen kein Fenster.
- Server validiert und bindet an Benutzer und Mandant; RLS-Buchführung nachgemessen.
- Alle Suiten, type-check, lint grün; Biome Web ≤ 53, API ≤ 82; Änderungsliste nicht in Client-Chunks.
- CHANGELOG „Unveröffentlicht → Neu“, Anwender- und Entwicklerdoku ergänzt.
</success_criteria>
<output>
Create `.planning/quick/260925-bow-was-ist-neu-fenster-beim-ersten-anmelden/260925-bow-SUMMARY.md` when done (gemessene Biome-Zahlen, gemessene RLS-Zählwerte user/Summe, Ergebnis des Chunk-Nachweises, Testzahlen API/Web).
</output>
@@ -0,0 +1,167 @@
---
phase: quick-260925-bow
plan: 01
quick_id: 260925-bow
status: complete
subsystem: web + api (Benutzer, Änderungsliste)
tags: [release-notice, changelog, user, prisma, a11y, rls]
requires:
- CHANGELOG.md als Bauzeit-Text (quick-260916-dcz)
- APP_VERSION als Laufzeit-ENV der API (quick-260914-ku1)
provides:
- "Spalte User.lastSeenReleaseVersion (Migration 20260925120000_user_last_seen_release)"
- "parseReleaseVersion / compareReleaseVersions / ReleaseNoticeResponse in @tessera/shared"
- "getRunningRelease() als einzige Quelle der laufenden Version"
- "GET /users/me/release-notice, POST /users/me/release-seen"
- "selectReleaseNotice (Web), Server-Aktionen, ReleaseNoticeDialog, ReleaseNoticeHost in AppShell"
affects:
- apps/web/src/components/layout/app-shell.tsx
- apps/api/src/user/user.service.ts (create)
- apps/api/src/user/admin-seed.service.ts
tech-stack:
added: []
patterns:
- "Server-Aktion in eigener 'use server'-Datei als einziger Web-Importeur der Änderungsliste neben page.tsx"
- "Fenster per React.lazy nur bei vorhandener Nachricht geladen"
key-files:
created:
- apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql
- apps/api/src/health/release-version.spec.ts
- apps/api/src/user/dto/release-seen.dto.ts
- apps/web/src/lib/release-notes.ts
- apps/web/src/lib/release-notes.test.ts
- apps/web/src/lib/release-notice-actions.ts
- apps/web/src/lib/release-notice-actions.test.ts
- apps/web/src/components/release-notice/release-notice-dialog.tsx
- apps/web/src/components/release-notice/release-notice-dialog.test.tsx
- apps/web/src/components/release-notice/release-notice-host.tsx
- apps/web/src/components/release-notice/release-notice-host.test.tsx
modified:
- packages/shared/src/index.ts
- apps/api/prisma/schema.prisma
- apps/api/src/health/app-version.ts
- apps/api/src/user/user.controller.ts
- apps/api/src/user/user.controller.spec.ts
- apps/api/src/user/user.service.ts
- apps/api/src/user/user.service.spec.ts
- apps/api/src/user/admin-seed.service.ts
- apps/api/src/user/admin-seed.service.spec.ts
- apps/web/src/components/changelog/changelog-view.tsx
- apps/web/src/components/layout/app-shell.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- apps/web/src/lib/changelog.ts
- apps/web/next.config.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
- CHANGELOG.md
decisions:
- "Einzige Quelle der laufenden Version ist APP_VERSION der API (getRunningRelease()); das Web nimmt currentRelease aus GET /users/me/release-notice"
- "Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds ganz, gibt es kein Fenster; eine laufende Version nur mit „Entfernt“ zählt ebenso als ohne Abschnitt"
- "Scrollbereich des Fensters ist ein benannter section ohne tabIndex (keine neue Biome-Warnung, keine Biome-Ausnahme)"
metrics:
duration: 16min
completed: 2026-09-25
actuals:
tokens: 30400
tasks: 3
commits: 9
plan_head_before: 9225ed1bf980aa688e50b55f23162927bf95b40c
---
# Quick 260925-bow: „Was ist neu“-Fenster beim ersten Anmelden nach einem Versionswechsel Summary
Nach einem Versionswechsel zeigt Tessera jedem Benutzer beim ersten Laden des Portals einmal ein Fenster „Neu in Version X.Y.Z“ mit Neu / Verbessert / Behoben aus CHANGELOG.md (höchstens drei Versionen, neueste zuerst). Der gesehene Stand liegt pro Benutzer in der neuen Spalte `User.lastSeenReleaseVersion` und wird erst beim Schließen über `POST /users/me/release-seen` gemerkt, gebunden an Benutzer und Mandanten.
## Was gebaut wurde
**Aufgabe 1 (Tracer, DB → API → Server-Aktion → Fenster)**
- `packages/shared`: `parseReleaseVersion` (optionales `v`, drei Zifferngruppen zu je 1 bis 6 Ziffern, optional Describe-Anhang, verankert, ≤ 64 Zeichen, kanonisch ohne führende Nullen), `compareReleaseVersions` (numerisch, wirft bei Unparsebarem), `ReleaseNoticeResponse`. Warnkommentar über `WIDGET_TYPES` ergänzt.
- `getRunningRelease()` in `app-version.ts` mit Begründung, warum die API die Quelle ist.
- Spalte + Migration (`ALTER TABLE "User" ADD COLUMN "lastSeenReleaseVersion" TEXT;`), kein Standardwert, kein Backfill. **Lokal auf die DB angewendet** (`prisma migrate deploy` über 172.19.0.2); `tessera_app` hat Rechte auf Tabellenebene und damit auch auf die neue Spalte.
- `GET me/release-notice` und `POST me/release-seen` in `user.controller.ts` vor der Kennungs-Route, ohne `@Roles`, beide über `forTenant()` + `where: { id: currentUser.id }`. `ReleaseSeenDto` mit `@IsString`, `@MaxLength(32)`, `@Matches` auf kanonisches X.Y.Z; im Rumpf zusätzlich Format-, dev- und „nicht über laufend“-Prüfung; nie absenken, unparsebaren Altwert überschreiben.
- Web: `release-notes.ts` (reine Auswahl, kein Import der Änderungsliste), `release-notice-actions.ts` (`'use server'`), `ReleaseNoticeDialog` (role=dialog, aria-modal, aria-labelledby, Anfangsfokus Überschrift, Escape, Fokusfalle mit dynamischer Elementliste, Fokus-Rückgabe, keine Animation, nur Tailwind-Tokens), `ReleaseNoticeHost` (Ref-Sperre, nicht auf `/change-password`, `React.lazy`, merkt erst beim Schließen), eingebunden nur in `AppShell`. `ChangelogView` mit `variant="plain"`. Texte `releaseNotice` in de.json/en.json.
**Aufgabe 2 (Anlagewege)**
- `UserService.create()` (Admin-Anlage und beide LDAP-Wege) und `AdminSeedService` setzen `lastSeenReleaseVersion: getRunningRelease()`; kein neuer Parameter. grep-Nachweis: kein eigener `user.create` in `apps/api/src/ldap`.
- RLS-Buchführung nachgemessen mit der Gate-Schleife: **user 8/17/0** (vorher 8/14/0, +3 gebunden in `user.controller.ts`), **Summe 61/216/6** (vorher 61/213/6). Übersichts-, Summen- und Fundstellenzeile mit Vermerk **quick-260925-bow** fortgeschrieben. `rls-access-inventory.spec.ts` grün (Paar `user.controller.ts | user | gebunden` unverändert).
**Aufgabe 3 (Doku und volle Prüfungen)**
- CHANGELOG „Unveröffentlicht → Neu“, Abschnitt „Was ist neu“ im Anwenderhandbuch, Entwicklerdoku (Importregel erweitert, Versionsquelle, Endpunkte, Spalte, Folge für die Freigabe, Ausprobieren mit `APP_VERSION`), Kopfkommentare `changelog.ts` und `next.config.ts`.
## Gemessene Ergebnisse
| Prüfung | Ergebnis |
|---|---|
| API-Suite (voll) | 85 Dateien, **1435 Tests grün** |
| Web-Suite (voll) | 95 Dateien, **924 Tests grün** |
| `pnpm turbo run type-check lint` | 9/9 Tasks erfolgreich |
| Biome-Warnungen | **Web 53** (Grenze 53), **API 82** (Grenze 82) |
| Stil-Gate (Versal/Sperrschrift, `·`, `→`, rohes HTML) | leer |
| i18n-Gate `releaseNotice` de/en | vollständig |
| RLS user / Summe | 8/17/0 / 61/216/6 |
| `next build` | erfolgreich |
| Chunk-Nachweis | Satz „hochgeladene Bilder liegen jetzt im Dateibereich des Servers“: 2 Dateien unter `.next/server` (`changelog/page.js` und der Server-Chunk der Aktion), **0 unter `.next/static`**; ebenso der neue CHANGELOG-Eintrag (2 / 0). `next-env.d.ts` unverändert. |
Abgleich mit der echten CHANGELOG.md: gemerkt `1.0.0`, laufend `1.4.0` ergibt 1.4.0 (new/changed/fixed), 1.3.1 (changed/fixed), 1.3.0 (new/changed/fixed) und `omittedCount` 2, also genau das, was die Browserprüfung des Orchestrators erwartet; gemerkt `null` ergibt nur 1.4.0; gemerkt `1.4.0` ergibt kein Fenster.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] StrictMode-Doppeleffekt verwarf das Abrufergebnis im Host**
- **Found during:** Aufgabe 1 (Host-Test „StrictMode-Doppeleffekt führt nicht zu zwei Abrufen“)
- **Issue:** Ein Abbruch-Flag im Aufräumen des Effekts wurde beim simulierten Unmount gesetzt, der zweite Effektlauf fragte wegen der Ref-Sperre nicht erneut, das einzige Ergebnis ging verloren, kein Fenster.
- **Fix:** Abbruch-Flag entfernt (setState nach Unmount ist in React 19 folgenlos), Kommentar dazu.
- **Commit:** 187fb76
**2. [Rule 3 - Blocking] Umlaut-Wächter kannte „Verbessert“ nicht**
- **Found during:** Aufgabe 3 (volle Web-Suite)
- **Issue:** `umlaut-guard.spec.ts` meldete das korrekt geschriebene Wort „Verbessert“ (Gruppe `releaseNotice.section.changed`) als unbekanntes „ss“-Wort.
- **Fix:** „Verbessert“ in `UMLAUT_ALLOWLIST` (`umlaut-dictionary.ts`) mit Vermerk aufgenommen.
- **Commit:** 2aeb3e8
**3. [Rule 3 - Blocking] Scrollbereich ohne `tabIndex={0}`**
- **Issue:** Der geplante `div tabIndex={0} aria-label` erzeugte zwei neue Biome-Warnungen (`noNoninteractiveTabindex`, `useAriaPropsSupportedByRole`) und hätte die Grenze Web ≤ 53 gerissen; Biome-Ausnahmen in neuen Dateien sind laut Plan verboten.
- **Fix:** Benannter `<section aria-label={contentLabel}>` ohne tabIndex. Heutige Chromium- und WebKit-Versionen (Browser und Desktop-App) machen einen Scroll-Container ohne fokussierbaren Inhalt selbst per Tastatur erreichbar; enthält er Links, scrollt der Tab-Fokus mit. Innere Versionsblöcke sind `div` statt `section`, damit keine verschachtelten Landmarken entstehen.
- **Commit:** 187fb76
**4. [Kleinigkeit] Überschriften-Hierarchie**
- Bei einer Version sind die Gruppen `h3` (wie geplant); bei mehreren Versionen trägt die Version `h3` und die Gruppen `h4` (statt einer Versionszeile ohne Überschriftenrolle). Test entsprechend.
**5. [Hinweis] `prisma validate` braucht DATABASE_URL**
- Das Aufgabe-1-Gate ruft `prisma validate` ohne Umgebung auf; hier scheitert das nur an der fehlenden `DATABASE_URL` (P1012). Mit gesetzter URL (echte Container-IP bzw. Platzhalter) ist das Schema gültig. `prisma format` wurde bewusst NICHT verwendet, weil es die ganze Schemadatei umformatiert hätte; die neue Zeile ist von Hand eingetragen.
**6. [Hinweis] Bestehende Format-Befunde nicht angefasst**
- `biome check` meldet in `app-shell.tsx` und `changelog-view.tsx` Format-/Importreihenfolge-Befunde, die schon vorher bestanden; das Lint-Skript ist `biome lint`, die Dateien wurden deshalb nicht umformatiert. Neue Dateien sind mit `biome check --write` formatiert, ohne Ausnahmen, `any` nur in Test-Attrappen wie im Bestand.
## Hinweise für den Orchestrator (Browserprüfung, D-11)
- Die Migration ist auf der lokalen DB bereits angewendet; der laufende API-Container (altes Abbild) stört sich an der zusätzlichen nullbaren Spalte nicht. Für die Prüfung muss wie im Plan beschrieben mit `--build-arg APP_VERSION=1.4.0-1-g0000000` neu gebaut werden (der Describe-Anhang braucht mindestens 4 Hex-Zeichen nach `g`, `g0000000` passt).
- Auf `/change-password` wird nicht abgefragt; die nächste Seite danach fragt einmal.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Bedrohungsmodells: die zwei Endpunkte (T-BOW-01/02/05/06), die Migration (T-BOW-07), die Server-Aktion/Chunks (T-BOW-03) und das Markdown-Rendering über `ChangelogView` mit `rehype-sanitize` (T-BOW-04) sind dort erfasst und umgesetzt.
## Commits
| Hash | Nachricht |
|---|---|
| b35edd5 | test(260925-bow): Versionsvergleich und Was-ist-neu-Endpunkte (rot) |
| 59db32a | feat(260925-bow): gesehene Version pro Benutzer merken - Spalte, Versionsfunktionen, API |
| cf7784e | test(260925-bow): Auswahl, Server-Aktionen, Fenster und Host des Was-ist-neu-Fensters |
| 187fb76 | feat(260925-bow): Was-ist-neu-Fenster im Portal-Rahmen |
| 25c8db7 | test(260925-bow): Anlagewege tragen die laufende Version ein (rot) |
| 5ae9aaa | feat(260925-bow): neue Benutzer bekommen die laufende Version eingetragen |
| 4fa5aaf | docs(260925-bow): RLS-Buchfuehrung nachgemessen (user 8/17/0, Summe 61/216/6) |
| 2aeb3e8 | fix(260925-bow): Umlaut-Waechter kennt das korrekte Wort Verbessert |
| b3b7b5d | docs(260925-bow): Was-ist-neu-Fenster in CHANGELOG, Anwender- und Entwicklerdoku |
Nicht gepusht, kein Tag, keine Freigabe. `.planning/**` nicht committet.
## Self-Check: PASSED
Alle 11 neuen Dateien vorhanden, alle 9 Commits im Verlauf (`git rev-list --count 9225ed1..HEAD` = 9).