Publish Container / docker (push) Successful in 14m20s
One scheduled date/time in the Current Series admin panel archives the active series to Finished Books and atomically swaps in new content for the series card, Start Here steps, and Companion Study Guide. Server checks every hour via setInterval; also runs on restart so the transition fires even if the server was down at the scheduled moment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
438 lines
17 KiB
JavaScript
438 lines
17 KiB
JavaScript
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
import { sanitizeSiteContent } from '../helpers.js'
|
|
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
|
|
import {
|
|
DATA_DIR,
|
|
DATA_FILE,
|
|
DRAFT_DATA_FILE,
|
|
QUESTIONS_FILE,
|
|
DEFAULT_SEO,
|
|
DEFAULT_LEGAL,
|
|
DEFAULT_REDIRECT_RULES,
|
|
DEFAULT_PODCAST_FEATURED_LINKS,
|
|
MAX_QUESTIONS,
|
|
EMPTY_HIT_STATS,
|
|
EMPTY_VISITOR_STATS,
|
|
} from '../config.js'
|
|
import { state } from '../state.js'
|
|
import {
|
|
loadSiteContentFile,
|
|
getStorageStatus,
|
|
refreshContentCaches,
|
|
queueHitStatsWrite,
|
|
queueVisitorStatsWrite,
|
|
queueContactSubmissionsWrite,
|
|
queueReplyTemplatesWrite,
|
|
queueReplyHistoryWrite,
|
|
queuePodcastChecklistWrite,
|
|
queueWebhooksWrite,
|
|
createBackupSnapshot,
|
|
listBackupPreviews,
|
|
readBackupPreview,
|
|
restoreFromBackup,
|
|
sanitizePodcastChecklist,
|
|
} from '../data.js'
|
|
import {
|
|
pruneStatsByDays,
|
|
filterSiteContentByReleaseDate,
|
|
} from '../study-helpers.js'
|
|
|
|
function invokeWebhook(url, action) {
|
|
if (!url) {
|
|
return Promise.resolve({ ok: false, message: `${action} webhook URL is not configured.` })
|
|
}
|
|
return fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action, at: new Date().toISOString(), source: 'siteforge-admin' }),
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) return { ok: false, message: `${action} webhook failed with ${response.status}.` }
|
|
return { ok: true, message: `${action} webhook triggered.` }
|
|
})
|
|
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
|
}
|
|
|
|
// Applies a scheduled series transition if its triggersAt time has passed.
|
|
// Called on a server interval (see server.js). Safe to call if no transition is set.
|
|
export async function runScheduledSeriesTransition() {
|
|
const content = state.cachedSiteContent
|
|
if (!content) return
|
|
|
|
const sched = content.scheduledSeriesTransition
|
|
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) return
|
|
if (new Date(sched.triggersAt) > new Date()) return
|
|
|
|
console.log('[series-scheduler] Firing scheduled series transition to:', sched.newTitle)
|
|
|
|
const archivedBook = {
|
|
id: Date.now().toString(36),
|
|
title: content.seriesTitle ?? '',
|
|
description: content.seriesDescription ?? '',
|
|
imageUrl: content.seriesImageUrl ?? '',
|
|
season: (sched.newSeason ?? 2) - 1 || 1,
|
|
}
|
|
|
|
const startHerePatch = {}
|
|
if (sched.newStartHereHeading?.trim()) startHerePatch.startHereHeading = sched.newStartHereHeading.trim()
|
|
if (sched.newStartHereIntro?.trim()) startHerePatch.startHereIntro = sched.newStartHereIntro.trim()
|
|
if (sched.newStartHereStep1Title?.trim()) startHerePatch.startHereStep1Title = sched.newStartHereStep1Title.trim()
|
|
if (sched.newStartHereStep1Body?.trim()) startHerePatch.startHereStep1Body = sched.newStartHereStep1Body.trim()
|
|
if (sched.newStartHereStep1Cta?.trim()) startHerePatch.startHereStep1Cta = sched.newStartHereStep1Cta.trim()
|
|
if (sched.newStartHereStep2Title?.trim()) startHerePatch.startHereStep2Title = sched.newStartHereStep2Title.trim()
|
|
if (sched.newStartHereStep2Body?.trim()) startHerePatch.startHereStep2Body = sched.newStartHereStep2Body.trim()
|
|
if (sched.newStartHereStep2Cta?.trim()) startHerePatch.startHereStep2Cta = sched.newStartHereStep2Cta.trim()
|
|
if (sched.newStartHereStep3Title?.trim()) startHerePatch.startHereStep3Title = sched.newStartHereStep3Title.trim()
|
|
if (sched.newStartHereStep3Body?.trim()) startHerePatch.startHereStep3Body = sched.newStartHereStep3Body.trim()
|
|
if (sched.newStartHereStep3Cta?.trim()) startHerePatch.startHereStep3Cta = sched.newStartHereStep3Cta.trim()
|
|
|
|
const studyGuidePatch = {}
|
|
if (sched.newStudyGuideTitle?.trim()) studyGuidePatch.studyGuideTitle = sched.newStudyGuideTitle.trim()
|
|
if (sched.newStudyGuideDescription?.trim()) studyGuidePatch.studyGuideDescription = sched.newStudyGuideDescription.trim()
|
|
if (sched.newStudyGuideDownloadUrl?.trim()) studyGuidePatch.studyGuideDownloadUrl = sched.newStudyGuideDownloadUrl.trim()
|
|
if (sched.newStudyGuideUrl?.trim()) studyGuidePatch.studyGuideUrl = sched.newStudyGuideUrl.trim()
|
|
if (sched.newStudyGuideAmazonButtonLabel?.trim()) studyGuidePatch.studyGuideAmazonButtonLabel = sched.newStudyGuideAmazonButtonLabel.trim()
|
|
if (sched.newStudyGuideAppleBooksUrl?.trim()) studyGuidePatch.studyGuideAppleBooksUrl = sched.newStudyGuideAppleBooksUrl.trim()
|
|
if (sched.newStudyGuideAppleBooksLabel?.trim()) studyGuidePatch.studyGuideAppleBooksLabel = sched.newStudyGuideAppleBooksLabel.trim()
|
|
|
|
const updatedContent = sanitizeSiteContent({
|
|
...content,
|
|
seriesTitle: sched.newTitle.trim(),
|
|
seriesDescription: sched.newDescription?.trim() ?? '',
|
|
seriesImageUrl: sched.newImageUrl?.trim() ?? content.seriesImageUrl ?? '',
|
|
seriesLabel: 'Now Playing',
|
|
podcastFeaturedLinks: [],
|
|
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), archivedBook],
|
|
...startHerePatch,
|
|
...studyGuidePatch,
|
|
scheduledSeriesTransition: undefined,
|
|
})
|
|
|
|
const publishedAt = new Date().toISOString()
|
|
try {
|
|
await mkdir(DATA_DIR, { recursive: true })
|
|
await writeFile(DATA_FILE, JSON.stringify({ siteContent: updatedContent, updatedAt: publishedAt }, null, 2), 'utf8')
|
|
state.cachedSiteContent = updatedContent
|
|
state.publishState.publishedAt = publishedAt
|
|
console.log('[series-scheduler] Transition complete — archived:', archivedBook.title)
|
|
} catch (err) {
|
|
console.error('[series-scheduler] Failed to write transition:', err)
|
|
}
|
|
}
|
|
|
|
export function register(app) {
|
|
app.get('/api/admin-content', async (req, res) => {
|
|
const source = req.query?.source === 'draft' ? 'draft' : 'published'
|
|
if (source === 'draft' && !isValidAdminSession(req)) {
|
|
res.status(401).json({ message: 'Unauthorized' })
|
|
return
|
|
}
|
|
|
|
try {
|
|
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
|
if (source === 'published') {
|
|
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
|
|
res.json({ ...parsed, siteContent: safeSiteContent })
|
|
return
|
|
}
|
|
res.json(parsed)
|
|
} catch {
|
|
if (source === 'draft') {
|
|
res.status(404).json({ message: 'No saved draft content file yet.' })
|
|
return
|
|
}
|
|
res.status(404).json({ message: 'No saved admin content file yet.' })
|
|
}
|
|
})
|
|
|
|
app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
|
|
res.json({
|
|
publishState: state.publishState,
|
|
hasDraft: Boolean(state.cachedDraftSiteContent),
|
|
hasPublished: Boolean(state.cachedSiteContent),
|
|
})
|
|
})
|
|
|
|
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: state.podcastChecklist })
|
|
})
|
|
|
|
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
|
|
try {
|
|
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
|
|
state.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)
|
|
const siteContent = parsed.siteContent ?? {}
|
|
res.json({
|
|
seo: siteContent.seo ?? DEFAULT_SEO,
|
|
legal: siteContent.legal ?? DEFAULT_LEGAL,
|
|
redirects: siteContent.redirects ?? DEFAULT_REDIRECT_RULES,
|
|
podcastFeaturedLinks: siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS,
|
|
publishState: state.publishState,
|
|
updatedAt: parsed.updatedAt ?? null,
|
|
})
|
|
} catch {
|
|
res.json({
|
|
seo: DEFAULT_SEO,
|
|
legal: DEFAULT_LEGAL,
|
|
redirects: DEFAULT_REDIRECT_RULES,
|
|
podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS,
|
|
publishState: state.publishState,
|
|
updatedAt: null,
|
|
})
|
|
}
|
|
})
|
|
|
|
app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => {
|
|
try {
|
|
const { siteContent } = req.body ?? {}
|
|
|
|
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
|
|
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
|
|
return
|
|
}
|
|
|
|
const safeSiteContent = sanitizeSiteContent(siteContent)
|
|
const updatedAt = new Date().toISOString()
|
|
|
|
await mkdir(DATA_DIR, { recursive: true })
|
|
await writeFile(
|
|
DRAFT_DATA_FILE,
|
|
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
|
|
'utf8',
|
|
)
|
|
|
|
state.cachedDraftSiteContent = safeSiteContent
|
|
state.publishState.draftUpdatedAt = updatedAt
|
|
|
|
res.json({ ok: true, updatedAt })
|
|
} 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}` })
|
|
}
|
|
})
|
|
|
|
app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
|
|
try {
|
|
const source = state.cachedDraftSiteContent
|
|
? { siteContent: state.cachedDraftSiteContent, updatedAt: state.publishState.draftUpdatedAt ?? new Date().toISOString() }
|
|
: await loadSiteContentFile(DRAFT_DATA_FILE)
|
|
|
|
const publishedAt = new Date().toISOString()
|
|
|
|
await mkdir(DATA_DIR, { recursive: true })
|
|
await writeFile(
|
|
DATA_FILE,
|
|
JSON.stringify({ siteContent: source.siteContent, updatedAt: publishedAt }, null, 2),
|
|
'utf8',
|
|
)
|
|
|
|
state.cachedSiteContent = source.siteContent
|
|
state.publishState.publishedAt = publishedAt
|
|
|
|
if (state.draftQuestions !== null) {
|
|
state.questions = state.draftQuestions.slice(0, MAX_QUESTIONS)
|
|
await mkdir(DATA_DIR, { recursive: true })
|
|
await writeFile(
|
|
QUESTIONS_FILE,
|
|
JSON.stringify({ questions: state.questions, updatedAt: publishedAt }, null, 2),
|
|
'utf8',
|
|
)
|
|
}
|
|
|
|
await createBackupSnapshot('post-publish')
|
|
res.json({ ok: true, publishedAt })
|
|
} 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}` })
|
|
}
|
|
})
|
|
|
|
app.put('/api/admin-content', requireAdminAuth, async (req, res) => {
|
|
try {
|
|
const { siteContent } = req.body ?? {}
|
|
|
|
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
|
|
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
|
|
return
|
|
}
|
|
|
|
const safeSiteContent = sanitizeSiteContent(siteContent)
|
|
const updatedAt = new Date().toISOString()
|
|
|
|
await mkdir(DATA_DIR, { recursive: true })
|
|
await writeFile(
|
|
DATA_FILE,
|
|
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
|
|
'utf8',
|
|
)
|
|
|
|
state.cachedSiteContent = safeSiteContent
|
|
state.publishState.publishedAt = updatedAt
|
|
|
|
res.json({ ok: true })
|
|
} catch {
|
|
res.status(500).json({ message: 'Failed to persist admin content.' })
|
|
}
|
|
})
|
|
|
|
app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
|
|
res.json({
|
|
buildCommit: process.env.BUILD_COMMIT ?? process.env.COMMIT_SHA ?? null,
|
|
buildNumber: process.env.BUILD_NUMBER ?? null,
|
|
deployedAt: process.env.DEPLOYED_AT ?? state.serverStartedAt,
|
|
cachePurge: state.lastCachePurgeStatus,
|
|
deployHook: state.lastDeployHookStatus,
|
|
})
|
|
})
|
|
|
|
app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => {
|
|
const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge')
|
|
state.lastCachePurgeStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
|
|
if (!result.ok) {
|
|
res.status(400).json({ message: result.message })
|
|
return
|
|
}
|
|
res.json({ ok: true, message: result.message })
|
|
})
|
|
|
|
app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
|
|
const result = await invokeWebhook(process.env.DEPLOY_WEBHOOK_URL ?? '', 'deploy')
|
|
state.lastDeployHookStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
|
|
if (!result.ok) {
|
|
res.status(400).json({ message: result.message })
|
|
return
|
|
}
|
|
res.json({ ok: true, message: result.message })
|
|
})
|
|
|
|
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
|
|
let adminContent = null
|
|
let draftContent = null
|
|
try {
|
|
const raw = await readFile(DATA_FILE, 'utf8')
|
|
adminContent = JSON.parse(raw)
|
|
} catch { adminContent = null }
|
|
|
|
try {
|
|
const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8')
|
|
draftContent = JSON.parse(rawDraft)
|
|
} catch { draftContent = null }
|
|
|
|
res.json({
|
|
exportedAt: new Date().toISOString(),
|
|
adminContent,
|
|
draftContent,
|
|
publishState: state.publishState,
|
|
hitStats: state.hitStats,
|
|
visitorStats: state.visitorStats,
|
|
contactSubmissions: state.contactSubmissions,
|
|
replyTemplates: state.replyTemplates,
|
|
replyHistory: state.replyHistory,
|
|
})
|
|
})
|
|
|
|
app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => {
|
|
state.hitStats = { ...EMPTY_HIT_STATS }
|
|
state.visitorStats = { ...EMPTY_VISITOR_STATS }
|
|
queueHitStatsWrite()
|
|
queueVisitorStatsWrite()
|
|
createBackupSnapshot('post-clear').catch(() => {})
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
app.post('/api/admin-stats/prune', requireAdminAuth, (req, res) => {
|
|
const result = pruneStatsByDays(req.body?.days)
|
|
queueHitStatsWrite()
|
|
queueVisitorStatsWrite()
|
|
createBackupSnapshot('post-prune').catch(() => {})
|
|
res.json({ ok: true, ...result })
|
|
})
|
|
|
|
app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => {
|
|
await createBackupSnapshot('manual')
|
|
res.json({ ok: true, backup: state.lastBackupStatus })
|
|
})
|
|
|
|
app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => {
|
|
try {
|
|
const backups = await listBackupPreviews()
|
|
res.json({ backups })
|
|
} catch {
|
|
res.status(500).json({ message: 'Could not list backups.' })
|
|
}
|
|
})
|
|
|
|
app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => {
|
|
try {
|
|
const { filename } = req.body ?? {}
|
|
const preview = await readBackupPreview(filename)
|
|
res.json({ preview })
|
|
} catch (err) {
|
|
res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' })
|
|
}
|
|
})
|
|
|
|
app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => {
|
|
try {
|
|
const { filename } = req.body ?? {}
|
|
await restoreFromBackup(filename)
|
|
const backups = await listBackupPreviews()
|
|
res.json({ ok: true, restored: filename, backups })
|
|
} catch (err) {
|
|
res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
|
|
}
|
|
})
|
|
|
|
// ── Webhooks ──────────────────────────────────────────────────────────────
|
|
|
|
app.get('/api/admin-webhooks', requireAdminAuth, (_req, res) => {
|
|
res.json({ webhooks: state.webhooks })
|
|
})
|
|
|
|
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => {
|
|
const { url, events } = req.body ?? {}
|
|
if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) {
|
|
res.status(400).json({ message: 'A valid HTTPS URL is required.' })
|
|
return
|
|
}
|
|
if (state.webhooks.length >= 50) {
|
|
res.status(400).json({ message: 'Maximum of 50 webhooks reached.' })
|
|
return
|
|
}
|
|
const webhook = {
|
|
id: crypto.randomUUID(),
|
|
url: url.trim().slice(0, 500),
|
|
events: Array.isArray(events) ? events.map(String).slice(0, 20) : (typeof events === 'string' && events.trim() ? events.split(',').map(e => e.trim()).filter(Boolean) : ['*']),
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
state.webhooks.push(webhook)
|
|
queueWebhooksWrite()
|
|
res.status(201).json({ webhook })
|
|
})
|
|
|
|
app.delete('/api/admin-webhooks/:id', requireAdminAuth, (req, res) => {
|
|
const idx = state.webhooks.findIndex(w => w.id === req.params.id)
|
|
if (idx === -1) { res.status(404).json({ message: 'Not found.' }); return }
|
|
state.webhooks.splice(idx, 1)
|
|
queueWebhooksWrite()
|
|
res.json({ ok: true })
|
|
})
|
|
}
|