Add admin podcast checklist UX and persistence updates

This commit is contained in:
nmemmert
2026-05-18 08:26:53 -04:00
parent dba5f16bcd
commit db762c5a05
2 changed files with 606 additions and 2 deletions
+171 -1
View File
@@ -57,6 +57,7 @@ const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json')
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
@@ -208,6 +209,122 @@ const DEFAULT_PUBLISH_STATE = {
publishedAt: null,
}
const DEFAULT_PODCAST_CHECKLIST_TASKS = [
{ id: 'verify_script', label: 'Verify Script', phase: 'pre' },
{ id: 'read_script', label: 'Read Script', phase: 'pre' },
{ id: 'record', label: 'Record', phase: 'pre' },
{ id: 'edit', label: 'Edit', phase: 'pre' },
{ id: 'mix', label: 'Mix', phase: 'pre' },
{ id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' },
{ id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' },
{ id: 'update_website', label: 'Update Website', phase: 'post' },
{ id: 'send_email', label: 'Send Email', phase: 'post' },
]
function buildChecklistEpisode(series, number) {
const tasks = {}
for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) {
tasks[task.id] = false
}
return {
id: `${series.toLowerCase()}-${number}`,
series,
episodeNumber: number,
title: '',
datePublished: '',
expanded: false,
tasks,
}
}
function buildDefaultPodcastChecklist() {
const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number))
const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1))
return {
tasks: DEFAULT_PODCAST_CHECKLIST_TASKS,
episodes: [...titusEpisodes, ...colossiansEpisodes],
}
}
function sanitizeChecklistTask(task) {
const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : ''
if (!label) return null
const phase = task?.phase === 'post' ? 'post' : 'pre'
const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID()
return { id, label, phase }
}
function sanitizePodcastChecklist(value) {
const fallback = buildDefaultPodcastChecklist()
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks
const seenTaskIds = new Set()
const tasks = []
for (const item of taskInput) {
const safeTask = sanitizeChecklistTask(item)
if (!safeTask) continue
if (seenTaskIds.has(safeTask.id)) continue
seenTaskIds.add(safeTask.id)
tasks.push(safeTask)
}
if (tasks.length === 0) {
for (const task of fallback.tasks) {
tasks.push({ ...task })
seenTaskIds.add(task.id)
}
}
const taskIds = tasks.map(task => task.id)
const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes
const episodes = []
for (const item of episodesInput) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID()
const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : ''
const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : ''
const rawEpisodeNumber = Number(item.episodeNumber)
const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0
? Math.round(rawEpisodeNumber)
: null
const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim())
? item.datePublished.trim()
: ''
const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks)
? item.tasks
: {}
const taskState = {}
for (const taskId of taskIds) {
taskState[taskId] = sourceTasks[taskId] === true
}
episodes.push({
id,
series,
episodeNumber,
title,
datePublished,
expanded: item.expanded === true,
tasks: taskState,
})
}
if (episodes.length === 0) {
return fallback
}
return { tasks, episodes }
}
const DEFAULT_REPLY_TEMPLATES = [
{
id: 'thanks-for-reaching-out',
@@ -238,6 +355,8 @@ let replyTemplates = [...DEFAULT_REPLY_TEMPLATES]
let replyTemplatesWritePromise = Promise.resolve()
let replyHistory = []
let replyHistoryWritePromise = Promise.resolve()
let podcastChecklist = buildDefaultPodcastChecklist()
let podcastChecklistWritePromise = Promise.resolve()
async function loadSiteContentFile(filePath) {
const raw = await readFile(filePath, 'utf8')
@@ -620,6 +739,31 @@ function loadReplyHistoryFromDisk() {
})
}
function loadPodcastChecklistFromDisk() {
return readFile(PODCAST_CHECKLIST_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
podcastChecklist = sanitizePodcastChecklist(parsed?.checklist)
})
.catch(() => {
podcastChecklist = buildDefaultPodcastChecklist()
})
}
function queuePodcastChecklistWrite() {
const updatedAt = new Date().toISOString()
podcastChecklistWritePromise = podcastChecklistWritePromise.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
PODCAST_CHECKLIST_FILE,
JSON.stringify({ checklist: podcastChecklist, updatedAt }, null, 2),
'utf8',
)
})
return podcastChecklistWritePromise
}
function normalizeMessageType(value) {
if (value === 'question' || value === 'testimony' || value === 'topic') return value
return 'general'
@@ -1027,6 +1171,7 @@ async function createBackupSnapshot(reason = 'scheduled') {
reason,
adminContent: null,
draftContent: null,
podcastChecklist,
publishState,
hitStats,
visitorStats,
@@ -1187,14 +1332,23 @@ async function restoreFromBackup(filename) {
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
queueHitStatsWrite()
queueVisitorStatsWrite()
queueContactSubmissionsWrite()
queueReplyTemplatesWrite()
queueReplyHistoryWrite()
queuePodcastChecklistWrite()
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise, replyTemplatesWritePromise, replyHistoryWritePromise])
await Promise.all([
hitStatsWritePromise,
visitorStatsWritePromise,
contactSubmissionsWritePromise,
replyTemplatesWritePromise,
replyHistoryWritePromise,
podcastChecklistWritePromise,
])
await refreshContentCaches()
await createBackupSnapshot('post-restore')
}
@@ -1346,6 +1500,21 @@ app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
})
})
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
res.json({ checklist: podcastChecklist })
})
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
try {
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
podcastChecklist = safeChecklist
await queuePodcastChecklistWrite()
res.json({ ok: true, checklist: safeChecklist })
} catch {
res.status(500).json({ message: 'Failed to save podcast checklist.' })
}
})
app.get('/api/site-config', async (_req, res) => {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
@@ -3447,6 +3616,7 @@ Promise.all([
loadStudyUsersFromDisk(),
loadStudyNotesFromDisk(),
loadDownloadCountsFromDisk(),
loadPodcastChecklistFromDisk(),
refreshContentCaches(),
])
.catch(err => {