mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 23:31:47 +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 };
|
||||
Reference in New Issue
Block a user