mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
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:
@@ -0,0 +1,70 @@
|
||||
import semver from 'semver';
|
||||
import { isAddonEnabled } from '../services/adminService.js';
|
||||
import type { NoticeCondition, SystemNotice } from './types.js';
|
||||
|
||||
interface ConditionContext {
|
||||
user: { login_count: number; first_seen_version: string; role: string; noTrips: number };
|
||||
currentAppVersion: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
// Custom predicate registry — extensible without modifying this file
|
||||
const customPredicates = new Map<string, (ctx: ConditionContext) => boolean>();
|
||||
export function registerPredicate(id: string, fn: (ctx: ConditionContext) => boolean): void {
|
||||
customPredicates.set(id, fn);
|
||||
}
|
||||
|
||||
function evaluateOne(condition: NoticeCondition, ctx: ConditionContext): boolean {
|
||||
switch (condition.kind) {
|
||||
case 'always':
|
||||
return true;
|
||||
case 'firstLogin':
|
||||
// login_count is incremented during login, so on the FIRST post-login fetch it's 1.
|
||||
return ctx.user.login_count <= 1;
|
||||
case 'noTrips':
|
||||
return ctx.user.noTrips === 0;
|
||||
|
||||
case 'existingUserBeforeVersion': {
|
||||
// Show to users who existed BEFORE this version was released.
|
||||
// Backfilled users have first_seen_version='0.0.0', so all pass semver.lt.
|
||||
const userVersion = semver.valid(ctx.user.first_seen_version) ?? '0.0.0';
|
||||
const noticeVersion = semver.valid(condition.version);
|
||||
if (!noticeVersion) return false;
|
||||
return (
|
||||
semver.lt(userVersion, noticeVersion) &&
|
||||
semver.gte(semver.valid(ctx.currentAppVersion) ?? '0.0.0', noticeVersion)
|
||||
);
|
||||
}
|
||||
|
||||
case 'dateWindow': {
|
||||
const start = new Date(condition.startsAt);
|
||||
const end = condition.endsAt ? new Date(condition.endsAt) : null;
|
||||
return ctx.now >= start && (end === null || ctx.now <= end);
|
||||
}
|
||||
|
||||
case 'role':
|
||||
return condition.roles.includes(ctx.user.role as 'admin' | 'user');
|
||||
|
||||
case 'addonEnabled':
|
||||
return isAddonEnabled(condition.addonId);
|
||||
|
||||
case 'custom': {
|
||||
const fn = customPredicates.get(condition.id);
|
||||
if (!fn) {
|
||||
console.warn(`[systemNotices] unknown custom predicate: "${condition.id}"`);
|
||||
return false;
|
||||
}
|
||||
return fn(ctx);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true only if ALL conditions pass (AND logic). */
|
||||
export function evaluate(notice: SystemNotice, ctx: ConditionContext): boolean {
|
||||
return notice.conditions.every(c => evaluateOne(c, ctx));
|
||||
}
|
||||
|
||||
export type { ConditionContext };
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { SystemNotice } from './types.js';
|
||||
|
||||
/**
|
||||
* SYSTEM NOTICE REGISTRY
|
||||
*
|
||||
* Rules for authoring:
|
||||
* - NEVER remove or renumber entries — dismissal tracking is keyed by `id`.
|
||||
* - `id` must be globally unique and stable across deployments.
|
||||
* - Title: ≤40 chars, sentence case, no trailing punctuation.
|
||||
* - Body: markdown (modal) or plain text (banner/toast). ≤400/140/80 chars.
|
||||
* - CTA label: ≤20 chars, a verb.
|
||||
* - Never hardcode version numbers/dates in translated strings — use bodyParams.
|
||||
* - See plans/system-notices/00-overview.md for full authoring guidelines.
|
||||
*/
|
||||
export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
// ── 3.0.0 upgrade notices (shown as a multipage modal to pre-3.0 users) ─────
|
||||
|
||||
{
|
||||
// Page 1 — breaking change first (warn → sorts before the two info notices)
|
||||
id: 'v3-photos',
|
||||
display: 'modal',
|
||||
severity: 'warn',
|
||||
icon: 'ImageOff',
|
||||
titleKey: 'system_notice.v3_photos.title',
|
||||
bodyKey: 'system_notice.v3_photos.body',
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 90,
|
||||
},
|
||||
|
||||
{
|
||||
// Page 2 — flagship feature (only when Journey addon is enabled)
|
||||
id: 'v3-journey',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'BookOpen',
|
||||
titleKey: 'system_notice.v3_journey.title',
|
||||
bodyKey: 'system_notice.v3_journey.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_timeline', iconName: 'CalendarDays' },
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_photos', iconName: 'Images' },
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_share', iconName: 'Globe' },
|
||||
{ labelKey: 'system_notice.v3_journey.highlight_export', iconName: 'FileText' },
|
||||
],
|
||||
cta: {
|
||||
kind: 'nav',
|
||||
labelKey: 'system_notice.v3_journey.cta_label',
|
||||
href: '/journey',
|
||||
},
|
||||
dismissible: true,
|
||||
conditions: [
|
||||
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
|
||||
{ kind: 'addonEnabled', addonId: 'journey' },
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 80,
|
||||
},
|
||||
|
||||
{
|
||||
// Page 3 — other highlights
|
||||
id: 'v3-features',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Sparkles',
|
||||
titleKey: 'system_notice.v3_features.title',
|
||||
bodyKey: 'system_notice.v3_features.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_features.highlight_dashboard', iconName: 'LayoutDashboard' },
|
||||
{ labelKey: 'system_notice.v3_features.highlight_offline', iconName: 'WifiOff' },
|
||||
{ labelKey: 'system_notice.v3_features.highlight_search', iconName: 'Search' },
|
||||
{ labelKey: 'system_notice.v3_features.highlight_import', iconName: 'FileInput' },
|
||||
],
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 70,
|
||||
},
|
||||
|
||||
// ── Onboarding ─────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
id: 'welcome-v1',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
icon: 'Sparkles',
|
||||
titleKey: 'system_notice.welcome_v1.title',
|
||||
bodyKey: 'system_notice.welcome_v1.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.welcome_v1.highlight_plan', iconName: 'Map' },
|
||||
{ labelKey: 'system_notice.welcome_v1.highlight_share', iconName: 'Users' },
|
||||
{ labelKey: 'system_notice.welcome_v1.highlight_offline', iconName: 'WifiOff' },
|
||||
],
|
||||
cta: {
|
||||
kind: 'action',
|
||||
labelKey: 'system_notice.welcome_v1.cta_label',
|
||||
actionId: 'open:trip-create',
|
||||
},
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'firstLogin' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 100,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,59 @@
|
||||
import { db } from '../db/database.js';
|
||||
import { SYSTEM_NOTICES } from './registry.js';
|
||||
import { evaluate } from './conditions.js';
|
||||
import type { SystemNoticeDTO } from './types.js';
|
||||
|
||||
function getCurrentAppVersion(): string {
|
||||
return process.env.APP_VERSION || '0.0.0';
|
||||
}
|
||||
|
||||
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 (n.expiresAt && now > new Date(n.expiresAt)) 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, expiresAt: _e, 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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type Display = 'modal' | 'banner' | 'toast';
|
||||
export type Severity = 'info' | 'warn' | 'critical';
|
||||
|
||||
export type NoticeCondition =
|
||||
| { kind: 'firstLogin' }
|
||||
| { kind: 'always' }
|
||||
| { kind: 'noTrips' }
|
||||
| { kind: 'existingUserBeforeVersion'; version: string }
|
||||
| { kind: 'dateWindow'; startsAt: string; endsAt?: string }
|
||||
| { kind: 'role'; roles: Array<'admin' | 'user'> }
|
||||
| { kind: 'addonEnabled'; addonId: string }
|
||||
| { kind: 'custom'; id: string };
|
||||
|
||||
export interface NoticeMedia {
|
||||
src: string;
|
||||
srcDark?: string;
|
||||
altKey: string;
|
||||
placement?: 'hero' | 'inline';
|
||||
aspectRatio?: string;
|
||||
}
|
||||
|
||||
export type NoticeCta =
|
||||
| { kind: 'nav'; labelKey: string; href: string }
|
||||
| { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
||||
|
||||
export interface SystemNotice {
|
||||
id: string;
|
||||
display: Display;
|
||||
severity: Severity;
|
||||
titleKey: string;
|
||||
bodyKey: string;
|
||||
bodyParams?: Record<string, string>;
|
||||
icon?: string;
|
||||
media?: NoticeMedia;
|
||||
highlights?: Array<{ labelKey: string; iconName?: string }>;
|
||||
cta?: NoticeCta;
|
||||
dismissible: boolean;
|
||||
conditions: NoticeCondition[];
|
||||
publishedAt: string;
|
||||
expiresAt?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
// DTO sent to client (same shape minus the conditions — server evaluates those)
|
||||
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'expiresAt' | 'priority'>;
|
||||
Reference in New Issue
Block a user