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:
Generated
+12
-13
@@ -24,6 +24,7 @@
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "^7.7.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
@@ -45,6 +46,7 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -1590,7 +1592,6 @@
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
@@ -1721,6 +1722,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@@ -2152,7 +2160,6 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -3164,7 +3171,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -3668,11 +3674,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.12",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -5730,7 +5735,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -5805,7 +5809,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -5961,7 +5964,6 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6078,7 +6080,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6092,7 +6093,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -6331,7 +6331,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
+3
-1
@@ -26,12 +26,13 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"undici": "^7.0.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "^7.7.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.19.0",
|
||||
@@ -54,6 +55,7 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ const RESET_TABLES = [
|
||||
'notification_channel_preferences',
|
||||
'notifications',
|
||||
'audit_log',
|
||||
// System notices
|
||||
'user_notice_dismissals',
|
||||
// User data
|
||||
'settings',
|
||||
'mcp_tokens',
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* System Notices API integration tests.
|
||||
* Covers GET /api/system-notices/active and POST /api/system-notices/:id/dismiss.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: () => null,
|
||||
isOwner: () => false,
|
||||
};
|
||||
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
|
||||
import type { SystemNotice } from '../../src/systemNotices/types';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
// Test notice injected into the registry for notice-specific tests
|
||||
const TEST_NOTICE: SystemNotice = {
|
||||
id: 'test-first-login-notice',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
titleKey: 'system_notice.test_first_login_notice.title',
|
||||
bodyKey: 'system_notice.test_first_login_notice.body',
|
||||
dismissible: true,
|
||||
conditions: [{ kind: 'firstLogin' }],
|
||||
publishedAt: '2026-01-01T00:00:00Z',
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/system-notices/active
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/system-notices/active', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).get('/api/system-notices/active');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns empty array for non-first-login user with no applicable notices', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// login_count > 1 means firstLogin condition does not match for any notice
|
||||
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns firstLogin notice for user with login_count <= 1', async () => {
|
||||
SYSTEM_NOTICES.push(TEST_NOTICE);
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
// Set login_count to 1 (first login)
|
||||
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
// welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present
|
||||
const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
|
||||
expect(testNotice).toBeDefined();
|
||||
// DTO should not expose conditions, publishedAt, expiresAt, priority
|
||||
expect(testNotice.conditions).toBeUndefined();
|
||||
expect(testNotice.publishedAt).toBeUndefined();
|
||||
} finally {
|
||||
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
|
||||
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not return firstLogin notice for user with login_count > 1', async () => {
|
||||
SYSTEM_NOTICES.push(TEST_NOTICE);
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
} finally {
|
||||
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
|
||||
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('filters out dismissed notices', async () => {
|
||||
SYSTEM_NOTICES.push(TEST_NOTICE);
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
|
||||
|
||||
// Dismiss the notice directly in DB
|
||||
testDb.prepare(
|
||||
'INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, ?, ?)'
|
||||
).run(user.id, TEST_NOTICE.id, Date.now());
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
// TEST_NOTICE should be filtered out; welcome-v1 may still appear
|
||||
const found = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
|
||||
expect(found).toBeUndefined();
|
||||
} finally {
|
||||
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
|
||||
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// POST /api/system-notices/:id/dismiss
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/system-notices/:id/dismiss', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/system-notices/test-id/dismiss');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown notice id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/system-notices/nonexistent-id/dismiss')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('NOTICE_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('returns 204 for valid notice id', async () => {
|
||||
SYSTEM_NOTICES.push(TEST_NOTICE);
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(204);
|
||||
} finally {
|
||||
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
|
||||
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('is idempotent — second dismiss also returns 204', async () => {
|
||||
SYSTEM_NOTICES.push(TEST_NOTICE);
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
const first = await request(app)
|
||||
.post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(first.status).toBe(204);
|
||||
|
||||
const second = await request(app)
|
||||
.post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(second.status).toBe(204);
|
||||
} finally {
|
||||
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
|
||||
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('dismiss appears in GET /active as filtered out', async () => {
|
||||
SYSTEM_NOTICES.push(TEST_NOTICE);
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id);
|
||||
|
||||
// Confirm TEST_NOTICE is visible before dismiss
|
||||
const before = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(before.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeDefined();
|
||||
|
||||
// Dismiss it
|
||||
await request(app)
|
||||
.post(`/api/system-notices/${TEST_NOTICE.id}/dismiss`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
// Confirm TEST_NOTICE is gone; other notices (e.g. welcome-v1) may still appear
|
||||
const after = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(after.status).toBe(200);
|
||||
expect(after.body.find((n: { id: string }) => n.id === TEST_NOTICE.id)).toBeUndefined();
|
||||
} finally {
|
||||
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
|
||||
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user