feat(system-notices): replace expiresAt with [minVersion, maxVersion) version gate

Prevents users who upgrade across multiple versions from seeing all
interim notices at once. Version bounds are evaluated server-side using
semver.coerce so prerelease builds compare as their base release.
Range is lower-inclusive, upper-exclusive: maxVersion: '4.0.0' hides
the notice once 4.0.0 ships.
This commit is contained in:
jubnl
2026-04-17 20:03:23 +02:00
parent 8cd5aa0d23
commit 5952e02971
6 changed files with 117 additions and 14 deletions
+18 -3
View File
@@ -3,7 +3,7 @@ import semver from 'semver';
import { db } from '../db/database.js';
import { SYSTEM_NOTICES } from './registry.js';
import { evaluate } from './conditions.js';
import type { SystemNoticeDTO } from './types.js';
import type { SystemNotice, SystemNoticeDTO } from './types.js';
function getCurrentAppVersion(): string {
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
@@ -16,6 +16,21 @@ function getCurrentAppVersion(): string {
}
}
export function isNoticeVersionActive(n: SystemNotice, currentAppVersion: string): boolean {
const appVersion = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
if (n.minVersion !== undefined) {
const min = semver.valid(n.minVersion);
if (!min) { console.warn(`[systemNotices] "${n.id}" invalid minVersion "${n.minVersion}" — skipping`); return false; }
if (semver.lt(appVersion, min)) return false;
}
if (n.maxVersion !== undefined) {
const max = semver.valid(n.maxVersion);
if (!max) { console.warn(`[systemNotices] "${n.id}" invalid maxVersion "${n.maxVersion}" — skipping`); return false; }
if (semver.gte(appVersion, max)) return false;
}
return true;
}
function severityWeight(s: string): number {
return s === 'critical' ? 2 : s === 'warn' ? 1 : 0;
}
@@ -44,7 +59,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
return SYSTEM_NOTICES
.filter(n => {
if (dismissedIds.has(n.id)) return false;
if (n.expiresAt && now > new Date(n.expiresAt)) return false;
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
return evaluate(n, ctx);
})
.sort((a, b) => {
@@ -54,7 +69,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
if (sw !== 0) return sw;
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
})
.map(({ conditions: _c, publishedAt: _p, expiresAt: _e, priority: _pr, ...dto }) => dto);
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto);
}
export function dismissNotice(userId: number, noticeId: string): boolean {
+3 -2
View File
@@ -37,9 +37,10 @@ export interface SystemNotice {
dismissible: boolean;
conditions: NoticeCondition[];
publishedAt: string;
expiresAt?: string;
minVersion?: string;
maxVersion?: string;
priority?: number;
}
// DTO sent to client (same shape minus the conditions — server evaluates those)
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'expiresAt' | 'priority'>;
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority'>;