From 45d410c1b001dfae4f0d759bd2bea14aba03492a Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 19 Mar 2026 16:31:27 +0100 Subject: [PATCH] Demo baseline reset: full DB snapshot/restore (v2.3.0) Hourly reset now restores entire DB from baseline snapshot instead of just deleting demo trips. This reverts ALL demo user changes including modifications to shared admin trips. Admin credentials (password, API keys) are preserved across resets. Admin can save new baseline via Admin Panel button. Removed demoWriteBlock middleware. --- client/src/api/client.js | 1 + client/src/pages/AdminPage.jsx | 24 +++++++++ server/package.json | 2 +- server/src/demo/demo-reset.js | 94 ++++++++++++++++++++++++++-------- server/src/demo/demo-seed.js | 7 +++ server/src/routes/admin.js | 14 +++++ server/src/scheduler.js | 3 +- 7 files changed, 120 insertions(+), 25 deletions(-) diff --git a/client/src/api/client.js b/client/src/api/client.js index 991a851..79a8ad7 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -122,6 +122,7 @@ export const adminApi = { updateUser: (id, data) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data), deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data), stats: () => apiClient.get('/admin/stats').then(r => r.data), + saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data), } export const mapsApi = { diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index 33a8a59..ceeecfa 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -12,6 +12,7 @@ import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, Ey import CustomSelect from '../components/shared/CustomSelect' export default function AdminPage() { + const { demoMode } = useAuthStore() const { t } = useTranslation() const TABS = [ { id: 'users', label: t('admin.tabs.users') }, @@ -208,6 +209,29 @@ export default function AdminPage() { + {/* Demo Baseline Button */} + {demoMode && ( +
+
+

Demo Baseline

+

Save current state as the hourly reset point. All admin trips and settings will be preserved.

+
+ +
+ )} + {/* Stats */} {stats && (
diff --git a/server/package.json b/server/package.json index 9817990..829d77b 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "nomad-server", - "version": "2.2.8", + "version": "2.3.0", "main": "src/index.js", "scripts": { "start": "node --experimental-sqlite src/index.js", diff --git a/server/src/demo/demo-reset.js b/server/src/demo/demo-reset.js index 9043953..eca69d3 100644 --- a/server/src/demo/demo-reset.js +++ b/server/src/demo/demo-reset.js @@ -1,34 +1,84 @@ -function resetDemoUser(db) { - const DEMO_EMAIL = 'demo@nomad.app'; +const fs = require('fs'); +const path = require('path'); - const demo = db.prepare('SELECT id FROM users WHERE email = ?').get(DEMO_EMAIL); - if (!demo) { - console.log('[Demo Reset] Demo user not found, skipping'); +const dataDir = path.join(__dirname, '../../data'); +const dbPath = path.join(dataDir, 'travel.db'); +const baselinePath = path.join(dataDir, 'travel-baseline.db'); + +function resetDemoUser() { + if (!fs.existsSync(baselinePath)) { + console.log('[Demo Reset] No baseline found, skipping. Admin must save baseline first.'); return; } - const demoId = Number(demo.id); + const { db, closeDb, reinitialize } = require('../db/database'); - // Delete all trips OWNED by demo user (shared trips from admin stay) - const demoTrips = db.prepare('SELECT id FROM trips WHERE user_id = ?').all(demoId); - for (const trip of demoTrips) { - // CASCADE handles days, places, assignments, packing, budget, reservations, photos, files, day_notes - db.prepare('DELETE FROM trips WHERE id = ?').run(trip.id); + // Save admin's current credentials and API keys (these should survive the reset) + const adminEmail = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app'; + let adminData = null; + try { + adminData = db.prepare( + 'SELECT password_hash, maps_api_key, openweather_api_key, unsplash_api_key, avatar FROM users WHERE email = ?' + ).get(adminEmail); + } catch (e) { + console.error('[Demo Reset] Failed to read admin data:', e.message); } - // Delete demo user's custom categories and tags - db.prepare('DELETE FROM categories WHERE user_id = ?').run(demoId); - db.prepare('DELETE FROM tags WHERE user_id = ?').run(demoId); + // Flush WAL to main DB file + try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} - // Reset demo user's settings - db.prepare('DELETE FROM settings WHERE user_id = ?').run(demoId); + // Close DB connection + closeDb(); - const deletedCount = demoTrips.length; - if (deletedCount > 0) { - console.log(`[Demo Reset] Cleaned ${deletedCount} trips from demo user`); - } else { - console.log('[Demo Reset] No demo user trips to clean'); + // Restore baseline + try { + fs.copyFileSync(baselinePath, dbPath); + // Remove WAL/SHM files if they exist (stale from old connection) + try { fs.unlinkSync(dbPath + '-wal'); } catch (e) {} + try { fs.unlinkSync(dbPath + '-shm'); } catch (e) {} + } catch (e) { + console.error('[Demo Reset] Failed to restore baseline:', e.message); + reinitialize(); + return; } + + // Reinitialize DB connection with restored baseline + reinitialize(); + + // Restore admin's latest credentials (in case admin changed password/API keys after baseline was saved) + if (adminData) { + try { + const { db: freshDb } = require('../db/database'); + freshDb.prepare( + 'UPDATE users SET password_hash = ?, maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE email = ?' + ).run( + adminData.password_hash, + adminData.maps_api_key, + adminData.openweather_api_key, + adminData.unsplash_api_key, + adminData.avatar, + adminEmail + ); + } catch (e) { + console.error('[Demo Reset] Failed to restore admin credentials:', e.message); + } + } + + console.log('[Demo Reset] Database restored from baseline'); } -module.exports = { resetDemoUser }; +function saveBaseline() { + const { db } = require('../db/database'); + + // Flush WAL so baseline file is self-contained + try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} + + fs.copyFileSync(dbPath, baselinePath); + console.log('[Demo] Baseline saved'); +} + +function hasBaseline() { + return fs.existsSync(baselinePath); +} + +module.exports = { resetDemoUser, saveBaseline, hasBaseline }; diff --git a/server/src/demo/demo-seed.js b/server/src/demo/demo-seed.js index 9d1bc2f..af3f0fe 100644 --- a/server/src/demo/demo-seed.js +++ b/server/src/demo/demo-seed.js @@ -42,6 +42,13 @@ function seedDemoData(db) { console.log('[Demo] Seeding example trips...'); seedExampleTrips(db, admin.id, demo.id); + + // Auto-save baseline after first seed + const { saveBaseline, hasBaseline } = require('./demo-reset'); + if (!hasBaseline()) { + saveBaseline(); + } + return { adminId: admin.id, demoId: demo.id }; } diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 682847d..d74c2ff 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -110,4 +110,18 @@ router.get('/stats', (req, res) => { res.json({ totalUsers, totalTrips, totalPlaces, totalPhotos, totalFiles }); }); +// POST /api/admin/save-demo-baseline (demo mode only) +router.post('/save-demo-baseline', (req, res) => { + if (process.env.DEMO_MODE !== 'true') { + return res.status(404).json({ error: 'Not found' }); + } + try { + const { saveBaseline } = require('../demo/demo-reset'); + saveBaseline(); + res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' }); + } catch (err) { + res.status(500).json({ error: 'Failed to save baseline: ' + err.message }); + } +}); + module.exports = router; diff --git a/server/src/scheduler.js b/server/src/scheduler.js index 13ade96..18565e0 100644 --- a/server/src/scheduler.js +++ b/server/src/scheduler.js @@ -111,9 +111,8 @@ function startDemoReset() { demoTask = cron.schedule('0 * * * *', () => { try { - const { db } = require('./db/database'); const { resetDemoUser } = require('./demo/demo-reset'); - resetDemoUser(db); + resetDemoUser(); } catch (err) { console.error('[Demo Reset] Error:', err.message); }