Fix admin publish data loss for unreleased lessons and harden data persistence

This commit is contained in:
nmemmert
2026-05-21 12:39:28 -04:00
parent da5f77c3c6
commit bd047b029c
4 changed files with 83 additions and 12 deletions
+1
View File
@@ -7,6 +7,7 @@ services:
- "4173:4173"
environment:
- PORT=4173
- SITEFORGE_DATA_DIR=/app/data
- ADMIN_PASSWORD=`generate a random password and set it here`
- RESEND_API_KEY=`set your Resend API key here`
volumes:
+2 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -e
DATA_DIR=/app/data
DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data}
SEED_DIR=/app/data-seed
# Ensure the data directory exists (in case the volume was not mounted)
@@ -210,4 +210,5 @@ NODE
merge_seed_study_release_dates "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json"
merge_seed_study_release_dates "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json"
export SITEFORGE_DATA_DIR="$DATA_DIR"
exec node server.js
+63 -5
View File
@@ -46,7 +46,11 @@ import {
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const DATA_DIR = path.join(__dirname, 'data')
const DEFAULT_DATA_DIR = path.join(__dirname, 'data')
const configuredDataDir = typeof process.env.SITEFORGE_DATA_DIR === 'string' ? process.env.SITEFORGE_DATA_DIR.trim() : ''
const DATA_DIR = configuredDataDir
? (path.isAbsolute(configuredDataDir) ? configuredDataDir : path.resolve(__dirname, configuredDataDir))
: DEFAULT_DATA_DIR
const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
const DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.json')
const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
@@ -386,6 +390,51 @@ async function loadSiteContentFile(filePath) {
}
}
async function checkDataDirWritable() {
try {
await mkdir(DATA_DIR, { recursive: true })
const marker = path.join(DATA_DIR, `.write-test-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`)
await writeFile(marker, 'ok', 'utf8')
await unlink(marker)
return { ok: true, error: null }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Unknown write test error' }
}
}
async function getStorageStatus() {
const writable = await checkDataDirWritable()
const files = {}
for (const [key, filePath] of Object.entries({
adminContent: DATA_FILE,
adminContentDraft: DRAFT_DATA_FILE,
studyUsers: STUDY_USERS_FILE,
})) {
try {
const fileStat = await stat(filePath)
files[key] = {
path: filePath,
exists: true,
sizeBytes: fileStat.size,
mtime: fileStat.mtime.toISOString(),
}
} catch {
files[key] = {
path: filePath,
exists: false,
sizeBytes: 0,
mtime: null,
}
}
}
return {
dataDir: DATA_DIR,
writable,
files,
}
}
async function refreshContentCaches() {
try {
const published = await loadSiteContentFile(DATA_FILE)
@@ -1520,6 +1569,11 @@ app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
})
})
app.get('/api/admin-storage-status', requireAdminAuth, async (_req, res) => {
const status = await getStorageStatus()
res.json(status)
})
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
res.json({ checklist: podcastChecklist })
})
@@ -1583,8 +1637,10 @@ app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => {
publishState.draftUpdatedAt = updatedAt
res.json({ ok: true, updatedAt })
} catch {
res.status(500).json({ message: 'Failed to persist admin draft content.' })
} catch (err) {
console.error('[admin-content-draft] persist error:', err)
const reason = err instanceof Error ? err.message : 'Unknown write error'
res.status(500).json({ message: `Failed to persist admin draft content to ${DATA_DIR}: ${reason}` })
}
})
@@ -1619,8 +1675,10 @@ app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
await createBackupSnapshot('post-publish')
res.json({ ok: true, publishedAt })
} catch {
res.status(500).json({ message: 'Failed to publish draft content.' })
} catch (err) {
console.error('[admin-content-publish] persist error:', err)
const reason = err instanceof Error ? err.message : 'Unknown write error'
res.status(500).json({ message: `Failed to publish draft content to ${DATA_DIR}: ${reason}` })
}
})
+17 -6
View File
@@ -691,11 +691,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
async function reloadContentFromServer() {
const r = await fetch('/api/admin-content')
if (!r.ok) return
const data = await r.json() as { siteContent?: Partial<SiteContent> }
if (data?.siteContent && typeof data.siteContent === 'object') {
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...data.siteContent })
const draftRes = await fetch('/api/admin-content?source=draft')
if (draftRes.ok) {
const draftData = await draftRes.json() as { siteContent?: Partial<SiteContent> }
if (draftData?.siteContent && typeof draftData.siteContent === 'object') {
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...draftData.siteContent })
setForm(next)
onSave(next)
return
}
}
const publishedRes = await fetch('/api/admin-content')
if (!publishedRes.ok) return
const publishedData = await publishedRes.json() as { siteContent?: Partial<SiteContent> }
if (publishedData?.siteContent && typeof publishedData.siteContent === 'object') {
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...publishedData.siteContent })
setForm(next)
onSave(next)
}
@@ -771,7 +782,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
const published = await publishRes.json() as { publishedAt?: string }
const latestRes = await fetch('/api/admin-content')
const latestRes = await fetch('/api/admin-content?source=draft')
if (!latestRes.ok) throw new Error('Failed to refresh published content')
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
if (latest?.siteContent) {