feat(notices): add system notice infrastructure

Server-side notice registry with per-user condition evaluation (firstLogin,
existingUserBeforeVersion, addonEnabled, dateWindow, role, custom).
Notices are sorted by priority then severity, filtered against dismissals
stored in a new user_notice_dismissals table, and served via
GET /api/system-notices/active + POST /api/system-notices/:id/dismiss.

Client renders notices through a host component that partitions by
display type (modal / banner / toast). The modal renderer supports
multi-page pagination with directional slide transitions, keyboard
navigation, and correct dismiss-all semantics on CTA / X / ESC.
Dismissals are optimistic with a single background retry.

Includes 3.0.0 upgrade notices (v3-photos, v3-journey, v3-features),
onboarding welcome modal, and full i18n coverage across 15 languages.
The /journey route is addon-gated on both client and server.

Also includes: unit + integration test suites, registry integrity test
that validates action CTA IDs against client source, and technical
documentation in docs/system-notices.md.
This commit is contained in:
jubnl
2026-04-16 14:36:33 +02:00
parent 3b069bc543
commit 293506217e
46 changed files with 3539 additions and 50 deletions
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { evaluate } from '../../../src/systemNotices/conditions.js';
import type { SystemNotice } from '../../../src/systemNotices/types.js';
const baseNotice: SystemNotice = {
id: 'test',
display: 'modal',
severity: 'info',
titleKey: 'k.title',
bodyKey: 'k.body',
dismissible: true,
conditions: [],
publishedAt: '2026-01-01T00:00:00Z',
};
const baseCtx = {
user: { login_count: 5, first_seen_version: '1.0.0', role: 'user' },
currentAppVersion: '2.0.0',
now: new Date('2026-06-01T00:00:00Z'),
};
describe('firstLogin', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'firstLogin' as const }] };
it('passes when login_count <= 1', () => {
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true);
});
it('fails when login_count > 1', () => {
expect(evaluate(notice, baseCtx)).toBe(false);
});
});
describe('existingUserBeforeVersion', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'existingUserBeforeVersion' as const, version: '2.0.0' }] };
it('passes for user with first_seen_version < notice version when current >= notice version', () => {
expect(evaluate(notice, baseCtx)).toBe(true);
});
it('fails for new user (first_seen_version >= notice version)', () => {
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, first_seen_version: '2.0.0' } })).toBe(false);
});
it('fails when current app version < notice version', () => {
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '1.5.0' })).toBe(false);
});
});
describe('dateWindow', () => {
it('passes when now is inside window', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }] };
expect(evaluate(notice, baseCtx)).toBe(true);
});
it('fails when now is before start', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-07-01T00:00:00Z' }] };
expect(evaluate(notice, baseCtx)).toBe(false);
});
it('passes when no endsAt', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-01-01T00:00:00Z' }] };
expect(evaluate(notice, baseCtx)).toBe(true);
});
});
describe('role', () => {
it('passes for matching role', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['user'] }] };
expect(evaluate(notice, baseCtx)).toBe(true);
});
it('fails for non-matching role', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['admin'] }] };
expect(evaluate(notice, baseCtx)).toBe(false);
});
});
describe('AND logic', () => {
it('requires all conditions to pass', () => {
const notice = { ...baseNotice, conditions: [
{ kind: 'firstLogin' as const },
{ kind: 'role' as const, roles: ['user'] },
]};
// login_count=1 passes firstLogin, role=user passes role → true
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true);
// login_count=2 fails firstLogin → false
expect(evaluate(notice, baseCtx)).toBe(false);
});
});
describe('empty conditions', () => {
it('always passes when conditions array is empty', () => {
expect(evaluate(baseNotice, baseCtx)).toBe(true);
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
/** Collect all actionIds registered via registerNoticeAction() in client source files. */
function collectRegisteredActionIds(): Set<string> {
const clientSrc = path.resolve(__dirname, '../../../../client/src');
const ids = new Set<string>();
const queue = [clientSrc];
while (queue.length) {
const dir = queue.pop()!;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { queue.push(full); continue; }
if (!entry.name.endsWith('noticeActions.ts') && !entry.name.endsWith('noticeActions.js')) continue;
const src = fs.readFileSync(full, 'utf8');
for (const m of src.matchAll(/registerNoticeAction\(\s*['"]([^'"]+)['"]/g)) {
ids.add(m[1]);
}
}
}
return ids;
}
describe('registry integrity', () => {
it('has no duplicate ids', () => {
const ids = SYSTEM_NOTICES.map(n => n.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('all action CTAs reference a registered actionId', () => {
const registeredActionIds = collectRegisteredActionIds();
const actionCtaIds = SYSTEM_NOTICES
.filter(n => n.cta?.kind === 'action')
.map(n => (n.cta as { actionId: string }).actionId);
for (const id of actionCtaIds) {
expect(registeredActionIds, `actionId "${id}" not found in any client noticeActions.ts`).toContain(id);
}
});
it('all publishedAt are valid ISO dates', () => {
for (const n of SYSTEM_NOTICES) {
expect(() => new Date(n.publishedAt).toISOString()).not.toThrow();
}
});
});