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>
This commit is contained in:
2026-09-25 08:42:11 +02:00
parent b35edd5f31
commit 59db32a01f
6 changed files with 229 additions and 1 deletions
+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);
}
+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;
}
+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
*/