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),
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 = {

View File

@@ -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() {
</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 && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">

View File

@@ -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",

View File

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

View File

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

View File

@@ -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;

View File

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