11 Commits

Author SHA1 Message Date
schalli acfffa3097 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>
2026-09-25 09:01:48 +02:00
schalli 59e9c34fa7 fix(260925-bow): Link "Alle Aenderungen ansehen" lesbar (dunkler Text, gelbe Unterstreichung)
Gelber Text auf weissem Grund lag weit unter 3:1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 09:01:48 +02:00
schalli b3b7b5d5e5 docs(260925-bow): Was-ist-neu-Fenster in CHANGELOG, Anwender- und Entwicklerdoku
- CHANGELOG Unveroeffentlicht -> Neu
- Anwenderhandbuch: Fenster nach einem Versionswechsel
- Entwicklerdoku: erweiterte Importregel fuer @/lib/changelog, Versionsquelle,
  Endpunkte, Spalte, Folge fuer die Freigabe
- Kopfkommentare changelog.ts und next.config.ts angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:54:38 +02:00
schalli 2aeb3e8ce3 fix(260925-bow): Umlaut-Waechter kennt das korrekte Wort Verbessert
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:52:26 +02:00
schalli 4fa5aafc54 docs(260925-bow): RLS-Buchfuehrung nachgemessen (user 8/17/0, Summe 61/216/6)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:50:06 +02:00
schalli 5ae9aaa0d6 feat(260925-bow): neue Benutzer bekommen die laufende Version eingetragen
- UserService.create (Admin-Anlage, beide LDAP-Wege) und AdminSeedService
  setzen lastSeenReleaseVersion = getRunningRelease(), null auf dev

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:50:06 +02:00
schalli 25c8db746a test(260925-bow): Anlagewege tragen die laufende Version ein (rot)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:49:21 +02:00
schalli 187fb76c91 feat(260925-bow): Was-ist-neu-Fenster im Portal-Rahmen
- release-notes.ts: reine Auswahl der Versionsabschnitte aus CHANGELOG.md
- release-notice-actions.ts ('use server'): Abruf und Merken ueber die API
- ReleaseNoticeDialog: barrierefreies Fenster, Eintraege ueber ChangelogView (variant plain)
- ReleaseNoticeHost in AppShell: einmal je Seitenladung, merkt erst beim Schliessen
- Texte releaseNotice in de.json und en.json

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:48:25 +02:00
schalli cf7784e409 test(260925-bow): Auswahl, Server-Aktionen, Fenster und Host des Was-ist-neu-Fensters
- selectReleaseNotice: Bereich, Deckel 3, null, unparsebar, nur Neu/Geaendert/Behoben
- Server-Aktionen: Cookie, Antwortform, Fehler still
- Fenster: role=dialog, Fokusfalle, alle Schliesswege genau einmal
- Host: merkt erst beim Schliessen, nicht auf /change-password, StrictMode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:48:25 +02:00
schalli 59db32a01f feat(260925-bow): gesehene Version pro Benutzer merken - Spalte, Versionsfunktionen, API
- packages/shared: parseReleaseVersion, compareReleaseVersions, ReleaseNoticeResponse
- getRunningRelease(): einzige Quelle der laufenden Version (APP_VERSION der API)
- User.lastSeenReleaseVersion (nullbar, Migration 20260925120000)
- GET /users/me/release-notice, POST /users/me/release-seen (gebunden an Benutzer und Mandant)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:42:11 +02:00
schalli b35edd5f31 test(260925-bow): Versionsvergleich und Was-ist-neu-Endpunkte (rot)
- parseReleaseVersion/compareReleaseVersions/getRunningRelease
- GET me/release-notice, POST me/release-seen: Format, nicht ueber laufend,
  nie absenken, Mandanten- und Benutzerbindung, ReleaseSeenDto

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:42:11 +02:00
34 changed files with 2518 additions and 18 deletions
+2 -1
View File
@@ -7,7 +7,7 @@ status: verified
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
last_updated: "2026-09-23T15:30:00.000Z"
last_activity: 2026-09-23
last_activity_desc: Quick 260924-m4n — flackernder Test entschaerft, alte DashboardImage-Spalte entfernt (mit Schutzklausel); 1.4.0 vom Nutzer ausdruecklich NICHT freigegeben
last_activity_desc: Quick 260925-bow — Was-ist-neu-Fenster nach Versionswechsel; 1.4.0 am 25.09. freigegeben und live; Stirling-PDF-Modul in Diskussion (Fragen an Nutzer offen)
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress:
total_phases: 18
@@ -475,6 +475,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260924-h7x | **Proxmox-Seite neu gestaltet (Status bestimmt das Bild) und Dashboard-Reiter in die Kopfzeile.** Nutzer hob am 24.09. die Umbausperre vom 23.09. selbst auf. Design-Plan aus dem frontend-design-Skill: Statusfarben als OKLCH-Tokens (`--status-ok/warn/down/idle/orphan`, dazu `-fg`-Textvarianten fuer 4,5:1), Gesundheitsbalken mit Legende, Karten mit Statusleiste links und im Statuston getoentem Schatten, eingelassene Messfelder, Knoten als Einschuebe mit Balken nach Schwellen (80/92 %, Sicherung > 26 h), PMG-Zahlfelder. **Deaktivierter Server = „Offline & verwaist“** (Vorrang vor allem, keine alten Werte, gestrichelt). Sortierung down/warn/ok/idle/orphan, Spaltenfluss statt Raster. Reiter als eingelassener Umschalter per Portal in der Kopfzeilenmitte (`header-center-slot`), eigene Zeile entfallen, Pfeiltasten, weiche Randausblendung bei Ueberlauf; unter 640 px Logo nur Bildmarke. Browser: hell/dunkel 1400 px, 390 px ohne Ueberlauf. web 789 gruen. | 2026-09-24 | 0fa7ce0,57c338f,7416a92,0b659d6,57a4196 | [260924-h7x-proxmox-seite-status-design-und-dashboar](./quick/260924-h7x-proxmox-seite-status-design-und-dashboar/) |
| 260924-i8v | **Proxmox-Kachel fuers Dashboard.** Modul-Kachel ueber den Weg aus 260922-m1h (Typ `proxmox` in packages/shared + Modulbindung, API-Freigabeliste, Registry, Katalog), nur fuer Benutzer mit Modulzugriff. Kompakter Gesundheitsbalken + Zusammenfassung in Worten, Serverliste nach Dringlichkeit mit je einer Kennzahl (Gaeste/Auslastung, aelteste Sicherung, eingehende Mails, unbekannt nie 0), Links auf /modules/proxmox (nicht im Bearbeitungsmodus), liest jede Minute den Zwischenstand (pausiert bei verborgenem Tab, loest NIE eine Abfrage aus), Titel + Serverauswahl an der Kachel und unter Einstellungen > Dashboard, Groessenstufen per Container-Query. Gemeinsame Teile nach `components/proxmox/` verschoben. Browser: Katalog, Kachel hell/dunkel, schmale Stufe (nur Punkte+Namen). web 864, api 1370 gruen. | 2026-09-24 | a906c67,92bf130,a217d60,377b6e3,586da44,602a45c | [260924-i8v-proxmox-kachel-fuers-dashboard](./quick/260924-i8v-proxmox-kachel-fuers-dashboard/) |
| 260924-m4n | **Flackernden Test entschaerft, alte Bildspalte entfernt.** (1) `tenant-selector.test.tsx`: Ursache war das Laden der Bausteine INNERHALB des ersten Tests (zaehlte in dessen 5-s-Grenze) -> Import vorab, Doppelfall getrennt, dasselbe in zwei weiteren Marktplatz-Tests; langsamster Web-Test jetzt < 2 s (mit 2 Kernen 1,3 s); act()-Warnungen der Proxmox-Kachel weg. (2) DashboardImage Stufe 2: Migration `20260924120000_dashboard_image_drop_data` mit Schutz (bricht ab, wenn noch Zeilen ohne `storagePath`; Zeilenschutz fuer die Pruefung abgeschaltet, sonst saehe sie still 0), `storagePath` NOT NULL, `data` weg, `system_read_policy` weg, Bootstrap-Umzug + `forSystem()` entfernt, Upload legt Zeile gleich mit Pfad an. Vorbedingung alpha geprueft (0 von 3 ohne Pfad); Live nicht pruefbar. Rueckweg bei Abbruch in `docs/anleitung-betrieb.md` Kap. 4. Browser/API: Bilder laden, Upload+Anzeige+Loeschen ok. api 1364, web 865 gruen. | 2026-09-24 | b10734f,dd54ec5 | [260924-m4n-flackernden-test-entschaerfen-und-dashbo](./quick/260924-m4n-flackernden-test-entschaerfen-und-dashbo/) |
| 260925-bow | **Was-ist-neu-Fenster nach Versionswechsel.** Spalte `User.lastSeenReleaseVersion` (Migration 20260925120000), Versionsnummer allein aus der API (`GET /users/me/release-notice`, Semver-Funktionen in packages/shared), Fenster im Portal-Rahmen einmal nach Versionswechsel, gemerkt erst beim Schliessen (`POST`), nur freigegebene Versionen (`dev` nie), hoechstens 3 Versionen + Hinweis auf aeltere + Link /changelog; neue Konten bekommen die laufende Version eingetragen; vorhandene ohne Stand sehen nur die aktuelle. Changelog-Text bleibt serverseitig. Browser: 1.3.0 -> Fenster 1.4.0, Verstanden merkt 1.4.0, kein zweites Mal; 1.0.0 -> 1.4.0/1.3.1/1.3.0 + „2 aelteren Versionen“; Link-Kontrast nachgebessert. api 1435, web 924 gruen. | 2026-09-25 | 59db32a,187fb76,5ae9aaa,b3b7b5d | [260925-bow-was-ist-neu-fenster-beim-ersten-anmelden](./quick/260925-bow-was-ist-neu-fenster-beim-ersten-anmelden/) |
## Deferred Items
@@ -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).
+4
View File
@@ -4,6 +4,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht
### Neu
- 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 finden Sie weiterhin unter „Was ist neu“.
## 1.4.0 – 2026-09-25
### Neu
@@ -0,0 +1,15 @@
-- quick-260925-bow: "Was ist neu"-Fenster nach einem Versionswechsel.
--
-- Merkt pro Benutzer die zuletzt gesehene freigegebene Version (X.Y.Z), damit
-- das Fenster im Browser und in der Desktop-App genau einmal je Version
-- erscheint. Gesetzt wird der Wert nur ueber POST /users/me/release-seen
-- (beim Schliessen des Fensters) und bei der Anlage neuer Benutzer (laufende
-- Version). NULL = Bestandsbenutzer ohne gemerkten Stand; sie sehen beim
-- ersten Mal nur den Abschnitt der laufenden Version. Bewusst kein
-- Standardwert und kein Backfill.
--
-- Die Anmelde-Funktionen auth_lookup_* liefern eine feste Spaltenliste
-- (RETURNS TABLE) und bleiben von der neuen Spalte unberuehrt.
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastSeenReleaseVersion" TEXT;
+3
View File
@@ -43,6 +43,9 @@ model User {
lastLoginAt DateTime?
avatarPath String?
accentColor String?
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
lastSeenReleaseVersion String?
passwordResetTokens PasswordResetToken[]
groupMemberships GroupMembership[]
moduleGrants ModuleGrant[]
+22 -1
View File
@@ -1,4 +1,4 @@
import type { VersionResponse } from '@tessera/shared';
import { parseReleaseVersion, type VersionResponse } from '@tessera/shared';
/**
* Versionsstempel der API (quick-260914-ku1).
@@ -30,3 +30,24 @@ export function formatAppVersionLine(v: VersionResponse = getAppVersion()): stri
const base = `Tessera API ${v.version} (${v.channel})`;
return v.commit ? `${base} ${v.commit}` : base;
}
/**
* Laufende FREIGEGEBENE Version der API als `X.Y.Z` oder `null`
* (quick-260925-bow, D-02/D-04).
*
* Einzige Quelle der laufenden Version fuer das "Was ist neu"-Fenster:
* `APP_VERSION` der API. Warum die API und nicht das Web: zwei der drei
* Anlagewege neuer Benutzer laufen ohne jede Web-Anfrage (LDAP-Abgleich per
* Zeitplan, Erst-Administrator beim API-Start) und tragen die Version bei der
* Anlage ein; ausserdem prueft `POST /users/me/release-seen` gegen diesen
* Wert. Das Web nimmt `currentRelease` aus `GET /users/me/release-notice`
* und wertet seine eigene `NEXT_PUBLIC_APP_VERSION` dafuer nicht aus. Beide
* Abbilder bekommen im CI denselben `APP_VERSION`-Wert
* (`.gitea/scripts/publish-images.sh`).
*
* `v1.4.0-5-gabc1234` (Beta, Describe-Stand) → `1.4.0`; `dev` oder ein
* blosser Commit-Stempel → `null` (dann erscheint nie ein Fenster).
*/
export function getRunningRelease(): string | null {
return parseReleaseVersion(getAppVersion().version);
}
+122
View File
@@ -0,0 +1,122 @@
import { compareReleaseVersions, parseReleaseVersion } from '@tessera/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRunningRelease } from './app-version';
/**
* Freigegebene Versionen (quick-260925-bow, D-02/D-06).
*
* `parseReleaseVersion` und `compareReleaseVersions` stehen EINMAL in
* `packages/shared/src/index.ts` und werden von API und Web benutzt. Getestet
* wird hier in der API-Suite, weil `packages/shared` keinen eigenen Testlauf
* hat (Vorbild `widget-module-map.spec.ts`).
*
* `getRunningRelease()` ist die einzige Quelle der laufenden Version fuer das
* "Was ist neu"-Fenster: `APP_VERSION` der API, auf X.Y.Z gekuerzt.
*/
afterEach(() => {
vi.unstubAllEnvs();
});
describe('parseReleaseVersion (quick-260925-bow)', () => {
it.each([
['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'],
['010.02.3', '10.2.3'],
['1.4.0', '1.4.0'],
])('%s → %s', (raw, expected) => {
expect(parseReleaseVersion(raw)).toBe(expected);
});
it.each([
['dev'],
[''],
['abc1234'],
['10.2'],
['10.2.3-rc.1'],
['10.2.3-dirty'],
[' 10.2.3'],
['10.2.3 '],
['V10.2.3'],
['vv10.2.3'],
['10.2.3.4'],
['1234567.0.0'],
['10.2.3-5-gxyz1234'],
['10.2.3-5-gabc'],
['10.2.3-g0123456'],
])('%j → null', (raw) => {
expect(parseReleaseVersion(raw)).toBeNull();
});
it('Eingaben ueber 64 Zeichen ergeben null, auch wenn das Muster sonst passen wuerde', () => {
const long = `1.2.3-5-g${'a'.repeat(40)}`;
expect(long.length).toBeLessThanOrEqual(64);
expect(parseReleaseVersion(long)).toBe('1.2.3');
expect(parseReleaseVersion(`${'1'.repeat(70)}.0.0`)).toBeNull();
expect(parseReleaseVersion('x'.repeat(10_000))).toBeNull();
});
it('Nicht-Zeichenketten ergeben null statt eines Fehlers', () => {
expect(parseReleaseVersion(undefined as unknown as string)).toBeNull();
expect(parseReleaseVersion(null as unknown as string)).toBeNull();
expect(parseReleaseVersion(123 as unknown as string)).toBeNull();
});
});
describe('compareReleaseVersions (quick-260925-bow)', () => {
it('vergleicht numerisch, nicht lexikografisch', () => {
expect(compareReleaseVersions('1.10.0', '1.9.0')).toBe(1);
expect(compareReleaseVersions('1.9.0', '1.10.0')).toBe(-1);
});
it('die hoehere Hauptversion gewinnt', () => {
expect(compareReleaseVersions('2.0.0', '1.99.99')).toBe(1);
expect(compareReleaseVersions('1.99.99', '2.0.0')).toBe(-1);
});
it('Patch-Stelle entscheidet bei gleicher Haupt- und Nebenversion', () => {
expect(compareReleaseVersions('1.3.1', '1.3.0')).toBe(1);
});
it('gleiche Versionen ergeben 0, auch mit v-Praefix und Describe-Anhang', () => {
expect(compareReleaseVersions('1.4.0', '1.4.0')).toBe(0);
expect(compareReleaseVersions('v10.2.3', '10.2.3')).toBe(0);
expect(compareReleaseVersions('v10.2.3-5-gabc1234', '10.2.3')).toBe(0);
});
it('wirft bei nicht parsebarer Eingabe', () => {
expect(() => compareReleaseVersions('dev', '1.0.0')).toThrow();
expect(() => compareReleaseVersions('1.0.0', 'abc1234')).toThrow();
});
});
describe('getRunningRelease (quick-260925-bow)', () => {
it('liest APP_VERSION und kuerzt den Describe-Stand auf X.Y.Z', () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
expect(getRunningRelease()).toBe('10.2.3');
});
it('ein Tag-Stand ergibt die Version selbst', () => {
vi.stubEnv('APP_VERSION', 'v1.4.0');
expect(getRunningRelease()).toBe('1.4.0');
});
it('dev ergibt null', () => {
vi.stubEnv('APP_VERSION', 'dev');
expect(getRunningRelease()).toBeNull();
});
it('ungesetzt oder leer ergibt null', () => {
vi.stubEnv('APP_VERSION', undefined);
expect(getRunningRelease()).toBeNull();
vi.stubEnv('APP_VERSION', '');
expect(getRunningRelease()).toBeNull();
});
it('ein blosser Commit-Stempel ergibt null', () => {
vi.stubEnv('APP_VERSION', 'abc1234');
expect(getRunningRelease()).toBeNull();
});
});
+35 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminSeedService } from './admin-seed.service';
/**
@@ -95,6 +95,40 @@ describe('AdminSeedService', () => {
service = new AdminSeedService(prisma, configService, groupsService);
});
afterEach(() => {
vi.unstubAllEnvs();
});
// quick-260925-bow (D-04): der Erst-Administrator bekommt die laufende
// freigegebene Version eingetragen und sieht kein "Was ist neu"-Fenster
// mit Altlasten; auf dev-Staenden null.
it('legt den Erst-Administrator mit lastSeenReleaseVersion der laufenden Version an', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234');
let createdData: any;
prisma.__setUserCreateImpl(async (args: any) => {
createdData = args.data;
return { id: 'u1' };
});
await service.onApplicationBootstrap();
expect(createdData.lastSeenReleaseVersion).toBe('10.2.3');
expect(createdData.role).toBe('SUPER_ADMIN');
});
it('legt den Erst-Administrator auf dev-Staenden mit lastSeenReleaseVersion null an', async () => {
vi.stubEnv('APP_VERSION', 'dev');
let createdData: any;
prisma.__setUserCreateImpl(async (args: any) => {
createdData = args.data;
return { id: 'u1' };
});
await service.onApplicationBootstrap();
expect(createdData).toHaveProperty('lastSeenReleaseVersion', null);
});
it('ruft auf einer frischen Installation in genau dieser Reihenfolge auf: tenant.upsert, ensureDefaultGroup, dann user.create', async () => {
await service.onApplicationBootstrap();
+5
View File
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { GroupsService } from '../groups/groups.service';
import { getRunningRelease } from '../health/app-version';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import { PrismaService } from '../prisma/prisma.service';
@@ -116,6 +117,10 @@ export class AdminSeedService implements OnApplicationBootstrap {
tenantId: tenant.id,
mustChangePassword: forceChange,
isActive: true,
// quick-260925-bow (D-04): laufende freigegebene Version, damit der
// Erst-Administrator kein "Was ist neu"-Fenster mit Altlasten sieht;
// null auf dev-Staenden.
lastSeenReleaseVersion: getRunningRelease(),
},
});
} catch (err: unknown) {
+17
View File
@@ -0,0 +1,17 @@
import { IsString, Matches, MaxLength } from 'class-validator';
/**
* Body von `POST /users/me/release-seen` (quick-260925-bow, D-05).
*
* Nur die kanonische Form `X.Y.Z` (ohne `v`, ohne fuehrende Nullen, ohne
* Describe-Anhang) — so, wie `GET /users/me/release-notice` sie als
* `currentRelease` liefert. Die Methode im Controller prueft zusaetzlich
* `parseReleaseVersion(version) === version` und "nicht ueber der laufenden
* Version", weil Unit-Tests sie ohne ValidationPipe aufrufen.
*/
export class ReleaseSeenDto {
@IsString()
@MaxLength(32)
@Matches(/^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/)
version!: string;
}
+185 -2
View File
@@ -1,6 +1,9 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReleaseSeenDto } from './dto/release-seen.dto';
import { UserController } from './user.controller';
/**
@@ -397,4 +400,184 @@ describe('UserController', () => {
expect(userService.delete).not.toHaveBeenCalled();
});
});
/**
* quick-260925-bow: "Was ist neu"-Fenster. Beide Wege lesen und schreiben
* ausschliesslich die Zeile des angemeldeten Benutzers ueber den an dessen
* Mandanten gebundenen Klienten; es gibt keinen Kennungsparameter.
* Die laufende Version kommt aus APP_VERSION (getRunningRelease()).
*/
describe('Was-ist-neu-Fenster (quick-260925-bow)', () => {
const me = { role: Role.USER, tenantId: 't1', id: 'u-me', username: 'me', mustChangePassword: false };
afterEach(() => {
vi.unstubAllEnvs();
});
function seedMe(lastSeenReleaseVersion: string | null, tenantId = 't1', id = 'u-me') {
prisma.__seedUser({ id, username: id, tenantId, lastSeenReleaseVersion });
}
describe('GET me/release-notice', () => {
it('liefert laufende Version und gemerkten Stand, gelesen ueber den gebundenen Klienten des eigenen Mandanten', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
seedMe('10.1.0');
const result = await controller.getReleaseNotice(me);
expect(result).toEqual({ currentRelease: '10.2.3', lastSeenReleaseVersion: '10.1.0' });
expectBoundCall(prisma, 't1', 'user', 'findUnique');
});
it('Bestandsbenutzer ohne gemerkten Stand: lastSeenReleaseVersion ist null; dev-Stand: currentRelease ist null', async () => {
vi.stubEnv('APP_VERSION', 'dev');
seedMe(null);
const result = await controller.getReleaseNotice(me);
expect(result).toEqual({ currentRelease: null, lastSeenReleaseVersion: null });
});
it('ein Benutzer derselben Kennung in einem fremden Mandanten ist unsichtbar: NotFoundException', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe('10.1.0', 't2');
await expect(controller.getReleaseNotice(me)).rejects.toBeInstanceOf(NotFoundException);
expectBoundCall(prisma, 't1', 'user', 'findUnique');
});
});
describe('POST me/release-seen', () => {
it('merkt die laufende Version und nennt den gespeicherten Stand', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null);
const result = await controller.markReleaseSeen({ version: '10.2.3' }, me);
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
const after = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } });
expect(after.lastSeenReleaseVersion).toBe('10.2.3');
expectBoundCall(prisma, 't1', 'user', 'findUnique');
expectBoundCall(prisma, 't1', 'user', 'update');
});
it('eine aeltere, aber nicht zu hohe Version wird angenommen, wenn sie ueber dem gemerkten Stand liegt', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
seedMe('10.0.0');
const result = await controller.markReleaseSeen({ version: '10.1.0' }, me);
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.1.0' });
});
it.each([
['v10.2.3'],
['10.2'],
['abc'],
['10.2.3-5-gabc1234'],
['010.2.3'],
[''],
[' 10.2.3'],
])('nicht kanonisches Format %j → BadRequestException, nichts gespeichert', async (version) => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null);
await expect(controller.markReleaseSeen({ version }, me)).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
});
it.each([[123], [null], [undefined], [{ version: '10.2.3' }], [['10.2.3']]])(
'Nicht-Zeichenkette %j → BadRequestException',
async (version) => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null);
await expect(
controller.markReleaseSeen({ version } as unknown as ReleaseSeenDto, me),
).rejects.toBeInstanceOf(BadRequestException);
},
);
it('eine Version ueber der laufenden → BadRequestException', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null);
await expect(controller.markReleaseSeen({ version: '10.2.4' }, me)).rejects.toBeInstanceOf(BadRequestException);
await expect(controller.markReleaseSeen({ version: '11.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
});
it.each([['dev'], ['abc1234'], [undefined]])(
'laufende Version nicht freigegeben (%j) → BadRequestException',
async (appVersion) => {
vi.stubEnv('APP_VERSION', appVersion);
seedMe(null);
await expect(controller.markReleaseSeen({ version: '1.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException);
},
);
it('ein gemerkter Stand wird nie abgesenkt', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe('10.2.3');
const result = await controller.markReleaseSeen({ version: '10.1.0' }, me);
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
});
it('ein unparsebarer gemerkter Wert wird ueberschrieben', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe('kaputt');
const result = await controller.markReleaseSeen({ version: '10.2.3' }, me);
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
});
it('zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden aendert sich', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null, 't1', 'u-me');
seedMe(null, 't2', 'u-other');
await controller.markReleaseSeen({ version: '10.2.3' }, me);
const mine = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } });
const other = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-other' } });
expect(mine.lastSeenReleaseVersion).toBe('10.2.3');
expect(other.lastSeenReleaseVersion).toBeNull();
expect(prisma.__boundCallLog.filter((c: any) => c.method === 'update').every((c: any) => c.tenantId === 't1')).toBe(true);
});
it('fehlt die eigene Zeile im eigenen Mandanten (Kennung nur in fremdem Mandanten) → NotFoundException, nichts geaendert', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
seedMe(null, 't2', 'u-me');
await expect(controller.markReleaseSeen({ version: '10.2.3' }, me)).rejects.toBeInstanceOf(NotFoundException);
const foreign = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-me' } });
expect(foreign.lastSeenReleaseVersion).toBeNull();
});
});
describe('ReleaseSeenDto (globale ValidationPipe)', () => {
async function errorsFor(body: unknown) {
return validate(plainToInstance(ReleaseSeenDto, body));
}
it('nimmt die kanonische Form X.Y.Z an', async () => {
expect(await errorsFor({ version: '1.4.0' })).toHaveLength(0);
});
it.each([
[{ version: 'v1.4.0' }],
[{ version: '1.4' }],
[{ version: '1.4.0-5-gabc1234' }],
[{ version: 123 }],
[{}],
[{ version: `1.4.0${'0'.repeat(40)}` }],
])('lehnt %j ab', async (body) => {
expect((await errorsFor(body)).length).toBeGreaterThan(0);
});
});
});
});
+101
View File
@@ -5,6 +5,8 @@ import {
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
@@ -16,6 +18,11 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import {
compareReleaseVersions,
parseReleaseVersion,
type ReleaseNoticeResponse,
} from '@tessera/shared';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Response } from 'express';
@@ -25,7 +32,9 @@ import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { getRunningRelease } from '../health/app-version';
import { CreateUserDto } from './dto/create-user.dto';
import { ReleaseSeenDto } from './dto/release-seen.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service';
@@ -48,6 +57,11 @@ function resolveAvatarsDir(): string {
* gebundenen Klienten (`tenantPrisma`, Konvention aus `ldap`, `groups`,
* `dkv`, `auth`, `user.service.ts`) oder ueber die uebergreifenden Methoden
* von `UserService`, deren Rumpf je Mandant gebunden ist.
*
* quick-260925-bow: dazu kommen drei gebundene Zugriffe der beiden
* Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen`
* ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit
* `where: { id: currentUser.id }`.
*/
@Controller('users')
@UseGuards(RolesGuard)
@@ -111,6 +125,93 @@ export class UserController {
});
}
/**
* GET /users/me/release-notice (quick-260925-bow, D-03/D-04)
*
* Grundlage des "Was ist neu"-Fensters: die laufende freigegebene Version
* der API (`getRunningRelease()`, `null` auf `dev`-Staenden) und der
* gemerkte Stand des angemeldeten Benutzers (`null` = Bestandsbenutzer).
* Welche Abschnitte der Aenderungsliste gezeigt werden, entscheidet das Web.
*
* Jeder angemeldete Benutzer, kein `@Roles`. Mandantenbindung: gelesen wird
* ausschliesslich die eigene Zeile (`where: { id: currentUser.id }`) ueber
* `forTenant(this.prisma, currentUser.tenantId)`; es gibt keinen
* Kennungsparameter. Statische `me/...`-Route steht VOR der Kennungs-Route (GET :id).
*/
@Get('me/release-notice')
async getReleaseNotice(@CurrentUser() currentUser: AuthUser): Promise<ReleaseNoticeResponse> {
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id },
select: { lastSeenReleaseVersion: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
return {
currentRelease: getRunningRelease(),
lastSeenReleaseVersion: user.lastSeenReleaseVersion,
};
}
/**
* POST /users/me/release-seen (quick-260925-bow, D-05)
*
* Merkt beim Schliessen des Fensters die gesehene Version. Angenommen wird
* nur die kanonische Form `X.Y.Z`, die nicht ueber der laufenden Version
* liegt; ohne freigegebene laufende Version (`dev`) wird nichts gemerkt.
* Ein gemerkter Stand wird nie abgesenkt; ein unparsebarer gemerkter Wert
* wird ueberschrieben. Die DTO prueft das Format schon in der
* ValidationPipe; die Pruefungen im Rumpf gelten zusaetzlich (Unit-Tests
* rufen die Methode ohne Pipe auf).
*
* Jeder angemeldete Benutzer, kein `@Roles`. Mandantenbindung wie oben:
* Lesen und Schreiben nur der eigenen Zeile ueber den an
* `currentUser.tenantId` gebundenen Klienten.
*/
@Post('me/release-seen')
@HttpCode(HttpStatus.OK)
async markReleaseSeen(
@Body() body: ReleaseSeenDto,
@CurrentUser() currentUser: AuthUser,
): Promise<{ success: true; lastSeenReleaseVersion: string }> {
const version: unknown = body?.version;
if (typeof version !== 'string' || parseReleaseVersion(version) !== version) {
throw new BadRequestException('Invalid version. Use X.Y.Z.');
}
const running = getRunningRelease();
if (running === null) {
throw new BadRequestException('No released version is running.');
}
if (compareReleaseVersions(version, running) > 0) {
throw new BadRequestException('Version is newer than the running version.');
}
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: currentUser.id },
select: { lastSeenReleaseVersion: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
const stored = user.lastSeenReleaseVersion;
if (
stored !== null &&
parseReleaseVersion(stored) !== null &&
compareReleaseVersions(version, stored) <= 0
) {
return { success: true, lastSeenReleaseVersion: stored };
}
await tenantPrisma.user.update({
where: { id: currentUser.id },
data: { lastSeenReleaseVersion: version },
});
return { success: true, lastSeenReleaseVersion: version };
}
/**
* GET /users/:id
*/
+51 -1
View File
@@ -1,5 +1,5 @@
import { ConflictException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { UserService } from './user.service';
/**
@@ -251,6 +251,56 @@ describe('UserService', () => {
});
});
/**
* quick-260925-bow (D-04): neue Benutzer bekommen die laufende freigegebene
* Version eingetragen, damit sie kein "Was ist neu"-Fenster mit Altlasten
* sehen. Der Wert ist eine Eigenschaft des Servers (APP_VERSION), nicht des
* Aufrufers — die Signatur von create() bekommt kein neues Feld.
*/
describe('create — lastSeenReleaseVersion (quick-260925-bow)', () => {
let prisma: any;
let service: UserService;
beforeEach(() => {
prisma = makeFakePrisma();
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined) } as any);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('traegt bei freigegebener laufender Version X.Y.Z ein (Describe-Stand gekuerzt)', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234');
const result = await service.create({ username: 'neu', email: 'neu@example.com', tenantId: 't1' });
expect(result.lastSeenReleaseVersion).toBe('10.2.3');
expectBoundCall(prisma, 't1', 'user', 'create');
});
it('traegt auf dev-Staenden null ein', async () => {
vi.stubEnv('APP_VERSION', 'dev');
const result = await service.create({ username: 'neu2', email: 'neu2@example.com', tenantId: 't1' });
expect(result.lastSeenReleaseVersion).toBeNull();
});
it('traegt ohne APP_VERSION null ein', async () => {
vi.stubEnv('APP_VERSION', undefined);
const result = await service.create({ username: 'neu3', email: 'neu3@example.com', tenantId: 't1' });
expect(result.lastSeenReleaseVersion).toBeNull();
});
it('ein von aussen untergeschobenes Feld setzt den Wert nicht (kein Parameter der Signatur)', async () => {
vi.stubEnv('APP_VERSION', 'v10.2.3');
const result = await service.create({
username: 'neu4',
email: 'neu4@example.com',
tenantId: 't1',
lastSeenReleaseVersion: '99.0.0',
} as unknown as Parameters<UserService['create']>[0]);
expect(result.lastSeenReleaseVersion).toBe('10.2.3');
});
});
describe('findById', () => {
let prisma: any;
let service: UserService;
+10
View File
@@ -2,6 +2,7 @@ import { ConflictException, Injectable, Logger } from '@nestjs/common';
import * as argon2 from 'argon2';
import type { User } from '@prisma/client';
import { GroupsService } from '../groups/groups.service';
import { getRunningRelease } from '../health/app-version';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error';
import { PrismaService } from '../prisma/prisma.service';
@@ -140,6 +141,14 @@ export class UserService {
* endet damit bei JEDEM Aufrufer an dieser einen Stelle. Die Meldung
* nennt WEDER den Halter NOCH dessen Mandanten, weil das sonst eine
* Aussage ueber einen fremden Mandanten waere (T-DAS-08).
*
* "Was ist neu"-Fenster (quick-260925-bow, D-04): neue Benutzer (Admin-
* Anlage, AD-Abgleich und AD-Import) bekommen hier die laufende
* freigegebene Version (`getRunningRelease()`) als
* `lastSeenReleaseVersion` eingetragen, damit sie kein Fenster mit
* Aenderungen aus der Zeit vor ihrem Konto sehen; auf Staenden ohne
* freigegebene Version (`dev`) `null`. Der Wert ist eine Eigenschaft des
* Servers, kein Parameter dieser Methode.
*/
async create(data: {
username: string;
@@ -164,6 +173,7 @@ export class UserService {
...rest,
username: rest.username.toLowerCase(),
passwordHash: password ? await argon2.hash(password) : null,
lastSeenReleaseVersion: getRunningRelease(),
},
});
} catch (err: unknown) {
+4 -2
View File
@@ -7,8 +7,10 @@ import createNextIntlPlugin from 'next-intl/plugin';
// dem Wurzelverzeichnis wird hier gelesen und als `env.TESSERA_CHANGELOG_MD`
// abgelegt. Next.js ersetzt `process.env.TESSERA_CHANGELOG_MD` fuer webpack UND
// Turbopack ueber denselben Define-Mechanismus. Der Text bleibt nur im
// Server-Bundle, weil ausschliesslich die Server-Seite (changelog/page.tsx)
// `@/lib/changelog` importiert — Importdisziplin, kein Client-Chunk.
// Server-Bundle, weil ausschliesslich Server-Code `@/lib/changelog` importiert:
// die Server-Seite (changelog/page.tsx) und die 'use server'-Datei
// lib/release-notice-actions.ts (quick-260925-bow) — Importdisziplin, kein
// Client-Chunk.
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
@@ -15,8 +15,19 @@ import { useTheme } from 'next-themes';
* uebereinstimmen (mounted-Guard wie in AppShell). Der Markdown-Text kommt
* als Prop von der Server-Seite; dieses Modul importiert `@/lib/changelog`
* bewusst NICHT, damit der Text nicht in Client-Chunks landet.
*
* quick-260925-bow: `variant="plain"` laesst Rahmen, Hintergrund und
* Innenabstand weg — so rendert das "Was ist neu"-Fenster seine Abschnitte mit
* demselben Renderer wie die Seite /changelog (Vorgabe `card`, Seite
* unveraendert).
*/
export function ChangelogView({ markdown }: { markdown: string }) {
export function ChangelogView({
markdown,
variant = 'card',
}: {
markdown: string;
variant?: 'card' | 'plain';
}) {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@@ -27,7 +38,10 @@ export function ChangelogView({ markdown }: { markdown: string }) {
const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
return (
<div data-testid="changelog-markdown" className="rounded-md border border-border bg-card p-4">
<div
data-testid="changelog-markdown"
className={variant === 'card' ? 'rounded-md border border-border bg-card p-4' : undefined}
>
<MDEditor.Markdown
source={markdown}
rehypePlugins={[[rehypeSanitize]]}
@@ -5,6 +5,7 @@ import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { installErrorBuffer } from '@/lib/error-buffer';
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
export function AppShell({ children }: { children: React.ReactNode }) {
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
@@ -37,6 +38,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
>
{children}
</main>
{/* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow):
nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */}
<ReleaseNoticeHost />
</div>
);
}
@@ -0,0 +1,217 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReleaseNotice } from '@/lib/release-notes';
import de from '@/messages/de.json';
/**
* release-notice-dialog.test — "Was ist neu"-Fenster (quick-260925-bow, D-07).
*
* Echte deutsche Texte ueber NextIntlClientProvider (prueft auch die
* ICU-Mehrzahl von `moreVersions`). Der Markdown-Renderer wird wie im Test
* der Seite /changelog durch ein <pre> ersetzt; `next/link` durch ein
* schlichtes <a> (Vorbild ServerCard.test.tsx).
*/
vi.mock('@uiw/react-md-editor', () => ({
default: {
Markdown: ({ source }: { source?: string }) => <pre data-testid="md">{source}</pre>,
},
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}));
vi.mock('next/link', () => ({
default: ({
href,
children,
onClick,
className,
}: {
href: string;
children: React.ReactNode;
onClick?: () => void;
className?: string;
}) => (
<a
href={href}
className={className}
onClick={(event) => {
event.preventDefault();
onClick?.();
}}
>
{children}
</a>
),
}));
import { ReleaseNoticeDialog } from './release-notice-dialog';
const ONE: ReleaseNotice = {
currentRelease: '1.4.0',
versions: [
{
version: '1.4.0',
sections: [
{ kind: 'new', markdown: '- Funktion N140' },
{ kind: 'changed', markdown: '- Aenderung G140' },
{ kind: 'fixed', markdown: '- Fehler F140' },
],
},
],
omittedCount: 0,
};
const THREE: ReleaseNotice = {
currentRelease: '1.4.0',
versions: [
{ version: '1.4.0', sections: [{ kind: 'new', markdown: '- N140' }] },
{ version: '1.3.1', sections: [{ kind: 'fixed', markdown: '- F131' }] },
{ version: '1.3.0', sections: [{ kind: 'changed', markdown: '- G130' }] },
],
omittedCount: 2,
};
function renderDialog(notice: ReleaseNotice, onClose = vi.fn()) {
render(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
<ReleaseNoticeDialog notice={notice} onClose={onClose} />
</NextIntlClientProvider>,
);
return onClose;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ReleaseNoticeDialog (quick-260925-bow)', () => {
it('Titel „Neu in Version 1.4.0“, role=dialog mit aria-modal und aria-labelledby auf die Ueberschrift', () => {
renderDialog(ONE);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
const heading = screen.getByRole('heading', { level: 2, name: 'Neu in Version 1.4.0' });
expect(dialog.getAttribute('aria-labelledby')).toBe(heading.id);
expect(heading.id).not.toBe('');
});
it('Anfangsfokus liegt auf der Ueberschrift', () => {
renderDialog(ONE);
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 2 }));
});
it('Gruppenueberschriften Neu, Verbessert, Behoben mit den Eintraegen; keine Versionsunterueberschrift bei einer Version', () => {
renderDialog(ONE);
const groups = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
expect(groups).toEqual(['Neu', 'Verbessert', 'Behoben']);
expect(screen.queryByText('Version 1.4.0')).toBeNull();
const md = screen.getAllByTestId('md').map((el) => el.textContent);
expect(md).toEqual(['- Funktion N140', '- Aenderung G140', '- Fehler F140']);
});
it('kein Satz ueber weitere Versionen bei omittedCount 0', () => {
renderDialog(ONE);
expect(screen.queryByText(/Dazu kommen Änderungen/)).toBeNull();
});
it('mehrere Versionen: Versionsunterueberschriften und Satz mit der Zahl weiterer Versionen', () => {
renderDialog(THREE);
const versions = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
expect(versions).toEqual(['Version 1.4.0', 'Version 1.3.1', 'Version 1.3.0']);
const groups = screen.getAllByRole('heading', { level: 4 }).map((h) => h.textContent);
expect(groups).toEqual(['Neu', 'Behoben', 'Verbessert']);
expect(screen.getByText('Dazu kommen Änderungen aus 2 älteren Versionen.')).toBeInTheDocument();
});
it('Einzahl im Satz ueber weitere Versionen', () => {
renderDialog({ ...THREE, omittedCount: 1 });
expect(screen.getByText('Dazu kommen Änderungen aus 1 älteren Version.')).toBeInTheDocument();
});
it('Link „Alle Änderungen ansehen“ auf /changelog ist immer vorhanden', () => {
renderDialog(ONE);
const link = screen.getByRole('link', { name: 'Alle Änderungen ansehen' });
expect(link).toHaveAttribute('href', '/changelog');
});
it('„Verstanden“ ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
fireEvent.click(screen.getByRole('button', { name: 'Verstanden' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('das Schließen-Kreuz ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Schließen' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('Escape ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('der Hintergrund ist eine benannte Schaltflaeche und ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
fireEvent.click(screen.getByRole('button', { name: 'Fenster schließen' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('der Link zu /changelog ruft onClose genau einmal', () => {
const onClose = renderDialog(ONE);
fireEvent.click(screen.getByRole('link', { name: 'Alle Änderungen ansehen' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('Fokusfalle: Tab vom letzten Element springt zum ersten, Umschalt+Tab vom ersten zum letzten', () => {
renderDialog(ONE);
const dialog = screen.getByRole('dialog');
const close = within(dialog).getByRole('button', { name: 'Schließen' });
const confirm = screen.getByRole('button', { name: 'Verstanden' });
confirm.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(document.activeElement).toBe(close);
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(confirm);
});
it('Tab mit Fokus auf der Ueberschrift springt auf das erste Element im Fenster', () => {
renderDialog(ONE);
const dialog = screen.getByRole('dialog');
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 2 }));
fireEvent.keyDown(document, { key: 'Tab' });
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: 'Schließen' }));
});
it('Fokus ausserhalb des Fensters (z. B. auf dem Hintergrund) wird per Tab ins Fenster geholt', () => {
renderDialog(ONE);
const backdrop = screen.getByRole('button', { name: 'Fenster schließen' });
const dialog = screen.getByRole('dialog');
backdrop.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(dialog.contains(document.activeElement)).toBe(true);
});
it('gibt beim Schliessen den Fokus an das zuvor aktive Element zurueck', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
const { unmount } = render(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
<ReleaseNoticeDialog notice={ONE} onClose={vi.fn()} />
</NextIntlClientProvider>,
);
expect(document.activeElement).not.toBe(opener);
unmount();
expect(document.activeElement).toBe(opener);
opener.remove();
});
});
@@ -0,0 +1,227 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useId, useRef } from 'react';
import { ChangelogView } from '@/components/changelog/changelog-view';
import type { ReleaseNotice } from '@/lib/release-notes';
/**
* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow, D-07).
*
* Aufbau nach `widget-catalog-modal.tsx`: Hintergrund als echte, benannte
* Schaltflaeche, Dialog mit `role="dialog"`, `aria-modal` und
* `aria-labelledby` auf die Ueberschrift. Anfangsfokus auf der Ueberschrift,
* Escape schliesst, Tab/Umschalt+Tab bleiben im Fenster (die fokussierbaren
* Elemente werden bei jedem Tastendruck neu ermittelt, weil die
* Markdown-Ausgabe Links enthalten kann). Beim Schliessen geht der Fokus an
* das zuvor aktive Element zurueck. Keine Einblendanimation.
*
* Eintraege rendert `ChangelogView` (derselbe Renderer wie /changelog, mit
* `rehype-sanitize`). Dieses Modul importiert `@/lib/changelog` NICHT — den
* Inhalt bekommt es fertig ausgewaehlt als Eigenschaft von der Server-Aktion.
*
* Jeder Schliessweg (Verstanden, Kreuz, Escape, Hintergrund, Link zu
* /changelog) ruft `onClose` genau einmal; gemerkt wird im Host.
*/
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';
interface ReleaseNoticeDialogProps {
notice: ReleaseNotice;
onClose: () => void;
}
export function ReleaseNoticeDialog({ notice, onClose }: ReleaseNoticeDialogProps) {
const t = useTranslations('releaseNotice');
const tCommon = useTranslations('common');
const titleId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const onCloseRef = useRef(onClose);
const closedRef = useRef(false);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
// Ein Schliessweg zaehlt genau einmal, auch wenn z. B. Escape und ein Klick
// kurz nacheinander kommen.
const close = () => {
if (closedRef.current) return;
closedRef.current = true;
onCloseRef.current();
};
// Anfangsfokus und Rueckgabe des Fokus beim Unmount.
useEffect(() => {
const previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
headingRef.current?.focus();
return () => {
previouslyFocused?.focus();
};
}, []);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const active = document.activeElement;
// Liegt der Fokus in einem ANDEREN Dialog, gehoert die Taste ihm.
const otherDialog = active instanceof Element ? active.closest('[role="dialog"]') : null;
if (otherDialog && otherDialog !== dialogRef.current) return;
if (event.key === 'Escape') {
event.preventDefault();
if (closedRef.current) return;
closedRef.current = true;
onCloseRef.current();
return;
}
if (event.key !== 'Tab') return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusables = Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
if (focusables.length === 0) {
event.preventDefault();
headingRef.current?.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const index = active instanceof HTMLElement ? focusables.indexOf(active) : -1;
if (index === -1) {
// Fokus auf der Ueberschrift oder ausserhalb (Hintergrund): ins Fenster holen.
event.preventDefault();
(event.shiftKey ? last : first).focus();
return;
}
if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const multipleVersions = notice.versions.length > 1;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Hintergrund als echte, benannte Schaltflaeche (wie im Kachel-Katalog). */}
<button
type="button"
onClick={close}
aria-label={t('close')}
className="fixed inset-0 bg-black/50"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative z-50 mx-4 flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg border border-border bg-card shadow-xl"
>
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
<h2
id={titleId}
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold text-foreground focus:outline-none"
>
{t('title', { version: notice.currentRelease })}
</h2>
<button
type="button"
onClick={close}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={tCommon('close')}
>
<svg
aria-hidden="true"
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"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<p className="px-6 pt-4 text-sm text-muted-foreground">{t('intro')}</p>
{/* Benannter, scrollbarer Bereich. Ohne eigenes tabIndex: heutige
Chromium- und WebKit-Versionen (Browser, Desktop-App) machen einen
Scroll-Container ohne fokussierbaren Inhalt selbst per Tastatur
erreichbar; enthaelt er Links, scrollt der Tab-Fokus mit. */}
<section
aria-label={t('contentLabel')}
className="min-h-0 flex-1 overflow-y-auto px-6 py-4"
>
<div className="flex flex-col gap-5">
{notice.versions.map((release) => {
// Ueberschriften-Hierarchie: bei einer Version sind die Gruppen
// h3; bei mehreren traegt die Version h3 und die Gruppen h4.
const GroupHeading = multipleVersions ? 'h4' : 'h3';
return (
<div key={release.version} className="flex flex-col gap-3">
{multipleVersions && (
<h3 className="text-base font-semibold text-foreground">
{t('versionHeading', { version: release.version })}
</h3>
)}
{release.sections.map((section) => (
<div key={section.kind} className="flex flex-col gap-1">
<GroupHeading className="text-sm font-semibold text-foreground">
{t(`section.${section.kind}`)}
</GroupHeading>
<ChangelogView markdown={section.markdown} variant="plain" />
</div>
))}
</div>
);
})}
</div>
</section>
<div className="flex flex-col gap-3 border-t border-border px-6 py-4">
{notice.omittedCount > 0 && (
<p className="text-sm text-muted-foreground">
{t('moreVersions', { count: notice.omittedCount })}
</p>
)}
<div className="flex items-center justify-between gap-4">
<Link
href="/changelog"
onClick={close}
className="text-sm font-medium text-foreground underline underline-offset-4 decoration-primary decoration-2 hover:decoration-foreground"
>
{t('showAll')}
</Link>
<button
type="button"
onClick={close}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t('confirm')}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,130 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { StrictMode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReleaseNotice } from '@/lib/release-notes';
/**
* release-notice-host.test — laedt die Nachricht einmal je Seitenladung im
* Portal-Rahmen und merkt erst beim Schliessen (quick-260925-bow, D-05/D-08).
*
* Server-Aktionen gemockt; das Fenster selbst ist durch eine schlanke
* Attrappe ersetzt (eigene Tests in release-notice-dialog.test.tsx).
* `usePathname` per veraenderbarer Variable (Muster: sidebar.test.tsx).
*/
const { fetchReleaseNotice, markReleaseSeenAction } = vi.hoisted(() => ({
fetchReleaseNotice: vi.fn(),
markReleaseSeenAction: vi.fn(),
}));
vi.mock('@/lib/release-notice-actions', () => ({
fetchReleaseNotice,
markReleaseSeenAction,
}));
let mockPathname = '/';
vi.mock('next/navigation', () => ({
usePathname: () => mockPathname,
}));
vi.mock('./release-notice-dialog', () => ({
ReleaseNoticeDialog: ({ notice, onClose }: { notice: ReleaseNotice; onClose: () => void }) => (
<div role="dialog" aria-label={`Neu in Version ${notice.currentRelease}`}>
<button type="button" onClick={onClose}>
Verstanden
</button>
</div>
),
}));
import { ReleaseNoticeHost } from './release-notice-host';
const NOTICE: ReleaseNotice = {
currentRelease: '1.4.0',
versions: [{ version: '1.4.0', sections: [{ kind: 'new', markdown: '- N' }] }],
omittedCount: 0,
};
beforeEach(() => {
mockPathname = '/';
markReleaseSeenAction.mockResolvedValue({ success: true });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ReleaseNoticeHost (quick-260925-bow)', () => {
it('ohne Nachricht rendert er nichts', async () => {
fetchReleaseNotice.mockResolvedValue(null);
const { container } = render(<ReleaseNoticeHost />);
await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
await act(async () => {});
expect(container).toBeEmptyDOMElement();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('mit Nachricht erscheint das Fenster; beim Oeffnen wird NICHT gemerkt', async () => {
fetchReleaseNotice.mockResolvedValue(NOTICE);
render(<ReleaseNoticeHost />);
expect(await screen.findByRole('dialog', { name: 'Neu in Version 1.4.0' })).toBeInTheDocument();
expect(markReleaseSeenAction).not.toHaveBeenCalled();
});
it('nach dem Schliessen ist das Fenster weg und die Version genau einmal gemerkt', async () => {
fetchReleaseNotice.mockResolvedValue(NOTICE);
render(<ReleaseNoticeHost />);
fireEvent.click(await screen.findByRole('button', { name: 'Verstanden' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(markReleaseSeenAction).toHaveBeenCalledTimes(1);
expect(markReleaseSeenAction).toHaveBeenCalledWith('1.4.0');
});
it('schlaegt das Merken fehl, bleibt das Fenster trotzdem zu (erscheint beim naechsten Laden erneut)', async () => {
fetchReleaseNotice.mockResolvedValue(NOTICE);
markReleaseSeenAction.mockRejectedValue(new Error('down'));
render(<ReleaseNoticeHost />);
fireEvent.click(await screen.findByRole('button', { name: 'Verstanden' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(markReleaseSeenAction).toHaveBeenCalledTimes(1);
});
it('auf /change-password wird nicht abgefragt, nach dem Wechsel auf / genau einmal', async () => {
fetchReleaseNotice.mockResolvedValue(null);
mockPathname = '/change-password';
const { rerender } = render(<ReleaseNoticeHost />);
await act(async () => {});
expect(fetchReleaseNotice).not.toHaveBeenCalled();
mockPathname = '/';
rerender(<ReleaseNoticeHost />);
await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
mockPathname = '/dashboard';
rerender(<ReleaseNoticeHost />);
await act(async () => {});
expect(fetchReleaseNotice).toHaveBeenCalledTimes(1);
});
it('StrictMode-Doppeleffekt fuehrt nicht zu zwei Abrufen', async () => {
fetchReleaseNotice.mockResolvedValue(NOTICE);
render(
<StrictMode>
<ReleaseNoticeHost />
</StrictMode>,
);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(fetchReleaseNotice).toHaveBeenCalledTimes(1);
});
it('ein Fehler beim Abruf bleibt still: kein Fenster', async () => {
fetchReleaseNotice.mockRejectedValue(new Error('down'));
const { container } = render(<ReleaseNoticeHost />);
await waitFor(() => expect(fetchReleaseNotice).toHaveBeenCalledTimes(1));
await act(async () => {});
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,69 @@
'use client';
import { usePathname } from 'next/navigation';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import type { ReleaseNotice } from '@/lib/release-notes';
import { fetchReleaseNotice, markReleaseSeenAction } from '@/lib/release-notice-actions';
/**
* Laedt das "Was ist neu"-Fenster einmal je Seitenladung im Portal-Rahmen
* (quick-260925-bow, D-03/D-05/D-08).
*
* Eingebunden nur in `AppShell` — die Anmeldeseite liegt im `(auth)`-Layout
* ohne AppShell, der Erststart-Dialog der Desktop-App ist die lokale
* `apps/desktop/src/setup.html` vor dem Portal. Auf `/change-password`
* (liegt im Portal-Rahmen, Pflicht-Passwortwechsel) wird nicht abgefragt;
* erst die naechste Seite danach fragt. Eine Ref-Sperre verhindert einen
* zweiten Abruf (StrictMode-Doppeleffekt, Seitenwechsel ohne Neuladen).
*
* Gemerkt wird erst beim Schliessen, nie beim Oeffnen: zuerst verschwindet
* das Fenster, dann geht `markReleaseSeenAction` an die API. Schlaegt das
* Merken fehl, erscheint das Fenster beim naechsten Laden erneut — gewollt,
* statt die Nachricht stumm zu verlieren.
*
* Das Fenster (mit dem Markdown-Renderer) wird per `React.lazy` nur geladen,
* wenn wirklich eine Nachricht da ist.
*/
const ReleaseNoticeDialog = lazy(() =>
import('./release-notice-dialog').then((mod) => ({ default: mod.ReleaseNoticeDialog })),
);
export function ReleaseNoticeHost() {
const pathname = usePathname();
const [notice, setNotice] = useState<ReleaseNotice | null>(null);
const requestedRef = useRef(false);
useEffect(() => {
if (requestedRef.current) return;
if (pathname?.startsWith('/change-password')) return;
requestedRef.current = true;
// Bewusst ohne Abbruch-Flag im Aufraeumen: der StrictMode-Doppeleffekt
// raeumt nach dem ersten Lauf auf, der zweite Lauf fragt wegen der Sperre
// nicht erneut — ein Flag wuerde das einzige Ergebnis verwerfen.
fetchReleaseNotice()
.then((result) => {
if (result) setNotice(result);
})
.catch(() => {
// Still: ohne Nachricht kein Fenster.
});
}, [pathname]);
if (!notice) return null;
const handleClose = () => {
const version = notice.currentRelease;
setNotice(null);
void markReleaseSeenAction(version).catch(() => {
// Still: das Fenster erscheint beim naechsten Laden erneut.
});
};
return (
<Suspense fallback={null}>
<ReleaseNoticeDialog notice={notice} onClose={handleClose} />
</Suspense>
);
}
+5 -3
View File
@@ -5,9 +5,11 @@ import type { AppChannel } from './app-version';
*
* Quelle ist CHANGELOG.md im Wurzelverzeichnis; `apps/web/next.config.ts` liest
* die Datei zur Bauzeit und legt den Text als `env.TESSERA_CHANGELOG_MD` ab.
* Dieses Modul darf NUR von der Server-Seite (`app/(portal)/changelog/page.tsx`)
* importiert werden — sonst landet der Text in oeffentlich abrufbaren
* Client-Chunks unter /_next/static.
* Dieses Modul darf NUR von Server-Code importiert werden — der Server-Seite
* (`app/(portal)/changelog/page.tsx`) und der `'use server'`-Datei
* `release-notice-actions.ts` ("Was ist neu"-Fenster, quick-260925-bow), nie
* von Client-Komponenten und auch nicht von `release-notes.ts` — sonst landet
* der Text in oeffentlich abrufbaren Client-Chunks unter /_next/static.
*
* Kanalregel (`filterChangelogForChannel`): auf `live` fehlt der Abschnitt
* "Unveröffentlicht" vollstaendig; auf `beta` und `dev` bleibt er und traegt
+228
View File
@@ -0,0 +1,228 @@
import { describe, expect, it } from 'vitest';
import {
parseChangelogReleases,
RELEASE_NOTICE_MAX_VERSIONS,
selectReleaseNotice,
} from './release-notes';
/**
* release-notes.test — Auswahl der Versionsabschnitte fuer das
* "Was ist neu"-Fenster (quick-260925-bow, D-03/D-04).
*
* Eigene Markdown-Fixtures statt der echten CHANGELOG.md, damit die Tests
* nicht bei jedem neuen Eintrag brechen.
*/
const SAMPLE = [
'# Änderungen an Tessera',
'',
'Vorspann.',
'',
'## Unveröffentlicht',
'',
'### Neu',
'',
'- Punkt U (noch nicht freigegeben)',
'',
'## 1.4.0 – 2026-09-25',
'',
'### Behoben',
'',
'- Fehler F140',
'',
'### Neu',
'',
'- Funktion N140',
' mit Fortsetzungszeile',
'',
'### Entfernt',
'',
'- Entfernt E140',
'',
'### Geändert',
'',
'- Aenderung G140',
'',
'## 1.3.1 – 2026-09-23',
'',
'### Geändert',
'',
'- Aenderung G131',
'',
'### Neu',
'',
'',
'## 1.3.0 – 2026-09-22',
'',
'### Neu',
'',
'- Funktion N130',
'',
'## 1.2.0 – 2026-09-17',
'',
'### Entfernt',
'',
'- Nur Entferntes E120',
'',
'## 1.1.0 – 2026-09-16',
'',
'### Behoben',
'',
'- Fehler F110',
'',
'## 1.0.0 – 2026-09-15',
'',
'### Neu',
'',
'- Funktion N100',
'',
].join('\n');
function versionsOf(notice: ReturnType<typeof selectReleaseNotice>) {
return notice?.versions.map((v) => v.version);
}
describe('parseChangelogReleases (quick-260925-bow)', () => {
it('liefert nur freigegebene Versionen mit mindestens einer Gruppe Neu/Geändert/Behoben', () => {
const releases = parseChangelogReleases(SAMPLE);
expect(releases.map((r) => r.version)).toEqual(['1.4.0', '1.3.1', '1.3.0', '1.1.0', '1.0.0']);
});
it('Gruppen stehen in fester Reihenfolge new, changed, fixed, unabhaengig von der Dateireihenfolge; Entfernt fehlt', () => {
const v140 = parseChangelogReleases(SAMPLE).find((r) => r.version === '1.4.0');
expect(v140?.sections.map((s) => s.kind)).toEqual(['new', 'changed', 'fixed']);
expect(v140?.sections[0].markdown).toBe('- Funktion N140\n mit Fortsetzungszeile');
expect(v140?.sections[1].markdown).toBe('- Aenderung G140');
expect(v140?.sections[2].markdown).toBe('- Fehler F140');
expect(JSON.stringify(v140)).not.toContain('E140');
});
it('leere Gruppen fallen weg', () => {
const v131 = parseChangelogReleases(SAMPLE).find((r) => r.version === '1.3.1');
expect(v131?.sections.map((s) => s.kind)).toEqual(['changed']);
});
it('„Unveröffentlicht“ wird nie ausgewählt', () => {
expect(JSON.stringify(parseChangelogReleases(SAMPLE))).not.toContain('Punkt U');
});
it('CRLF wird normalisiert', () => {
const releases = parseChangelogReleases(SAMPLE.replace(/\n/g, '\r\n'));
expect(releases.map((r) => r.version)).toEqual(['1.4.0', '1.3.1', '1.3.0', '1.1.0', '1.0.0']);
expect(releases[0].sections[0].markdown).toBe('- Funktion N140\n mit Fortsetzungszeile');
});
it('leere Eingabe ergibt eine leere Liste', () => {
expect(parseChangelogReleases('')).toEqual([]);
});
});
describe('selectReleaseNotice (quick-260925-bow)', () => {
it('Deckel ist 3 Versionen', () => {
expect(RELEASE_NOTICE_MAX_VERSIONS).toBe(3);
});
it('current null oder unparsebar → null', () => {
expect(selectReleaseNotice(SAMPLE, null, '1.0.0')).toBeNull();
expect(selectReleaseNotice(SAMPLE, 'dev', '1.0.0')).toBeNull();
expect(selectReleaseNotice(SAMPLE, 'abc1234', null)).toBeNull();
});
it('lastSeen null → nur der Abschnitt der laufenden Version', () => {
const notice = selectReleaseNotice(SAMPLE, '1.4.0', null);
expect(notice?.currentRelease).toBe('1.4.0');
expect(versionsOf(notice)).toEqual(['1.4.0']);
expect(notice?.omittedCount).toBe(0);
});
it('lastSeen gleich oder ueber current → null', () => {
expect(selectReleaseNotice(SAMPLE, '1.4.0', '1.4.0')).toBeNull();
expect(selectReleaseNotice(SAMPLE, '1.3.1', '1.4.0')).toBeNull();
});
it('lastSeen 1.3.0, current 1.4.0 → 1.4.0 und 1.3.1, neueste zuerst, omittedCount 0', () => {
const notice = selectReleaseNotice(SAMPLE, '1.4.0', '1.3.0');
expect(versionsOf(notice)).toEqual(['1.4.0', '1.3.1']);
expect(notice?.omittedCount).toBe(0);
});
it('lastSeen 1.0.0 bei vier passenden Versionen darueber → die drei neuesten, omittedCount 1', () => {
const notice = selectReleaseNotice(SAMPLE, '1.4.0', '1.0.0');
expect(versionsOf(notice)).toEqual(['1.4.0', '1.3.1', '1.3.0']);
expect(notice?.omittedCount).toBe(1);
});
it('fuenf Versionen ueber lastSeen → drei gezeigt, omittedCount 2', () => {
const five = [
'## 2.4.0',
'### Neu',
'- a',
'## 2.3.0',
'### Neu',
'- b',
'## 2.2.0',
'### Neu',
'- c',
'## 2.1.0',
'### Neu',
'- d',
'## 2.0.1',
'### Behoben',
'- e',
'## 2.0.0',
'### Neu',
'- f',
].join('\n');
const notice = selectReleaseNotice(five, '2.4.0', '2.0.0');
expect(versionsOf(notice)).toEqual(['2.4.0', '2.3.0', '2.2.0']);
expect(notice?.omittedCount).toBe(2);
});
it('lastSeen unparsebar → wie null', () => {
const notice = selectReleaseNotice(SAMPLE, '1.4.0', 'kaputt');
expect(versionsOf(notice)).toEqual(['1.4.0']);
});
it('Versionen ueber current (Web neuer als API) werden ausgelassen', () => {
const notice = selectReleaseNotice(SAMPLE, '1.3.1', '1.1.0');
expect(versionsOf(notice)).toEqual(['1.3.1', '1.3.0']);
expect(notice?.currentRelease).toBe('1.3.1');
});
it('laufende Version ohne Abschnitt in der Liste → null', () => {
expect(selectReleaseNotice(SAMPLE, '1.5.0', '1.3.0')).toBeNull();
expect(selectReleaseNotice(SAMPLE, '1.5.0', null)).toBeNull();
});
it('eine laufende Version nur mit „Entfernt“ zaehlt als ohne Abschnitt → null', () => {
expect(selectReleaseNotice(SAMPLE, '1.2.0', '1.1.0')).toBeNull();
});
it('Versionen nur mit „Entfernt“ fallen im Bereich ganz weg', () => {
const notice = selectReleaseNotice(SAMPLE, '1.3.0', '1.0.0');
expect(versionsOf(notice)).toEqual(['1.3.0', '1.1.0']);
expect(notice?.omittedCount).toBe(0);
});
it('sortiert numerisch absteigend statt der Dateireihenfolge zu vertrauen', () => {
const shuffled = [
'## 1.9.0',
'### Neu',
'- neun',
'## 1.10.0',
'### Neu',
'- zehn',
'## 1.8.0',
'### Neu',
'- acht',
].join('\n');
const notice = selectReleaseNotice(shuffled, '1.10.0', '1.7.0');
expect(versionsOf(notice)).toEqual(['1.10.0', '1.9.0', '1.8.0']);
});
it('current mit v-Praefix oder Describe-Anhang wird kanonisch ausgewertet', () => {
const notice = selectReleaseNotice(SAMPLE, 'v1.4.0-3-gabc1234', null);
expect(notice?.currentRelease).toBe('1.4.0');
expect(versionsOf(notice)).toEqual(['1.4.0']);
});
});
+159
View File
@@ -0,0 +1,159 @@
import { compareReleaseVersions, parseReleaseVersion } from '@tessera/shared';
/**
* Auswahl der Versionsabschnitte fuer das "Was ist neu"-Fenster
* (quick-260925-bow, D-03/D-04).
*
* Reine Funktionen ueber dem Markdown-Text von CHANGELOG.md. Diese Datei
* importiert bewusst NICHT `@/lib/changelog` und keine React-/Next-Module:
* ihre Typen werden auch von Client-Komponenten (Fenster, Host) benutzt, und
* der Text der Aenderungsliste darf nicht in oeffentliche Client-Chunks
* gelangen. Den Text reicht die Server-Aktion `release-notice-actions.ts`
* herein.
*
* Regeln:
* - Nur `## `-Abschnitte, deren erstes Wort eine freigegebene Version ist
* (`parseReleaseVersion`); "Unveröffentlicht" und alles Unparsebare fallen weg.
* - Darin nur die Gruppen "Neu" (new), "Geändert" (changed, im Fenster
* "Verbessert") und "Behoben" (fixed), in dieser festen Reihenfolge; andere
* Gruppen ("Entfernt" ...) und Gruppen ohne Listenpunkt fallen weg, eine
* Version ohne verbleibende Gruppe ebenso.
* - Gezeigt wird jede Version mit gemerkt < Version ≤ laufend, neueste zuerst,
* hoechstens `RELEASE_NOTICE_MAX_VERSIONS`; der Rest zaehlt in
* `omittedCount`. Ohne (gueltigen) gemerkten Stand nur die laufende Version.
* - Fehlt der Abschnitt der laufenden Version ganz (Web-Abbild passt nicht
* zur API), gibt es kein Fenster.
*/
export type ReleaseSectionKind = 'new' | 'changed' | 'fixed';
export interface ReleaseNotesSection {
kind: ReleaseSectionKind;
/** Nur die Zeilen unter der Gruppenueberschrift, Leerzeilen am Rand entfernt. */
markdown: string;
}
export interface ReleaseNotesVersion {
version: string;
sections: ReleaseNotesSection[];
}
export interface ReleaseNotice {
currentRelease: string;
versions: ReleaseNotesVersion[];
omittedCount: number;
}
export const RELEASE_NOTICE_MAX_VERSIONS = 3;
const VERSION_HEADING_RE = /^## /;
const GROUP_HEADING_RE = /^### /;
const LIST_ITEM_RE = /^\s*[-*] /;
const GROUP_KIND: Record<string, ReleaseSectionKind> = {
Neu: 'new',
Geändert: 'changed',
Behoben: 'fixed',
};
const KIND_ORDER: ReleaseSectionKind[] = ['new', 'changed', 'fixed'];
function trimBlankLines(lines: string[]): string[] {
let start = 0;
let end = lines.length;
while (start < end && lines[start].trim() === '') start++;
while (end > start && lines[end - 1].trim() === '') end--;
return lines.slice(start, end);
}
/** Alle freigegebenen Versionen der Liste, auch solche ohne gezeigte Gruppe. */
function parseAllReleases(markdown: string): ReleaseNotesVersion[] {
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
const releases: ReleaseNotesVersion[] = [];
let version: string | null = null;
let groups = new Map<ReleaseSectionKind, string[]>();
let currentGroup: string[] | null = null;
const flush = () => {
if (version === null) return;
const sections: ReleaseNotesSection[] = [];
for (const kind of KIND_ORDER) {
const body = groups.get(kind);
if (!body?.some((line) => LIST_ITEM_RE.test(line))) continue;
sections.push({ kind, markdown: trimBlankLines(body).join('\n') });
}
releases.push({ version, sections });
};
for (const line of lines) {
if (VERSION_HEADING_RE.test(line)) {
flush();
const firstWord = line.slice(3).trim().split(/\s+/)[0] ?? '';
version = parseReleaseVersion(firstWord);
groups = new Map();
currentGroup = null;
continue;
}
if (version === null) continue;
if (GROUP_HEADING_RE.test(line)) {
const kind = GROUP_KIND[line.slice(4).trim()];
if (kind && !groups.has(kind)) {
currentGroup = [];
groups.set(kind, currentGroup);
} else {
currentGroup = null;
}
continue;
}
currentGroup?.push(line);
}
flush();
return releases;
}
/**
* Freigegebene Versionen mit mindestens einer Gruppe Neu/Geändert/Behoben,
* in Dateireihenfolge.
*/
export function parseChangelogReleases(markdown: string): ReleaseNotesVersion[] {
return parseAllReleases(markdown).filter((release) => release.sections.length > 0);
}
/**
* Inhalt des Fensters oder `null` (kein Fenster). `currentRelease` kommt aus
* `GET /users/me/release-notice` (API, `getRunningRelease()`),
* `lastSeen` ist der gemerkte Stand des Benutzers.
*/
export function selectReleaseNotice(
markdown: string,
currentRelease: string | null,
lastSeen: string | null,
): ReleaseNotice | null {
const current = currentRelease === null ? null : parseReleaseVersion(currentRelease);
if (current === null) return null;
const seen = lastSeen === null ? null : parseReleaseVersion(lastSeen);
if (seen !== null && compareReleaseVersions(seen, current) >= 0) return null;
const all = parseAllReleases(markdown);
if (!all.some((release) => release.version === current)) return null;
const inRange = all
.filter((release) => release.sections.length > 0)
.filter((release) => {
if (compareReleaseVersions(release.version, current) > 0) return false;
if (seen === null) return release.version === current;
return compareReleaseVersions(release.version, seen) > 0;
})
.sort((a, b) => compareReleaseVersions(b.version, a.version));
if (inRange.length === 0) return null;
return {
currentRelease: current,
versions: inRange.slice(0, RELEASE_NOTICE_MAX_VERSIONS),
omittedCount: Math.max(0, inRange.length - RELEASE_NOTICE_MAX_VERSIONS),
};
}
@@ -0,0 +1,185 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* release-notice-actions.test — Server-Aktionen des "Was ist neu"-Fensters
* (quick-260925-bow).
*
* Muster wie auth-actions.test.ts: next/headers gemockt, fetch per
* vi.stubGlobal. `@/lib/changelog` wird mit eigenem Markdown gemockt, damit
* die Tests nicht von der echten CHANGELOG.md abhaengen.
*/
const { cookieGet } = vi.hoisted(() => ({ cookieGet: vi.fn() }));
vi.mock('next/headers', () => ({
cookies: () => Promise.resolve({ get: cookieGet, set: vi.fn(), delete: vi.fn() }),
}));
vi.mock('@/lib/changelog', () => ({
changelogMarkdown: [
'# Änderungen',
'',
'## Unveröffentlicht',
'',
'### Neu',
'',
'- Beta-Punkt',
'',
'## 2.1.0 – 2026-09-25',
'',
'### Neu',
'',
'- Funktion 210',
'',
'## 2.0.0 – 2026-09-01',
'',
'### Behoben',
'',
'- Fehler 200',
'',
].join('\n'),
}));
function okJson(body: unknown) {
return vi.fn(() => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) }));
}
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe('fetchReleaseNotice (quick-260925-bow)', () => {
it('ohne Cookie → null und kein fetch', async () => {
cookieGet.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { fetchReleaseNotice } = await import('./release-notice-actions');
expect(await fetchReleaseNotice()).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('API 200 → Auswahl aus der Aenderungsliste, Cookie weitergereicht, no-store', async () => {
cookieGet.mockReturnValue({ value: 'session-abc' });
const fetchMock = okJson({ currentRelease: '2.1.0', lastSeenReleaseVersion: '1.9.0' });
vi.stubGlobal('fetch', fetchMock);
const { fetchReleaseNotice } = await import('./release-notice-actions');
const notice = await fetchReleaseNotice();
expect(notice?.currentRelease).toBe('2.1.0');
expect(notice?.versions.map((v) => v.version)).toEqual(['2.1.0', '2.0.0']);
expect(notice?.omittedCount).toBe(0);
expect(JSON.stringify(notice)).not.toContain('Beta-Punkt');
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toMatch(/\/users\/me\/release-notice$/);
expect((init.headers as Record<string, string>).Cookie).toBe('session=session-abc');
expect(init.cache).toBe('no-store');
});
it('gemerkter Stand gleich der laufenden Version → null', async () => {
cookieGet.mockReturnValue({ value: 's' });
vi.stubGlobal('fetch', okJson({ currentRelease: '2.1.0', lastSeenReleaseVersion: '2.1.0' }));
const { fetchReleaseNotice } = await import('./release-notice-actions');
expect(await fetchReleaseNotice()).toBeNull();
});
it('dev-Stand (currentRelease null) → null', async () => {
cookieGet.mockReturnValue({ value: 's' });
vi.stubGlobal('fetch', okJson({ currentRelease: null, lastSeenReleaseVersion: null }));
const { fetchReleaseNotice } = await import('./release-notice-actions');
expect(await fetchReleaseNotice()).toBeNull();
});
it.each([
[null],
['text'],
[{}],
[{ currentRelease: 210, lastSeenReleaseVersion: null }],
[{ currentRelease: '2.1.0', lastSeenReleaseVersion: 5 }],
[{ currentRelease: '2.1.0' }],
])('unerwartete Antwortform %j → null', async (body) => {
cookieGet.mockReturnValue({ value: 's' });
vi.stubGlobal('fetch', okJson(body));
const { fetchReleaseNotice } = await import('./release-notice-actions');
expect(await fetchReleaseNotice()).toBeNull();
});
it('API nicht ok → null', async () => {
cookieGet.mockReturnValue({ value: 's' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) })),
);
const { fetchReleaseNotice } = await import('./release-notice-actions');
expect(await fetchReleaseNotice()).toBeNull();
});
it('Netzfehler → null', async () => {
cookieGet.mockReturnValue({ value: 's' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('ECONNREFUSED'))),
);
const { fetchReleaseNotice } = await import('./release-notice-actions');
expect(await fetchReleaseNotice()).toBeNull();
});
});
describe('markReleaseSeenAction (quick-260925-bow)', () => {
it('schickt POST mit JSON, Cookie und { version }', async () => {
cookieGet.mockReturnValue({ value: 'session-abc' });
const fetchMock = okJson({ success: true, lastSeenReleaseVersion: '2.1.0' });
vi.stubGlobal('fetch', fetchMock);
const { markReleaseSeenAction } = await import('./release-notice-actions');
expect(await markReleaseSeenAction('2.1.0')).toEqual({ success: true });
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toMatch(/\/users\/me\/release-seen$/);
expect(init.method).toBe('POST');
const headers = init.headers as Record<string, string>;
expect(headers['Content-Type']).toBe('application/json');
expect(headers.Cookie).toBe('session=session-abc');
expect(JSON.parse(init.body as string)).toEqual({ version: '2.1.0' });
});
it('API nicht ok → { success: false }', async () => {
cookieGet.mockReturnValue({ value: 's' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: false, status: 400 })),
);
const { markReleaseSeenAction } = await import('./release-notice-actions');
expect(await markReleaseSeenAction('2.1.0')).toEqual({ success: false });
});
it('Netzfehler → { success: false }', async () => {
cookieGet.mockReturnValue({ value: 's' });
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('down'))),
);
const { markReleaseSeenAction } = await import('./release-notice-actions');
expect(await markReleaseSeenAction('2.1.0')).toEqual({ success: false });
});
it('ohne Cookie → { success: false } und kein fetch', async () => {
cookieGet.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { markReleaseSeenAction } = await import('./release-notice-actions');
expect(await markReleaseSeenAction('2.1.0')).toEqual({ success: false });
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,72 @@
'use server';
import { cookies } from 'next/headers';
import { changelogMarkdown } from '@/lib/changelog';
import { type ReleaseNotice, selectReleaseNotice } from '@/lib/release-notes';
/**
* Server-Aktionen des "Was ist neu"-Fensters (quick-260925-bow).
*
* Eigene `'use server'`-Datei, damit `auth-actions.ts` die Aenderungsliste
* nicht importiert. Server-Code darf `@/lib/changelog` importieren; Client-
* Komponenten bekommen von hier nur eine Aktions-Referenz, der Text der
* Aenderungsliste bleibt im Server-Bundle (Regel aus quick-260916-dcz).
*
* Die laufende Version kommt aus der API (`currentRelease`, einzige Quelle
* `APP_VERSION` der API), nicht aus `NEXT_PUBLIC_APP_VERSION` des Webs.
* Jeder Fehler endet still: kein Fenster bzw. `{ success: false }`.
*/
const API_URL =
process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
function isStringOrNull(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
async function sessionCookie(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get('session')?.value ?? null;
}
export async function fetchReleaseNotice(): Promise<ReleaseNotice | null> {
try {
const session = await sessionCookie();
if (!session) return null;
const response = await fetch(`${API_URL}/users/me/release-notice`, {
headers: { Cookie: `session=${session}` },
cache: 'no-store',
});
if (!response.ok) return null;
const body: unknown = await response.json();
if (!body || typeof body !== 'object') return null;
const { currentRelease, lastSeenReleaseVersion } = body as Record<string, unknown>;
if (!isStringOrNull(currentRelease) || !isStringOrNull(lastSeenReleaseVersion)) return null;
return selectReleaseNotice(changelogMarkdown, currentRelease, lastSeenReleaseVersion);
} catch {
return null;
}
}
export async function markReleaseSeenAction(version: string): Promise<{ success: boolean }> {
try {
const session = await sessionCookie();
if (!session) return { success: false };
const response = await fetch(`${API_URL}/users/me/release-seen`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `session=${session}`,
},
body: JSON.stringify({ version }),
cache: 'no-store',
});
return { success: response.ok };
} catch {
return { success: false };
}
}
+15
View File
@@ -1291,5 +1291,20 @@
"notificationSectionTitle": "Benachrichtigung",
"platformFeedsNote": "Diese Feeds werden von der Administration gepflegt und gelten für alle."
}
},
"releaseNotice": {
"title": "Neu in Version {version}",
"intro": "Tessera wurde aktualisiert. Das hat sich für Sie geändert:",
"versionHeading": "Version {version}",
"section": {
"new": "Neu",
"changed": "Verbessert",
"fixed": "Behoben"
},
"moreVersions": "Dazu kommen Änderungen aus {count, plural, one {# älteren Version} other {# älteren Versionen}}.",
"showAll": "Alle Änderungen ansehen",
"confirm": "Verstanden",
"close": "Fenster schließen",
"contentLabel": "Änderungen"
}
}
+15
View File
@@ -1291,5 +1291,20 @@
"notificationSectionTitle": "Notification",
"platformFeedsNote": "These feeds are maintained by the administration and apply to everyone."
}
},
"releaseNotice": {
"title": "New in version {version}",
"intro": "Tessera has been updated. Here is what changed for you:",
"versionHeading": "Version {version}",
"section": {
"new": "New",
"changed": "Improved",
"fixed": "Fixed"
},
"moreVersions": "There are also changes from {count, plural, one {# earlier version} other {# earlier versions}}.",
"showAll": "View all changes",
"confirm": "Got it",
"close": "Close window",
"contentLabel": "Changes"
}
}
@@ -192,4 +192,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
// quick-260924-h7x: Proxmox-Seite neu gestaltet — korrektes Deutsch mit „ss“
'Prozessor',
'Arbeitsspeicher',
// quick-260925-bow: „Was ist neu“-Fenster, Gruppe „Verbessert“ — korrektes Deutsch mit „ss“
'Verbessert',
];
+4
View File
@@ -272,6 +272,10 @@ Ein Klick auf die Versionsnummer ganz unten in der Seitenleiste öffnet die Seit
Auf dem Live-System sehen Sie nur freigegebene Versionen. Auf der Beta erscheint zusätzlich der Abschnitt **Noch nicht freigegeben (Beta)** mit einem gelben Hinweis: Diese Punkte sind in der Beta bereits enthalten, aber noch nicht als Version freigegeben.
**Fenster nach einem Versionswechsel:** Wenn Tessera auf eine neue Version aktualisiert wurde, erscheint bei Ihrer ersten Anmeldung danach einmal ein Fenster **Neu in Version …**. Es zeigt die für Sie wichtigen Änderungen, gegliedert in **Neu**, **Verbessert** und **Behoben**. Haben Sie mehrere Versionen verpasst, sehen Sie höchstens die drei neuesten; ein Satz im Fenster nennt dann, wie viele ältere Versionen es noch gibt. Unten führt der Link **Alle Änderungen ansehen** zur vollständigen Seite **Was ist neu**.
Sie schließen das Fenster mit **Verstanden**, mit dem Kreuz oben rechts, mit der Escape-Taste oder mit einem Klick neben das Fenster. Tessera merkt sich das für Ihr Konto: Das Fenster erscheint pro Version nur einmal – auch in der Desktop-App nicht noch einmal – und erst mit der nächsten Version wieder. Neu angelegte Konten sehen das Fenster erst ab der nächsten Version. Punkte, die auf der Beta unter **Noch nicht freigegeben (Beta)** stehen, kommen in diesem Fenster nicht vor; sie erscheinen dort erst, wenn die Version freigegeben ist.
## 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.
+27 -2
View File
@@ -645,13 +645,38 @@ keine Commit-Kürzel, keine unerklärten Fachbegriffe. Bei der Freigabe wird der
Betriebshandbuch Kapitel 9). Die Seite „Was ist neu“ (`apps/web/src/app/(portal)/changelog/page.tsx`)
liest den Text zur Bauzeit aus `env.TESSERA_CHANGELOG_MD`, das `apps/web/next.config.ts` aus der
Datei befüllt — deshalb steht `COPY CHANGELOG.md ./` im Web-Dockerfile und `!CHANGELOG.md` als
Ausnahme in `.dockerignore`. Nur `page.tsx` darf `@/lib/changelog` importieren, damit der Text im
Server-Bundle bleibt und nicht in öffentlich abrufbare Client-Chunks gelangt. Die Kanalregel (Live
Ausnahme in `.dockerignore`. Nur Server-Code darf `@/lib/changelog` importieren: `page.tsx` und die
`'use server'`-Datei `apps/web/src/lib/release-notice-actions.ts` — nie eine Client-Komponente, auch
nicht `apps/web/src/lib/release-notes.ts` (dessen Typen nutzen Client-Komponenten). So bleibt der
Text im Server-Bundle und gelangt nicht in öffentlich abrufbare Client-Chunks. Die Kanalregel (Live
ohne „Unveröffentlicht“, Beta/Entwicklung mit „Noch nicht freigegeben (Beta)“) liegt in
`filterChangelogForChannel` (`apps/web/src/lib/changelog.ts`) mit Tests. Beim Tag `vX.Y.Z`
schneidet `.gitea/scripts/publish-release.sh` den Abschnitt der Version heraus und legt daraus den
Gitea-Release an — fehlt der Abschnitt, bricht dieser CI-Schritt mit Exit 1 ab.
**„Was ist neu“-Fenster nach einem Versionswechsel (quick-260925-bow):** Beim ersten Laden des
Portal-Rahmens nach einem Versionswechsel zeigt `ReleaseNoticeHost` (in `AppShell`, also nie auf
der Anmeldeseite; nicht auf `/change-password`) einmal ein Fenster mit den Gruppen Neu / Geändert
(angezeigt als „Verbessert“) / Behoben der verpassten Versionen, höchstens drei, neueste zuerst.
Einzige Quelle der laufenden Version ist `APP_VERSION` der API, gelesen über `getRunningRelease()`
in `apps/api/src/health/app-version.ts` — nicht `NEXT_PUBLIC_APP_VERSION` des Webs. Grund: der
LDAP-Abgleich (Zeitplan) und der Erst-Administrator (API-Start) legen Benutzer ohne jede
Web-Anfrage an und tragen dabei die laufende Version ein; ebenso prüft der Merk-Endpunkt gegen
diesen Wert. Endpunkte: `GET /users/me/release-notice` liefert `currentRelease` und den gemerkten
Stand; `POST /users/me/release-seen` mit `{ version }` nimmt nur die kanonische Form `X.Y.Z` an,
die nicht über der laufenden Version liegt, senkt einen gemerkten Stand nie ab und schreibt
ausschließlich die eigene Zeile (`forTenant()`, `where: { id: currentUser.id }`). Gemerkt wird erst
beim Schließen, nie beim Öffnen. Spalte `User.lastSeenReleaseVersion`: `null` = Bestandsbenutzer,
dann zeigt das Fenster nur die laufende Version; `UserService.create()` und der Admin-Seed tragen
bei der Anlage die laufende Version ein. `parseReleaseVersion`/`compareReleaseVersions` stehen einmal
in `packages/shared/src/index.ts` (Laufzeit-Import in API und Web). Die Auswahl der Abschnitte
macht `selectReleaseNotice` in `apps/web/src/lib/release-notes.ts`. Folge für die Freigabe: erst ein
Tag `vX.Y.Z` (auf der Beta dessen Describe-Stand `vX.Y.Z-N-g<sha>`, gekürzt auf `X.Y.Z`) löst das
Fenster aus; Punkte unter „Unveröffentlicht“ erscheinen darin nie. Lokal steht `APP_VERSION` auf
`dev` — dann erscheint nie ein Fenster; zum Ausprobieren beim Bau `--build-arg APP_VERSION=1.4.0`
setzen. Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds, entsteht
kein Fenster und nichts wird gemerkt.
**i18n — Schlüsselparität zwischen de.json und en.json:** Jeder benutzersichtbare Text gehört in
beide Sprachdateien, `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json`. Ein
strukturelle Wächter-Test, `apps/web/src/messages/tenderRadar-parity.spec.ts`, prüft für den
@@ -166,7 +166,7 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| groups | 0 | 31 | 0 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) |
| ldap | 1 | 27 | 2 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer waren bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04). **260914-eym:** die beiden Leser in `ldap-config.service.ts` laufen über `forSystem()` (4→1 ungebunden, 2 System), die Schreibzeile der Nachverschlüsselung über `forTenant()` (26→27 gebunden); der eine verbleibende ungebundene Rohtreffer ist `resolveEmailForWrite` |
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| user | 8 | 17 | 0 | **quick-260925-bow:** +3 gebunden in `user.controller.ts`, „Was ist neu“-Fenster, `GET me/release-notice` (ein `findUnique`) und `POST me/release-seen` (`findUnique` + `update`), beide über `forTenant()` mit `where: { id: currentUser.id }`, nachgemessen mit der Gate-Schleife: 8/17/0. Vorher: **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
| dashboard | 1 | 29 | 0 | **quick-260924-m4n (Stufe 2 der Bilderrahmen-Umstellung):** nachgemessen mit der Gate-Schleife 1/29/0 — die Zeile nannte zuletzt 1/28/1, gemessen waren vor dieser Änderung aber schon 1/31/1: quick-260923-lrr hatte in `dashboard.service.ts` zwei gebundene `tenantPrisma.favoriteLink.`-Rohtreffer (Aufräumen hochgeladener Favoriten-Symbole) hinzugefügt, ohne diese Zeile nachzuziehen, und die ad9-Zählung lag um eins zu niedrig. Diese Änderung selbst: −2 gebunden und −1 System in `dashboard-images.service.ts` — der Bootstrap-Umzug ist entfernt (sein `systemPrisma.dashboardImage.findMany` und sein je Zeile gebundenes `update`), und der Upload legt die Zeile gleich MIT `storagePath` an (UUID vom Dienst), das nachträgliche `update` entfällt. Übrig in `dashboard-images.service.ts`: 7 gebundene Rohtreffer (`findMany`, `count`, `create`, `delete` beim Zurücknehmen, zweimal `findUnique`, `delete`). Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** 24→28 gebunden — Task 2 (Reiter anlegen/umbenennen/löschen/umsortieren) bringt vier weitere gebundene `tenantPrisma.dashboard.`-Rohtreffer in `dashboard.service.ts`: `createDashboard` (`findMany` der vorhandenen Namen, `create`), `renameDashboard` (`update`), `deleteDashboard` (die Zählung vor dem Löschen). Die Schreib-/Lese-Zugriffe INNERHALB der `withTenantTransaction` in `deleteDashboard`/`reorderDashboards` (`tx.dashboard.*`, `tx.widgetInstance.deleteMany`, `tx.dashboardLayout.deleteMany`) zählt diese einfache Rohtrefferzählung strukturell NICHT mit — dieselbe dokumentierte Lücke wie bei `groups.service.ts` (siehe Kopf dieses Abschnitts); sie sind trotzdem gebunden (jeder Aufruf von `withTenantTransaction(` zählt als gebunden) und stehen deshalb bereits als `gebunden` in den Paaren `dashboard`/`widgetInstance`/`dashboardLayout` unten. Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-ad9 (Task 1):** 21→24 gebunden — die neue Reitertabelle bringt drei gebundene `dashboard`-Rohtreffer in `dashboard.service.ts` (zwei `findMany` in `listDashboards`, ein `findUnique` im Riegel `assertOwnedDashboard`), nachgemessen mit der Gate-Schleife. Vorher: **260922-hk4:** 18→21 gebunden, 0→1 System — die Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Spalte `data`. Drei zusätzliche gebundene Rohtreffer in `dashboard-images.service.ts`: das Nachtragen von `storagePath` nach dem Upload (die UUID steht erst nach `create` fest), das Zurücknehmen der Zeile bei fehlgeschlagenem Schreiben, und das Nachtragen im Umzug beim Start. Der eine System-Rohtreffer ist die Lesehälfte dieses Umzugs (`onApplicationBootstrap`, Zeilen ohne `storagePath` über ALLE Mandanten, Muster DKV-Planer) — geschrieben wird auch dort je Zeile mandantengebunden. Nachgemessen mit der Gate-Schleife. Vorher: **260921-pi9:** 12→18 gebunden — `dashboard-images.service.ts` (Bilderrahmen) bringt sechs gebundene `dashboardImage`-Rohtreffer (`findMany`, `count`, `create`, zweimal `findUnique`, `delete`), nachgemessen mit der Gate-Schleife. Vorher: **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
@@ -176,7 +176,7 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **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 |
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
| **Summe** | **61** | **213** | **6** | **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **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** | **216** | **6** | **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **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 |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 77 Paare)
@@ -760,7 +760,7 @@ werden.
| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an und liest beim Start alle Mandanten fuer die Standardgruppen-Reparatur — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Fuenfter und bislang einziger bereits vollstaendig richtiger Fall der Hintergrunddienst-Falle (Befund K, siehe Abschnitt unten). 3c-Befund (260914-eym): einziger Lesezugriff außerhalb der Schleife, `Tenant` ohne Regel — kein Systemkontext nötig, Datei unverändert, Stand bleibt `ungebunden`. |
| apps/api/src/user/admin-seed.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `bewusst-uebergreifend` auf `beides`, weil die bisherige Begruendung ("es gibt strukturell keinen Mandanten zum Binden") nachweislich FALSCH war (Befund J) — der Mandant wird eine Anweisung vorher angelegt und ist bekannt. Die Erstanlage-Pruefung bleibt bewusst ungebunden (kein Mandant existiert zu diesem Zeitpunkt, `username` ist plattformweit eindeutig); die Erstanlage des Administrators selbst laeuft seit Aufgabe 2 ueber `forTenant()`, gebunden an den unmittelbar zuvor angelegten Mandanten. Eine P2002-Kollision beim Anlegen wird wie "Administrator existiert bereits" behandelt statt den Start abzubrechen (Befund I). |
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. |
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; seit quick-260925-bow ebenso die drei Zugriffe der zwei Selbstbedienungswege des „Was ist neu“-Fensters (`GET me/release-notice` liest, `POST me/release-seen` liest und schreibt `lastSeenReleaseVersion`), weiterhin `forTenant()` mit `where: { id: currentUser.id }` und ohne Kennungsparameter; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. |
| apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
+71
View File
@@ -93,6 +93,11 @@ export interface DesktopLatestResponse {
* das native Type-Stripping von Node 24. Deshalb darf diese Datei nur
* loeschbare Syntax enthalten — keine `enum`, kein `namespace`, keine
* Parameter-Eigenschaften.
*
* Der zweite Laufzeit-Import sind `parseReleaseVersion`/`compareReleaseVersions`
* weiter unten (quick-260925-bow) — dieselbe Regel gilt dort, und neue
* Laufzeit-Funktionen gehoeren direkt in diese Datei (CJS-`require` findet
* relative Importe ohne `.ts`-Endung nicht).
*/
export const WIDGET_TYPES = [
'clock',
@@ -123,3 +128,69 @@ export type WidgetType = (typeof WIDGET_TYPES)[number];
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {
proxmox: 'proxmox',
};
/**
* Freigegebene Versionen (quick-260925-bow, D-02/D-06) — EINE Implementierung
* fuer API und Web ("Was ist neu"-Fenster nach einem Versionswechsel).
*
* Laufzeit-Import aus `@tessera/shared` (siehe Warnkommentar ueber
* `WIDGET_TYPES`): nur loeschbare Syntax, keine relativen Importe.
*
* Angenommen werden genau: optionales `v`, drei Zifferngruppen zu je 1 bis 6
* Ziffern, optional der Describe-Anhang von `git describe`
* (`-<Anzahl>-g<4 bis 40 Hex-Zeichen>`). Alles andere ist KEINE freigegebene
* Version: `dev`, ein blosser Commit-Stempel, Vorabversionen wie `-rc.1`,
* `-dirty`, Leerzeichen am Rand (kein Trimmen). Eingaben ueber 64 Zeichen
* werden vor dem Muster abgewiesen; der Ausdruck ist verankert und hat keine
* verschachtelten Wiederholungen.
*/
const RELEASE_VERSION_PATTERN = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-\d{1,6}-g[0-9a-f]{4,40})?$/;
const RELEASE_VERSION_MAX_LENGTH = 64;
/**
* Liefert die kanonische Form `X.Y.Z` (ohne `v`, ohne fuehrende Nullen) oder
* `null`, wenn die Eingabe keine freigegebene Version ist.
* Beispiel: `v10.2.3-5-gabc1234` → `10.2.3`, `dev` → `null`.
*/
export function parseReleaseVersion(raw: string): string | null {
if (typeof raw !== 'string' || raw.length > RELEASE_VERSION_MAX_LENGTH) {
return null;
}
const match = RELEASE_VERSION_PATTERN.exec(raw);
if (!match) {
return null;
}
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`;
}
/**
* Numerischer Vergleich zweier freigegebener Versionen: -1, 0 oder 1.
* `1.10.0` liegt ueber `1.9.0`. Wirft, wenn eine Seite nicht parsebar ist —
* Aufrufer pruefen vorher mit `parseReleaseVersion`.
*/
export function compareReleaseVersions(a: string, b: string): number {
const left = parseReleaseVersion(a);
const right = parseReleaseVersion(b);
if (left === null || right === null) {
throw new Error(`Keine freigegebene Version: ${left === null ? a : b}`);
}
const l = left.split('.').map(Number);
const r = right.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (l[i] > r[i]) return 1;
if (l[i] < r[i]) return -1;
}
return 0;
}
/**
* Antwort von `GET /users/me/release-notice` (quick-260925-bow).
* `currentRelease` ist die laufende freigegebene Version der API
* (`getRunningRelease()`, `null` auf `dev`-Staenden), `lastSeenReleaseVersion`
* der gemerkte Stand des angemeldeten Benutzers (`null` = Bestandsbenutzer).
*/
export interface ReleaseNoticeResponse {
currentRelease: string | null;
lastSeenReleaseVersion: string | null;
}