Add admin backup and restore for all config files

Download button exports item-overrides, delivery-rates, categories-display,
occasions, hours, and vinyl-config as a single JSON file. Restore button
applies a previously downloaded backup (skips vinyl-config to avoid
overwriting it). Both accessible from the admin header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-04-29 23:09:34 -04:00
parent 1dc8a087b6
commit be7f98a347
2 changed files with 128 additions and 0 deletions

View File

@ -1514,6 +1514,9 @@ export default function AdminPage() {
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [refreshMsg, setRefreshMsg] = useState('')
const [restoring, setRestoring] = useState(false)
const [restoreMsg, setRestoreMsg] = useState('')
const restoreInputRef = useRef<HTMLInputElement>(null)
// Category creation state (for the Categories tab)
const [newCatName, setNewCatName] = useState('')
@ -1590,6 +1593,43 @@ export default function AdminPage() {
setCatMessage(cat.id ? `Created: ${cat.name}` : 'Failed to create category')
}
function handleBackup() {
window.location.href = BASE + '/api/admin/backup'
}
async function handleRestore(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (!confirm('This will overwrite your current item overrides, delivery rates, hours, occasions, and category settings. Continue?')) {
if (restoreInputRef.current) restoreInputRef.current.value = ''
return
}
setRestoring(true)
setRestoreMsg('')
try {
const text = await file.text()
const bundle = JSON.parse(text)
const res = await fetch(BASE + '/api/admin/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bundle),
})
const data = await res.json()
if (res.ok) {
setRestoreMsg(`Restored: ${data.restored.join(', ')}`)
load()
} else {
setRestoreMsg(`Error: ${data.error}`)
}
} catch {
setRestoreMsg('Invalid backup file')
} finally {
setRestoring(false)
if (restoreInputRef.current) restoreInputRef.current.value = ''
}
setTimeout(() => setRestoreMsg(''), 5000)
}
function handleSaved(id: string, patch: Partial<ItemOverride>) {
setItems((prev) =>
prev.map((item) =>
@ -1630,6 +1670,21 @@ export default function AdminPage() {
<button className="button is-small" onClick={load} title="Reload page data">
<i className="fas fa-sync" />
</button>
<button className="button is-small" onClick={handleBackup} title="Download config backup">
<span className="icon is-small"><i className="fas fa-download" /></span>
<span>Backup</span>
</button>
<label className={`button is-small${restoring ? ' is-loading' : ''}`} title="Restore config from backup file" style={{ cursor: 'pointer' }}>
<span className="icon is-small"><i className="fas fa-upload" /></span>
<span>Restore</span>
<input
ref={restoreInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleRestore}
/>
</label>
<button className="button is-small" onClick={handleLogout}>Sign out</button>
</div>
{refreshMsg && (
@ -1637,6 +1692,11 @@ export default function AdminPage() {
{refreshMsg}
</p>
)}
{restoreMsg && (
<p className={`is-size-7 ${restoreMsg.startsWith('Error') || restoreMsg === 'Invalid backup file' ? 'has-text-danger' : 'has-text-success'}`}>
{restoreMsg}
</p>
)}
{fetchedAt && !refreshMsg && (
<p className="is-size-7 has-text-grey">
Cache: {new Date(fetchedAt).toLocaleString()}

View File

@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import { readFileSync, existsSync, writeFileSync } from 'fs'
import path from 'path'
export const dynamic = 'force-dynamic'
const DATA = path.join(process.cwd(), 'data')
const FILES = [
'item-overrides',
'delivery-rates',
'categories-display',
'occasions',
'hours',
'vinyl-config',
] as const
type FileKey = typeof FILES[number]
function readFile(key: FileKey): unknown {
const p = path.join(DATA, `${key}.json`)
if (!existsSync(p)) return null
try { return JSON.parse(readFileSync(p, 'utf-8')) } catch { return null }
}
/** GET — download a JSON bundle of all config files */
export async function GET() {
const bundle: Record<string, unknown> = {
version: 1,
exportedAt: new Date().toISOString(),
}
for (const key of FILES) {
bundle[key] = readFile(key)
}
return new NextResponse(JSON.stringify(bundle, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="bpb-config-${new Date().toISOString().slice(0, 10)}.json"`,
},
})
}
/** POST — restore from a previously downloaded bundle */
export async function POST(req: NextRequest) {
let bundle: Record<string, unknown>
try {
bundle = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const restored: string[] = []
const skipped: string[] = []
for (const key of FILES) {
if (key === 'vinyl-config') continue // never overwrite vinyl-config from restore
const value = bundle[key]
if (value == null) { skipped.push(key); continue }
try {
writeFileSync(path.join(DATA, `${key}.json`), JSON.stringify(value, null, 2), 'utf-8')
restored.push(key)
} catch (err) {
return NextResponse.json({ error: `Failed to write ${key}: ${String(err)}` }, { status: 500 })
}
}
return NextResponse.json({ ok: true, restored, skipped })
}