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.` })) } // The site can run two series at once ("lanes"): the primary series (flat series* fields) // and an optional second series (secondSeries). Each lane has its own scheduled transition. const SERIES_LANES = [ { lane: 'primary', scheduleKey: 'scheduledSeriesTransition' }, { lane: 'second', scheduleKey: 'scheduledSecondSeriesTransition' }, ] function readCurrentSeries(content, lane) { if (lane === 'second') { const s = content.secondSeries ?? {} return { title: s.title ?? '', description: s.description ?? '', imageUrl: s.imageUrl ?? '', season: s.season, studySlug: s.studySlug ?? '', } } return { title: content.seriesTitle ?? '', description: content.seriesDescription ?? '', imageUrl: content.seriesImageUrl ?? '', season: content.seriesSeason, studySlug: content.seriesStudySlug ?? '', } } // Builds the content that results from applying one lane's scheduled series transition. // Only the named lane changes; the other lane's series, studies and highlights are left alone. function applySeriesTransition(content, sched, lane = 'primary') { const scheduleKey = lane === 'second' ? 'scheduledSecondSeriesTransition' : 'scheduledSeriesTransition' const current = readCurrentSeries(content, lane) const newSeason = Number.isInteger(sched.newSeason) ? sched.newSeason : undefined const newStudySlug = typeof sched.newStudySlug === 'string' ? sched.newStudySlug.trim().toLowerCase() : '' // Highlights for the ending series move onto its Finished Book; the other lane's and general ones stay. const allHighlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : [] const isThisLane = link => (link.lane === 'second' ? 'second' : link.lane === 'general' ? 'general' : 'primary') === lane const endingHighlights = current.title.trim() ? allHighlights.filter(isThisLane) : [] const keptHighlights = current.title.trim() ? allHighlights.filter(link => !isThisLane(link)) : allHighlights const archivedBook = current.title.trim() ? { id: Date.now().toString(36), title: current.title, description: current.description, imageUrl: current.imageUrl, season: current.season ?? ((newSeason ?? 2) - 1 || 1), ...(current.studySlug ? { studySlug: current.studySlug } : {}), ...(endingHighlights.length > 0 ? { highlights: endingHighlights.map(({ lane: _lane, ...link }) => link) } : {}), } : null const patch = {} const copy = (from, to) => { if (typeof sched[from] === 'string' && sched[from].trim()) patch[to] = sched[from].trim() } copy('newStartHereHeading', 'startHereHeading') copy('newStartHereIntro', 'startHereIntro') for (const n of [1, 2, 3]) { copy(`newStartHereStep${n}Title`, `startHereStep${n}Title`) copy(`newStartHereStep${n}Body`, `startHereStep${n}Body`) copy(`newStartHereStep${n}Cta`, `startHereStep${n}Cta`) } copy('newStudyGuideTitle', 'studyGuideTitle') copy('newStudyGuideDescription', 'studyGuideDescription') copy('newStudyGuideDownloadUrl', 'studyGuideDownloadUrl') copy('newStudyGuideUrl', 'studyGuideUrl') copy('newStudyGuideAmazonButtonLabel', 'studyGuideAmazonButtonLabel') copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl') copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel') // The finished series' study becomes Complete (still enrollable, self-paced); the // incoming series' study opens up, taking the lane it launches in. const laneLetter = lane === 'second' ? 'B' : 'A' const studies = (Array.isArray(content.studies) ? content.studies : []).map(study => { if (!study) return study if (current.studySlug && study.slug === current.studySlug && study.status !== 'planned') { return { ...study, status: 'complete' } } if (newStudySlug && study.slug === newStudySlug) { return { ...study, status: 'active', lane: study.lane || laneLetter, startsAt: '' } } return study }) const lanePatch = lane === 'second' ? { secondSeries: { ...(content.secondSeries ?? {}), label: 'Now Playing', title: sched.newTitle.trim(), description: sched.newDescription?.trim() ?? '', imageUrl: sched.newImageUrl?.trim() || content.secondSeries?.imageUrl || '', studySlug: newStudySlug, ...(newSeason ? { season: newSeason } : {}), }, } : { seriesTitle: sched.newTitle.trim(), seriesDescription: sched.newDescription?.trim() ?? '', seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '', seriesLabel: 'Now Playing', seriesStudySlug: newStudySlug, ...(newSeason ? { seriesSeason: newSeason } : {}), } const next = sanitizeSiteContent({ ...content, ...lanePatch, studies, podcastFeaturedLinks: keptHighlights, finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), ...(archivedBook ? [archivedBook] : [])], ...patch, }) delete next[scheduleKey] return { next, archivedBook } } // Applies any scheduled series transitions (one per lane) whose triggersAt time has passed. // A schedule is saved with the draft (Save Schedule), so look in the draft as well as // published content. Applies to both so a later publish doesn't undo it. // Called on a server interval (see server.js). Safe to call if no transition is set. export async function runScheduledSeriesTransition() { const published = state.cachedSiteContent const draft = state.cachedDraftSiteContent if (!published) return let nextPublished = published let nextDraft = draft const fired = [] for (const { lane, scheduleKey } of SERIES_LANES) { const sched = nextDraft?.[scheduleKey] ?? nextPublished[scheduleKey] if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) continue const triggerTime = new Date(sched.triggersAt).getTime() if (Number.isNaN(triggerTime) || triggerTime > Date.now()) continue console.log(`[series-scheduler] Firing scheduled ${lane} series transition to:`, sched.newTitle) const result = applySeriesTransition(nextPublished, sched, lane) nextPublished = result.next if (nextDraft) nextDraft = applySeriesTransition(nextDraft, sched, lane).next fired.push(result.archivedBook?.title ?? '(nothing archived)') } if (fired.length === 0) return const publishedAt = new Date().toISOString() try { await mkdir(DATA_DIR, { recursive: true }) await writeFile(DATA_FILE, JSON.stringify({ siteContent: nextPublished, updatedAt: publishedAt }, null, 2), 'utf8') state.cachedSiteContent = nextPublished state.publishState.publishedAt = publishedAt console.log('[series-scheduler] Transition complete — archived:', fired.join(', ')) } catch (err) { console.error('[series-scheduler] Failed to write transition:', err) return } if (nextDraft && nextDraft !== draft) { try { await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: nextDraft, updatedAt: publishedAt }, null, 2), 'utf8') state.cachedDraftSiteContent = nextDraft state.publishState.draftUpdatedAt = publishedAt } catch (err) { console.error('[series-scheduler] Failed to update draft after 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 }) }) }