From 59db32a01f1d774f758e078d8e269695d5daf3ff Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 25 Sep 2026 08:42:11 +0200 Subject: [PATCH] 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) --- .../migration.sql | 15 +++ apps/api/prisma/schema.prisma | 3 + apps/api/src/health/app-version.ts | 23 +++- apps/api/src/user/dto/release-seen.dto.ts | 17 +++ apps/api/src/user/user.controller.ts | 101 ++++++++++++++++++ packages/shared/src/index.ts | 71 ++++++++++++ 6 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql create mode 100644 apps/api/src/user/dto/release-seen.dto.ts diff --git a/apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql b/apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql new file mode 100644 index 0000000..ad5de63 --- /dev/null +++ b/apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 918889c..1443c90 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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[] diff --git a/apps/api/src/health/app-version.ts b/apps/api/src/health/app-version.ts index f49defc..b67c8cd 100644 --- a/apps/api/src/health/app-version.ts +++ b/apps/api/src/health/app-version.ts @@ -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); +} diff --git a/apps/api/src/user/dto/release-seen.dto.ts b/apps/api/src/user/dto/release-seen.dto.ts new file mode 100644 index 0000000..5f8c22d --- /dev/null +++ b/apps/api/src/user/dto/release-seen.dto.ts @@ -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; +} diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index dd7bb95..693b43c 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -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 { + 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 */ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b0b7788..831aa43 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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> = { 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` + * (`--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; +}