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 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; // Strip prerelease/build metadata so '3.0.0-pre.42' is treated as '3.0.0'. const appVersion = semver.coerce(ctx.currentAppVersion)?.version ?? '0.0.0'; return ( semver.lt(userVersion, noticeVersion) && semver.gte(appVersion, 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 };