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:
@@ -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;
|
||||||
@@ -43,6 +43,9 @@ model User {
|
|||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
avatarPath String?
|
avatarPath String?
|
||||||
accentColor 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[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
groupMemberships GroupMembership[]
|
groupMemberships GroupMembership[]
|
||||||
moduleGrants ModuleGrant[]
|
moduleGrants ModuleGrant[]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { VersionResponse } from '@tessera/shared';
|
import { parseReleaseVersion, type VersionResponse } from '@tessera/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Versionsstempel der API (quick-260914-ku1).
|
* 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})`;
|
const base = `Tessera API ${v.version} (${v.channel})`;
|
||||||
return v.commit ? `${base} ${v.commit}` : base;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -16,6 +18,11 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
compareReleaseVersions,
|
||||||
|
parseReleaseVersion,
|
||||||
|
type ReleaseNoticeResponse,
|
||||||
|
} from '@tessera/shared';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { Response } from 'express';
|
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 { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { getRunningRelease } from '../health/app-version';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { ReleaseSeenDto } from './dto/release-seen.dto';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@@ -48,6 +57,11 @@ function resolveAvatarsDir(): string {
|
|||||||
* gebundenen Klienten (`tenantPrisma`, Konvention aus `ldap`, `groups`,
|
* gebundenen Klienten (`tenantPrisma`, Konvention aus `ldap`, `groups`,
|
||||||
* `dkv`, `auth`, `user.service.ts`) oder ueber die uebergreifenden Methoden
|
* `dkv`, `auth`, `user.service.ts`) oder ueber die uebergreifenden Methoden
|
||||||
* von `UserService`, deren Rumpf je Mandant gebunden ist.
|
* 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')
|
@Controller('users')
|
||||||
@UseGuards(RolesGuard)
|
@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
|
* GET /users/:id
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export interface DesktopLatestResponse {
|
|||||||
* das native Type-Stripping von Node 24. Deshalb darf diese Datei nur
|
* das native Type-Stripping von Node 24. Deshalb darf diese Datei nur
|
||||||
* loeschbare Syntax enthalten — keine `enum`, kein `namespace`, keine
|
* loeschbare Syntax enthalten — keine `enum`, kein `namespace`, keine
|
||||||
* Parameter-Eigenschaften.
|
* 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 = [
|
export const WIDGET_TYPES = [
|
||||||
'clock',
|
'clock',
|
||||||
@@ -123,3 +128,69 @@ export type WidgetType = (typeof WIDGET_TYPES)[number];
|
|||||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {
|
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {
|
||||||
proxmox: 'proxmox',
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user