From 5952e02971fccf41b1cd13e5e079d298bc5a192e Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 20:03:23 +0200 Subject: [PATCH 1/4] 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. --- docs/system-notices.md | 27 ++++++--- server/src/systemNotices/service.ts | 21 ++++++- server/src/systemNotices/types.ts | 5 +- .../tests/integration/systemNotices.test.ts | 4 +- .../tests/unit/systemNotices/registry.test.ts | 18 ++++++ .../unit/systemNotices/versionRange.test.ts | 56 +++++++++++++++++++ 6 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 server/tests/unit/systemNotices/versionRange.test.ts diff --git a/docs/system-notices.md b/docs/system-notices.md index 6c1961fe..814292df 100644 --- a/docs/system-notices.md +++ b/docs/system-notices.md @@ -69,10 +69,10 @@ There are **no database rows for notice definitions**. The registry is code-only ├── reads user_notice_dismissals ├── filters SYSTEM_NOTICES: │ – not dismissed - │ – not expired (expiresAt) + │ – within [minVersion, maxVersion] range for the running app version │ – all conditions pass (AND logic) ├── sorts by priority → severity → publishedAt (desc) - └── strips server-only fields (conditions, publishedAt, expiresAt, priority) + └── strips server-only fields (conditions, publishedAt, minVersion, maxVersion, priority) │ ▼ 6. Client receives SystemNoticeDTO[] @@ -138,7 +138,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ **Never remove or renumber an entry. Never reuse an ID.** -Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, add `expiresAt` to stop it from being shown — do not delete the entry. +Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, set `maxVersion` to the last app version on which it should appear — do not delete the entry. --- @@ -162,13 +162,16 @@ Dismissals are stored in the database keyed by `id`. Removing an entry means dis | Field | Type | Description | |---|---|---| | `priority` | `number` | Higher number = shown first. Primary sort key. Default: `0`. | -| `expiresAt` | `string` | ISO 8601 date. Notice is automatically hidden after this date. Preferred over deleting entries. | +| `minVersion` | `string` | Lowest app version (inclusive, semver) that should show this notice. Omit for no lower bound. | +| `maxVersion` | `string` | Upper bound (exclusive, semver) — notice is hidden once this version ships. `maxVersion: '4.0.0'` means shown on `< 4.0.0`. Omit for no upper bound. | | `icon` | `string` | Lucide icon name (e.g. `'Sparkles'`, `'ImageOff'`). Shown in the modal's severity icon circle. Falls back to the severity default icon if absent or unrecognised. | | `bodyParams` | `Record` | Interpolation parameters for `bodyKey`. Values replace `{key}` placeholders in the translated string. **Never hardcode version numbers or dates directly in translation strings — use this instead.** | | `media` | `NoticeMedia` | Image to display in the modal. See below. | | `highlights` | `Array<{ labelKey: string; iconName?: string }>` | Bullet-point feature list rendered below the body in modals. Each entry is a translation key + optional Lucide icon name. | | `cta` | `NoticeCta` | Primary action button. See [§8 CTAs](#8-ctas-call-to-action). | +> **Version bounds:** The range is `[minVersion, maxVersion)` — lower bound inclusive, upper bound exclusive. So `maxVersion: '4.0.0'` hides the notice once the app reaches 4.0.0. Both bounds are compared after stripping prerelease/build metadata via `semver.coerce`, so a server running `3.0.0-pre.42` is treated as `3.0.0` — consistent with `existingUserBeforeVersion` and staging environments behave like production. + ### `NoticeMedia` ```typescript @@ -596,17 +599,25 @@ The registry integrity test will catch any `actionId` that appears in the regist ### Retire a notice (stop showing it) -**Do not delete the entry.** Set `expiresAt`: +**Do not delete the entry.** Set `maxVersion` to the last app version on which the notice should appear. Once the app is upgraded past that version, the service filters it out automatically. The database row for dismissed users remains harmless. ```typescript { id: 'old-campaign', // ... all existing fields unchanged ... - expiresAt: '2026-07-01T00:00:00Z', + maxVersion: '3.1.0', // hidden once 3.1.0 ships (exclusive upper bound) } ``` -After the expiry date the service filters it out automatically. The database row for dismissed users remains harmless. +To scope a notice to a specific version window (e.g. a v3-only announcement), combine both bounds: + +```typescript +{ + id: 'v3-only', + minVersion: '3.0.0', + maxVersion: '4.0.0', // shown on >= 3.0.0 and < 4.0.0 +} +``` --- @@ -751,4 +762,4 @@ cd client && npm run test -- SystemNoticeModal | CTA labels ≤ 20 chars, sentence case, a verb | Consistent button copy across the app. | | Priorities must be set explicitly for upgrade notices | Adjacent notices form a multipage group; ordering matters for the reading flow. | | `action` CTA `actionId` must be registered client-side | The registry integrity test enforces this. Add both the registry entry and the `registerNoticeAction` call in the same PR. | -| `expiresAt` over deletion for retiring notices | See above. | +| `maxVersion` over deletion for retiring notices | See §12. Deletion would cause dismissed users to re-see the notice if the ID were ever reused. | diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts index 13c16b1b..1a3a4ac2 100644 --- a/server/src/systemNotices/service.ts +++ b/server/src/systemNotices/service.ts @@ -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 { diff --git a/server/src/systemNotices/types.ts b/server/src/systemNotices/types.ts index bfd4812a..1004db03 100644 --- a/server/src/systemNotices/types.ts +++ b/server/src/systemNotices/types.ts @@ -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; +export type SystemNoticeDTO = Omit; diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts index 3e540067..0fcd6a33 100644 --- a/server/tests/integration/systemNotices.test.ts +++ b/server/tests/integration/systemNotices.test.ts @@ -107,9 +107,11 @@ describe('GET /api/system-notices/active', () => { // welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id); expect(testNotice).toBeDefined(); - // DTO should not expose conditions, publishedAt, expiresAt, priority + // DTO should not expose conditions, publishedAt, minVersion, maxVersion, priority expect(testNotice.conditions).toBeUndefined(); expect(testNotice.publishedAt).toBeUndefined(); + expect(testNotice.minVersion).toBeUndefined(); + expect(testNotice.maxVersion).toBeUndefined(); } finally { const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE); if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1); diff --git a/server/tests/unit/systemNotices/registry.test.ts b/server/tests/unit/systemNotices/registry.test.ts index bb9cc035..ab72bceb 100644 --- a/server/tests/unit/systemNotices/registry.test.ts +++ b/server/tests/unit/systemNotices/registry.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; +import semver from 'semver'; import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js'; /** Collect all actionIds registered via registerNoticeAction() in client source files. */ @@ -45,4 +46,21 @@ describe('registry integrity', () => { expect(() => new Date(n.publishedAt).toISOString()).not.toThrow(); } }); + + it('minVersion and maxVersion are valid semver when set, and minVersion <= maxVersion when both set', () => { + for (const n of SYSTEM_NOTICES) { + if (n.minVersion !== undefined) { + expect(semver.valid(n.minVersion), `notice "${n.id}" has invalid minVersion "${n.minVersion}"`).not.toBeNull(); + } + if (n.maxVersion !== undefined) { + expect(semver.valid(n.maxVersion), `notice "${n.id}" has invalid maxVersion "${n.maxVersion}"`).not.toBeNull(); + } + if (n.minVersion && n.maxVersion) { + expect( + semver.lte(n.minVersion, n.maxVersion), + `notice "${n.id}": minVersion ${n.minVersion} > maxVersion ${n.maxVersion}` + ).toBe(true); + } + } + }); }); diff --git a/server/tests/unit/systemNotices/versionRange.test.ts b/server/tests/unit/systemNotices/versionRange.test.ts new file mode 100644 index 00000000..3973dd1b --- /dev/null +++ b/server/tests/unit/systemNotices/versionRange.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { isNoticeVersionActive } from '../../../src/systemNotices/service.js'; +import type { SystemNotice } from '../../../src/systemNotices/types.js'; + +const base: SystemNotice = { + id: 'test-notice', + display: 'modal', + severity: 'info', + titleKey: 'k', + bodyKey: 'k', + dismissible: true, + conditions: [], + publishedAt: '2026-01-01T00:00:00Z', +}; + +describe('isNoticeVersionActive', () => { + it('passes when no bounds are set', () => { + expect(isNoticeVersionActive(base, '3.5.0')).toBe(true); + }); + + it('passes when app version equals minVersion (inclusive)', () => { + expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '3.0.0')).toBe(true); + }); + + it('fails when app version is below minVersion', () => { + expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '2.9.9')).toBe(false); + }); + + it('fails when app version equals maxVersion (exclusive upper bound)', () => { + expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '3.0.0')).toBe(false); + }); + + it('fails when app version exceeds maxVersion', () => { + expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '3.0.1')).toBe(false); + }); + + it('passes when app version is just below maxVersion', () => { + expect(isNoticeVersionActive({ ...base, maxVersion: '3.0.0' }, '2.9.9')).toBe(true); + }); + + it('passes when app version is inside [minVersion, maxVersion)', () => { + expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0', maxVersion: '3.9.9' }, '3.5.2')).toBe(true); + }); + + it('treats prerelease app version as base release (semver.coerce)', () => { + expect(isNoticeVersionActive({ ...base, minVersion: '3.0.0' }, '3.0.0-pre.42')).toBe(true); + }); + + it('skips notice and returns false when minVersion is invalid semver', () => { + expect(isNoticeVersionActive({ ...base, minVersion: 'not-semver' }, '3.0.0')).toBe(false); + }); + + it('skips notice and returns false when maxVersion is invalid semver', () => { + expect(isNoticeVersionActive({ ...base, maxVersion: 'garbage' }, '3.0.0')).toBe(false); + }); +}); From 4b7ba6cb3fc4794975bcab24df7a835be91e38b8 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 20:04:54 +0200 Subject: [PATCH 2/4] feat(system-notices): apply version gates to v3 upgrade notices --- server/src/systemNotices/registry.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/src/systemNotices/registry.ts b/server/src/systemNotices/registry.ts index ccf4f6c3..19f056ce 100644 --- a/server/src/systemNotices/registry.ts +++ b/server/src/systemNotices/registry.ts @@ -27,6 +27,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }], publishedAt: '2026-04-16T00:00:00Z', priority: 90, + minVersion: '3.0.0', + maxVersion: '4.0.0', }, { @@ -55,6 +57,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ ], publishedAt: '2026-04-16T00:00:00Z', priority: 80, + minVersion: '3.0.0', + maxVersion: '4.0.0', }, { @@ -78,6 +82,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ ], publishedAt: '2026-04-16T00:00:00Z', priority: 75, + minVersion: '3.0.0', + maxVersion: '4.0.0', }, { @@ -98,6 +104,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }], publishedAt: '2026-04-16T00:00:00Z', priority: 70, + minVersion: '3.0.0', + maxVersion: '4.0.0', }, { @@ -112,6 +120,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }], publishedAt: '2026-04-16T00:00:00Z', priority: 95, + minVersion: '3.0.0', + maxVersion: '4.0.0', }, // ── Onboarding ───────────────────────────────────────────────────────────── From a84aedc3b4e9d51c841d67308fe07b4afdee73cb Mon Sep 17 00:00:00 2001 From: "Julien G." <66769052+jubnl@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:07:34 +0200 Subject: [PATCH 3/4] Fix range notation for app version filtering --- docs/system-notices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/system-notices.md b/docs/system-notices.md index 814292df..be749ac4 100644 --- a/docs/system-notices.md +++ b/docs/system-notices.md @@ -69,7 +69,7 @@ There are **no database rows for notice definitions**. The registry is code-only ├── reads user_notice_dismissals ├── filters SYSTEM_NOTICES: │ – not dismissed - │ – within [minVersion, maxVersion] range for the running app version + │ – within [minVersion, maxVersion) range for the running app version │ – all conditions pass (AND logic) ├── sorts by priority → severity → publishedAt (desc) └── strips server-only fields (conditions, publishedAt, minVersion, maxVersion, priority) From 1425c4e05bf9886e5a786304b27be60be3265add Mon Sep 17 00:00:00 2001 From: "Julien G." <66769052+jubnl@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:09:34 +0200 Subject: [PATCH 4/4] Update maxVersion explanation in system-notices.md Clarified the explanation regarding setting maxVersion for notices. --- docs/system-notices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/system-notices.md b/docs/system-notices.md index be749ac4..612a9ef3 100644 --- a/docs/system-notices.md +++ b/docs/system-notices.md @@ -138,7 +138,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ **Never remove or renumber an entry. Never reuse an ID.** -Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, set `maxVersion` to the last app version on which it should appear — do not delete the entry. +Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, set `maxVersion` to the upper version on which it should appear (e.g. `4.0.0` means show notice until `4.0.0` is reached) — do not delete the entry. ---