Fix admin publish data loss for unreleased lessons and harden data persistence
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user