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); }