Files
TREK/server/src/systemNotices/service.ts
T
jubnl 5952e02971 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.
2026-04-17 20:03:23 +02:00

84 lines
3.1 KiB
TypeScript

import { createRequire } from 'module';
import semver from 'semver';
import { db } from '../db/database.js';
import { SYSTEM_NOTICES } from './registry.js';
import { evaluate } from './conditions.js';
import type { SystemNotice, SystemNoticeDTO } from './types.js';
function getCurrentAppVersion(): string {
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
if (fromEnv) return fromEnv;
try {
const pkg = require('../../package.json') as { version?: string };
return semver.valid(pkg.version ?? '') ?? '0.0.0';
} catch {
return '0.0.0';
}
}
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;
}
export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
const user = db.prepare(
'SELECT login_count, first_seen_version, role FROM users WHERE id = ?'
).get(userId) as { login_count: number; first_seen_version: string; role: string } | undefined;
if (!user) return [];
const { count: tripCount } = db.prepare(
'SELECT COUNT(*) AS count FROM trips WHERE user_id = ?'
).get(userId) as { count: number };
const dismissedIds = new Set<string>(
(db.prepare('SELECT notice_id FROM user_notice_dismissals WHERE user_id = ?')
.all(userId) as Array<{ notice_id: string }>)
.map(r => r.notice_id)
);
const now = new Date();
const currentAppVersion = getCurrentAppVersion();
const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now };
return SYSTEM_NOTICES
.filter(n => {
if (dismissedIds.has(n.id)) return false;
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
return evaluate(n, ctx);
})
.sort((a, b) => {
const pw = (b.priority ?? 0) - (a.priority ?? 0);
if (pw !== 0) return pw;
const sw = severityWeight(b.severity) - severityWeight(a.severity);
if (sw !== 0) return sw;
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
})
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto);
}
export function dismissNotice(userId: number, noticeId: string): boolean {
const exists = SYSTEM_NOTICES.some(n => n.id === noticeId);
if (!exists) return false;
db.prepare(`
INSERT OR IGNORE INTO user_notice_dismissals (user_id, notice_id, dismissed_at)
VALUES (?, ?, ?)
`).run(userId, noticeId, Date.now());
return true;
}