mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge branch 'feat/system-notices' into dev
This commit is contained in:
@@ -6,6 +6,7 @@ export const ADDON_IDS = {
|
||||
VACAY: 'vacay',
|
||||
ATLAS: 'atlas',
|
||||
COLLAB: 'collab',
|
||||
JOURNEY: 'journey',
|
||||
} as const;
|
||||
|
||||
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
|
||||
|
||||
+8
-1
@@ -42,10 +42,13 @@ import shareRoutes from './routes/share';
|
||||
import journeyRoutes from './routes/journey';
|
||||
import journeyPublicRoutes from './routes/journeyPublic';
|
||||
import publicConfigRoutes from './routes/publicConfig';
|
||||
import systemNoticesRoutes from './routes/systemNotices';
|
||||
import { mcpHandler } from './mcp';
|
||||
import { Addon } from './types';
|
||||
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
||||
import { getCollabFeatures } from './services/adminService';
|
||||
import { isAddonEnabled } from './services/adminService';
|
||||
import { ADDON_IDS } from './addons';
|
||||
|
||||
export function createApp(): express.Application {
|
||||
const app = express();
|
||||
@@ -267,13 +270,17 @@ export function createApp(): express.Application {
|
||||
// Addon routes
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
app.use('/api/journeys', journeyRoutes);
|
||||
app.use('/api/journeys', (req, res, next) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
|
||||
next();
|
||||
}, journeyRoutes);
|
||||
app.use('/api/public/journey', journeyPublicRoutes);
|
||||
app.use('/api/integrations/memories', memoriesRoutes);
|
||||
app.use('/api/photos', photoRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/system-notices', systemNoticesRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api', shareRoutes);
|
||||
|
||||
@@ -1614,6 +1614,19 @@ function runMigrations(db: Database.Database): void {
|
||||
// Migration 102: Add check_in_end column for check-in time ranges
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in_end TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
// Migration 103: System notices — user tracking columns + dismissals table
|
||||
() => {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`);
|
||||
db.exec(`ALTER TABLE users ADD COLUMN login_count INTEGER NOT NULL DEFAULT 0`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_notice_dismissals (
|
||||
user_id INTEGER NOT NULL,
|
||||
notice_id TEXT NOT NULL,
|
||||
dismissed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, notice_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { getActiveNoticesFor, dismissNotice } from '../systemNotices/service.js';
|
||||
import type { AuthRequest } from '../types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/system-notices/active
|
||||
// Returns notices active for the authenticated user.
|
||||
router.get('/active', authenticate, (req, res) => {
|
||||
const userId = (req as AuthRequest).user!.id;
|
||||
const notices = getActiveNoticesFor(userId);
|
||||
res.json(notices);
|
||||
});
|
||||
|
||||
// POST /api/system-notices/:id/dismiss
|
||||
// Marks a notice as dismissed for the authenticated user. Idempotent.
|
||||
router.post('/:id/dismiss', authenticate, (req, res) => {
|
||||
const userId = (req as AuthRequest).user!.id;
|
||||
const noticeId = req.params.id;
|
||||
const ok = dismissNotice(userId, noticeId);
|
||||
if (!ok) {
|
||||
res.status(404).json({ error: 'NOTICE_NOT_FOUND' });
|
||||
return;
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -334,8 +334,8 @@ export function registerUser(body: {
|
||||
|
||||
try {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
|
||||
).run(username, email, password_hash, role);
|
||||
'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)'
|
||||
).run(username, email, password_hash, role, process.env.APP_VERSION || '0.0.0');
|
||||
|
||||
const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false };
|
||||
const token = generateToken(user);
|
||||
@@ -408,7 +408,7 @@ export function loginUser(body: {
|
||||
return { mfa_required: true, mfa_token };
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const token = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
|
||||
@@ -972,7 +972,7 @@ export function verifyMfaLogin(body: {
|
||||
user.id
|
||||
);
|
||||
}
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const sessionToken = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
return {
|
||||
|
||||
@@ -287,8 +287,8 @@ export function findOrCreateUser(
|
||||
if (existing) username = `${username}_${Date.now() % 10000}`;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(username, email, hash, role, sub, config.issuer);
|
||||
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
|
||||
).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
|
||||
|
||||
if (validInvite) {
|
||||
const updated = db.prepare(
|
||||
@@ -308,5 +308,5 @@ export function findOrCreateUser(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function touchLastLogin(userId: number): void {
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
@@ -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'>;
|
||||
@@ -17,6 +17,8 @@ export interface User {
|
||||
mfa_secret?: string | null;
|
||||
mfa_backup_codes?: string | null;
|
||||
must_change_password?: number | boolean;
|
||||
first_seen_version?: string;
|
||||
login_count?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user