mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
+19
-8
@@ -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<string, string>` | 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. |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user