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:
@@ -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 = {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user