Publish Container / build (push) Successful in 21m12s
- Second series gets its own guide (Resources page card, download page, admin fields) - When a series ends (scheduled or manual) its guide moves onto its Finished Book, which shows a guide button; the main series' guide is cleared unless the launch supplies a new one - Lane B launch schedule can supply the incoming series' guide - Download lookup covers second-series and finished-book guides; guide URLs are sanitized on save and on use Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
556 lines
21 KiB
JavaScript
556 lines
21 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.` }))
|
|
}
|
|
|
|
// 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 ?? '',
|
|
guide: s.guide?.title ? s.guide : undefined,
|
|
}
|
|
}
|
|
return {
|
|
title: content.seriesTitle ?? '', description: content.seriesDescription ?? '', imageUrl: content.seriesImageUrl ?? '',
|
|
season: content.seriesSeason, studySlug: content.seriesStudySlug ?? '',
|
|
// The main series' guide lives in the flat studyGuide* fields
|
|
guide: content.studyGuideTitle?.trim()
|
|
? {
|
|
title: content.studyGuideTitle,
|
|
description: content.studyGuideDescription ?? '',
|
|
downloadUrl: content.studyGuideDownloadUrl ?? '',
|
|
amazonUrl: content.studyGuideUrl ?? '',
|
|
amazonLabel: content.studyGuideAmazonButtonLabel ?? '',
|
|
appleBooksUrl: content.studyGuideAppleBooksUrl ?? '',
|
|
appleBooksLabel: content.studyGuideAppleBooksLabel ?? '',
|
|
}
|
|
: undefined,
|
|
}
|
|
}
|
|
|
|
// 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 } : {}),
|
|
...(current.guide ? { guide: current.guide } : {}),
|
|
...(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,
|
|
// The ending series' guide moved onto its Finished Book; the incoming series brings its own
|
|
guide: sched.newGuide?.title?.trim() ? sched.newGuide : undefined,
|
|
...(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 } : {}),
|
|
// The ending series' guide moved onto its Finished Book; unless the launch brings a new one, clear it
|
|
...(current.guide && !sched.newStudyGuideTitle?.trim()
|
|
? {
|
|
studyGuideTitle: '', studyGuideDescription: '', studyGuideDownloadUrl: '', studyGuideUrl: '',
|
|
studyGuideAmazonButtonLabel: '', studyGuideAppleBooksUrl: '', studyGuideAppleBooksLabel: '',
|
|
}
|
|
: {}),
|
|
}
|
|
|
|
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 })
|
|
})
|
|
}
|