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.
This commit is contained in:
Maurice
2026-03-19 16:31:27 +01:00
parent cd36fba0c9
commit 45d410c1b0
7 changed files with 120 additions and 25 deletions

View File

@@ -122,6 +122,7 @@ export const adminApi = {
updateUser: (id, data) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data), updateUser: (id, data) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data), deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
stats: () => apiClient.get('/admin/stats').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 = { export const mapsApi = {

View File

@@ -12,6 +12,7 @@ import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, Ey
import CustomSelect from '../components/shared/CustomSelect' import CustomSelect from '../components/shared/CustomSelect'
export default function AdminPage() { export default function AdminPage() {
const { demoMode } = useAuthStore()
const { t } = useTranslation() const { t } = useTranslation()
const TABS = [ const TABS = [
{ id: 'users', label: t('admin.tabs.users') }, { id: 'users', label: t('admin.tabs.users') },
@@ -208,6 +209,29 @@ export default function AdminPage() {
</div> </div>
</div> </div>
{/* Demo Baseline Button */}
{demoMode && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-amber-900">Demo Baseline</p>
<p className="text-xs text-amber-700">Save current state as the hourly reset point. All admin trips and settings will be preserved.</p>
</div>
<button
onClick={async () => {
try {
await adminApi.saveDemoBaseline()
toast.success('Baseline saved! Resets will restore to this state.')
} catch (e) {
toast.error(e.response?.data?.error || 'Failed to save baseline')
}
}}
className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-semibold hover:bg-amber-700 transition-colors flex-shrink-0 ml-4"
>
Save Baseline
</button>
</div>
)}
{/* Stats */} {/* Stats */}
{stats && ( {stats && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">

View File

@@ -1,6 +1,6 @@
{ {
"name": "nomad-server", "name": "nomad-server",
"version": "2.2.8", "version": "2.3.0",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node --experimental-sqlite src/index.js", "start": "node --experimental-sqlite src/index.js",

View File

@@ -1,34 +1,84 @@
function resetDemoUser(db) { const fs = require('fs');
const DEMO_EMAIL = 'demo@nomad.app'; const path = require('path');
const demo = db.prepare('SELECT id FROM users WHERE email = ?').get(DEMO_EMAIL); const dataDir = path.join(__dirname, '../../data');
if (!demo) { const dbPath = path.join(dataDir, 'travel.db');
console.log('[Demo Reset] Demo user not found, skipping'); 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; 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) // Save admin's current credentials and API keys (these should survive the reset)
const demoTrips = db.prepare('SELECT id FROM trips WHERE user_id = ?').all(demoId); const adminEmail = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app';
for (const trip of demoTrips) { let adminData = null;
// CASCADE handles days, places, assignments, packing, budget, reservations, photos, files, day_notes try {
db.prepare('DELETE FROM trips WHERE id = ?').run(trip.id); 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 // Flush WAL to main DB file
db.prepare('DELETE FROM categories WHERE user_id = ?').run(demoId); try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
db.prepare('DELETE FROM tags WHERE user_id = ?').run(demoId);
// Reset demo user's settings // Close DB connection
db.prepare('DELETE FROM settings WHERE user_id = ?').run(demoId); closeDb();
const deletedCount = demoTrips.length; // Restore baseline
if (deletedCount > 0) { try {
console.log(`[Demo Reset] Cleaned ${deletedCount} trips from demo user`); fs.copyFileSync(baselinePath, dbPath);
} else { // Remove WAL/SHM files if they exist (stale from old connection)
console.log('[Demo Reset] No demo user trips to clean'); 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 };

View File

@@ -42,6 +42,13 @@ function seedDemoData(db) {
console.log('[Demo] Seeding example trips...'); console.log('[Demo] Seeding example trips...');
seedExampleTrips(db, admin.id, demo.id); 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 }; return { adminId: admin.id, demoId: demo.id };
} }

View File

@@ -110,4 +110,18 @@ router.get('/stats', (req, res) => {
res.json({ totalUsers, totalTrips, totalPlaces, totalPhotos, totalFiles }); 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; module.exports = router;

View File

@@ -111,9 +111,8 @@ function startDemoReset() {
demoTask = cron.schedule('0 * * * *', () => { demoTask = cron.schedule('0 * * * *', () => {
try { try {
const { db } = require('./db/database');
const { resetDemoUser } = require('./demo/demo-reset'); const { resetDemoUser } = require('./demo/demo-reset');
resetDemoUser(db); resetDemoUser();
} catch (err) { } catch (err) {
console.error('[Demo Reset] Error:', err.message); console.error('[Demo Reset] Error:', err.message);
} }