50 KiB
phase, plan, quick_id, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
| phase | plan | quick_id | type | wave | depends_on | autonomous | requirements | files_modified | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-260925-bow | 01 | 260925-bow | execute | 1 | true |
|
|
|
|
Quick 260925-bow — „Was ist neu“-Fenster beim ersten Anmelden nach einem Versionswechsel
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.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
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
vund 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-seenmit 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.mdnachziehen. - 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 ausTESSERA_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:ChangelogViewrendert Markdown mitMDEditor.Markdown+rehype-sanitize, Farbmodus nach Mount. Wird wiederverwendet (D-07).apps/web/src/components/layout/app-shell.tsx: Portal-Rahmen (nur im(portal)-Layout;/loginliegt in(auth)ohne AppShell). Anmeldung navigiert perwindow.location.href→ AppShell wird frisch gemountet.apps/web/src/lib/auth-actions.ts+auth-actions.test.ts: Muster für Server-Aktionen (Cookiesession→Cookie: session=…anAPI_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001') und deren Tests (next/headersgemockt,fetchpervi.stubGlobal).apps/web/src/middleware.tsleitet beimustChangePasswordauf/change-password(liegt IM Portal-Rahmen).apps/api/src/health/app-version.ts:getAppVersion()liestprocess.env.APP_VERSION || 'dev'zur Laufzeit (Tests:vi.stubEnv, Vorbildhealth.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 (keineenum, keinnamespace, keine Parameter-Eigenschaften), keine relativen Importe in neue Dateien (CJS-requirefindet keine.ts-Endung) — deshalb alles direkt inindex.ts. Das Web importiert bereits Laufzeitwerte daraus (widget-registry.tsx).apps/api/src/user/user.controller.ts:@Controller('users')+@UseGuards(RolesGuard); Selbstbedienungswege ohne@Roles(VorbildPATCH me/accent-color:forTenant(this.prisma, currentUser.tenantId)→tenantPrisma.user.update({ where: { id: currentUser.id } … })). GlobaleValidationPipe({ whitelist: true, transform: true })→ Body braucht eine DTO-Klasse mit class-validator-Dekoratoren, sonst werden Felder entfernt. Route-Reihenfolge: neue statischeme/…-Routen VOR@Get(':id')einfügen (Projektregel gegen 404-Shadowing).apps/api/src/user/user.controller.spec.ts: Zwei-Klienten-Attrappe (forTenantgemockt →prisma.__makeBoundClient(tenantId),scopedFindUnique/scopedUpdatefiltern nach Mandant,boundCallLog).- Benutzer-Anlagewege (per grep
user\.createvollständig ermittelt):UserService.create()inapps/api/src/user/user.service.ts(einziger Erzeugungspunkt für Admin-AnlagePOST /usersUND beide LDAP-WegeLdapService.upsertMappedUser/importUsersByDn) undAdminSeedServiceinapps/api/src/user/admin-seed.service.ts(Erst-Administrator).apps/api/scripts/rls-scratch-check.mjslegt 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-Funktionenauth_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.tsprüft Paare (Datei, Modell); das Paaruser.controller.ts | user | gebundenexistiert. Die Übersichtszeile| user | 8 | 14 | 0 |und die Summenzeile| **Summe** | **61** | **213** | **6** |indocs/mandantentrennung-zugriffsklassifikation.mdwerden 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.
-
Versionen (D-02, D-06) in
packages/shared/src/index.tsdirekt (keine neue Datei, nur löschbare Syntax; den Warnkommentar überWIDGET_TYPESum den Hinweis ergänzen, dass diese Funktionen der zweite Laufzeit-Import der API sind):parseReleaseVersion(raw: string): string | nullmit dem Muster optionalesv, 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 kanonischNumber(a).Number(b).Number(c).compareReleaseVersions(a: string, b: string): numberparst beide, wirftErrorbei null, vergleicht die drei Zahlen der Reihe nach und liefert -1/0/1. Dazuexport interface ReleaseNoticeResponse { currentRelease: string | null; lastSeenReleaseVersion: string | null }. Tests inapps/api/src/health/release-version.spec.ts(API-Suite, weilpackages/sharedkeinen eigenen Testlauf hat; Vorbildwidget-module-map.spec.ts). -
Laufende Version der API (einzige Quelle, siehe Kontext): in
apps/api/src/health/app-version.tsexport 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. -
Spalte (D-01):
lastSeenReleaseVersion String?immodel User(nebenaccentColor), Migrationapps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sqlmit Kopfkommentar (quick-260925-bow, wozu die Spalte dient, null = Bestandsbenutzer) und genauALTER TABLE "User" ADD COLUMN "lastSeenReleaseVersion" TEXT;. Danachpnpm --filter @tessera/api exec prisma generate. Kein Standardwert, kein Backfill (D-04: null ist gewollt). -
Endpunkte (D-05) in
apps/api/src/user/user.controller.ts, beide VOR@Get(':id'), ohne@Roles(jeder angemeldete Benutzer), beide überforTenant(this.prisma, currentUser.tenantId)mitwhere: { id: currentUser.id }— kein Kennungsparameter aus der Anfrage.GET me/release-notice:findUniquemitselect: { lastSeenReleaseVersion: true }, fehlt die Zeile, dannNotFoundException, sonstReleaseNoticeResponsemitcurrentRelease: getRunningRelease().POST me/release-seenmit@HttpCode(HttpStatus.OK)und neuer DTOapps/api/src/user/dto/release-seen.dto.ts(ReleaseSeenDto,versionmit@IsString(),@MaxLength(32),@Matchesauf die kanonische Form X.Y.Z): im Rumpf zusätzlich prüfen (Unit-Tests rufen die Methode ohne Pipe):typeof version === 'string'undparseReleaseVersion(version) === version, sonstBadRequestException; istgetRunningRelease()null, dannBadRequestException(keine freigegebene Version); istcompareReleaseVersions(version, running) > 0, dannBadRequestException. Dann gemerkten Stand lesen (findUnique, fehlt er, dannNotFoundException); nur wenn der gemerkte Wert null oder unparsebar ist oder die neue Version größer ist,updatemitdata: { lastSeenReleaseVersion: version }; Antwort{ success: true, lastSeenReleaseVersion: <gespeicherter Stand> }. JSDoc je Methode mit Bezug auf quick-260925-bow und die Mandantenbindung. Tests im bestehendenuser.controller.spec.tsmit der vorhandenen Zwei-Klienten-Attrappe,APP_VERSIONpervi.stubEnv(inafterEachvi.unstubAllEnvs()). -
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): TypenReleaseSectionKind = 'new' | 'changed' | 'fixed',ReleaseNotesSection { kind; markdown }(nur die Zeilen unter der Gruppenüberschrift, Leerzeilen am Rand entfernt),ReleaseNotesVersion { version; sections },ReleaseNotice { currentRelease; versions; omittedCount }, KonstanteRELEASE_NOTICE_MAX_VERSIONS = 3.parseChangelogReleases(markdown): CRLF normalisieren, an##-Überschriften zerlegen, Version = erstes Wort der Überschrift durchparseReleaseVersion(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 percompareReleaseVersions(nicht der Dateireihenfolge vertrauen); ergibt sich keine Version, Rückgabe null. -
Server-Aktionen in neuer Datei
apps/web/src/lib/release-notice-actions.tsmit'use server'(eigene Datei, damitauth-actions.tsdie Änderungsliste nicht importiert):fetchReleaseNotice(): Promise<ReleaseNotice | null>liest das CookiesessionwiefetchSessionState, ruftGET ${API_URL}/users/me/release-noticemitcache: 'no-store', prüft die Antwortform (beide Felder string oder null, sonst null) und gibtselectReleaseNotice(changelogMarkdown, body.currentRelease, body.lastSeenReleaseVersion)zurück; jeder Fehler still mit Rückgabe null.markReleaseSeenAction(version: string): Promise<{ success: boolean }>schicktPOST ${API_URL}/users/me/release-seen.changelogMarkdownkommt aus@/lib/changelog— erlaubt, weil Server-Code. Tests nach Vorbildauth-actions.test.ts,@/lib/changelogpervi.mockmit eigenem Markdown. -
Renderer wiederverwenden (D-07):
ChangelogViewbekommt eine optionale Eigenschaftvariant?: 'card' | 'plain'(Vorgabecard, Seite /changelog unverändert);plainlässt Rahmen, Hintergrund und Innenabstand weg,data-testidbleibt. Die bestehenden Tests der Seite müssen unverändert grün bleiben. -
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 nachwidget-catalog-modal.tsx: äußererfixed inset-0 z-50-Container, Hintergrund als echte Schaltfläche mit aria-label (SchlüsselreleaseNotice.close) undbg-black/50, Dialogrole="dialog",aria-modal="true",aria-labelledbyauf 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}“ mittabIndex={-1}und Anfangsfokus per Ref beim Mount, daneben Schließen-Kreuz (SVG aus dem Katalog-Fenster, aria-labelcommon.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 eineh3(text-sm font-semibold text-foreground) und<ChangelogView markdown={section.markdown} variant="plain" />. Fuß: beiomittedCount > 0der SatzmoreVersions(ICU-Plural), linksnext/link„Alle Änderungen ansehen“ auf/changelog(Klick ruft onClose, Navigation läuft normal weiter), rechts Hauptknopf „Verstanden“ mit den Knopfklassen ausbug-report-dialog.tsx. Tastatur:keydown-Listener aufdocument— 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 mitmotion-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. -
Host
apps/web/src/components/release-notice/release-notice-host.tsx('use client'): Zustandnotice, Ref-Sperre „schon abgefragt“;useEffectaufusePathname(): ist die Sperre gesetzt oder beginnt der Pfad mit/change-password, nichts tun; sonst Sperre setzen undfetchReleaseNotice()aufrufen, Ergebnis in den Zustand (Fehler still). Das Fenster perReact.lazy+Suspense fallback={null}laden, damit der Markdown-Renderer nur geladen wird, wenn wirklich eine Nachricht da ist.onClose: zuerstsetNotice(null)(Fenster sofort weg), dannvoid markReleaseSeenAction(notice.currentRelease)— schlägt das Merken fehl, erscheint das Fenster beim nächsten Laden erneut (gewollt, nicht stumm verloren). Inapp-shell.tsx<ReleaseNoticeHost />nach</main>einfügen (nur dort; Anmeldeseite hat keine AppShell, der Erststart-Dialog der Desktop-App ist die lokaleapps/desktop/src/setup.htmlvor dem Portal und wird nicht berührt; die Bildschirmfoto-Funktion rastertdocument.bodyund nimmt ein offenes Fenster einfach mit). -
Texte (D-07) neuer Namensraum
releaseNoticeinde.jsonunden.jsonmit 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).
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
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.
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 ….
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'
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.
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): ….
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
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.
<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> |
Orchestrator (D-11, Browserprüfung mit Playwright am lokalen Stack, NICHT Aufgabe des Executors):
- 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. 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.- „Verstanden“ → DB zeigt
1.4.0; Neuladen → kein Fenster. lastSeenReleaseVersion = NULL→ nur Abschnitt 1.4.0, keine Versionsunterüberschrift.= '1.4.0'→ kein Fenster. Escape und Hintergrundklick schließen ebenfalls und merken.- Anmeldeseite zeigt nie ein Fenster; „Fehler melden“ mit offenem Fenster funktioniert (Bildschirmfoto enthält das Fenster).
<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>