Files
2026-10-12 11:56:42 -04:00

175 lines
9.6 KiB
JavaScript

import { state } from './state.js'
import { requireAdminAuth } from './auth.js'
import { scriptKey } from './episode-script-keys.js'
import { getFeedIndex } from './feed-release.js'
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
// One report per lane (A = primary series, B = second series) listing what is set up, what is
// missing, and an episode-by-episode grid. Reads the working draft so problems show up before
// publishing. Read-only: it never changes anything.
const err = (message, view) => ({ severity: 'error', message, view })
const warn = (message, view) => ({ severity: 'warn', message, view })
function laneConfig(content, lane) {
if (lane === 'A') {
return {
title: String(content.seriesTitle ?? '').trim(),
studySlug: String(content.seriesStudySlug ?? '').trim(),
highlightLane: 'primary',
description: String(content.seriesDescription ?? '').trim(),
imageUrl: String(content.seriesImageUrl ?? '').trim(),
listenUrl: String(content.seriesListenUrl ?? '').trim(),
hasGuide: Boolean(String(content.studyGuideTitle ?? '').trim() && (content.studyGuideDownloadUrl || content.studyGuideUrl || content.studyGuideAppleBooksUrl)),
transition: content.scheduledSeriesTransition,
}
}
const second = content.secondSeries ?? {}
return {
title: String(second.title ?? '').trim(),
studySlug: String(second.studySlug ?? '').trim(),
highlightLane: 'second',
description: String(second.description ?? '').trim(),
imageUrl: String(second.imageUrl ?? '').trim(),
listenUrl: String(second.listenUrl ?? '').trim(),
hasGuide: Boolean(String(second.guide?.title ?? '').trim() && (second.guide?.downloadUrl || second.guide?.amazonUrl || second.guide?.appleBooksUrl)),
transition: content.scheduledSecondSeriesTransition,
}
}
export async function buildSeasonCheck() {
const content = state.cachedDraftSiteContent ?? state.cachedSiteContent ?? {}
const [wf, index] = await Promise.all([loadPodcastWorkflow(), getFeedIndex()])
const seasons = { A: laneSeasonOf('A', wf, content), B: laneSeasonOf('B', wf, content) }
const studies = Array.isArray(content.studies) ? content.studies : []
const highlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const planned = new Set(wf.planned.map(p => scriptKey(p.season, p.episodeNumber)))
const lanes = []
for (const lane of ['A', 'B']) {
const cfg = laneConfig(content, lane)
// Lane B only exists once a second series has been set up
if (lane === 'B' && !cfg.title && !cfg.studySlug && !seasons.B) continue
const season = seasons[lane]
const problems = []
if (!cfg.title) problems.push(warn(`Lane ${lane} has no series title.`, 'podcast:current-series'))
if (!season) problems.push(err(`Lane ${lane} has no season number. Highlights and lesson audio can't match the feed without it.`, 'podcast:workflow'))
if (season && lane === 'B' && season === seasons.A) problems.push(err(`Lanes A and B both use season ${season}.`, 'podcast:workflow'))
const study = studies.find(s => s?.lane === lane) ?? studies.find(s => s?.slug && s.slug === cfg.studySlug)
if (!study) problems.push(warn(`No study is linked to Lane ${lane}. Set the study's lane, or link it in Current Series.`, 'colossians-study'))
else if (study.lane !== lane) problems.push(warn(`"${study.title}" is linked to Lane ${lane} in Current Series but the study's own lane isn't set to ${lane}.`, 'colossians-study'))
const sections = Array.isArray(study?.sections) ? study.sections : []
const laneHighlights = highlights.filter(h => (h.lane ?? 'primary') === cfg.highlightLane)
const noNumber = sections.filter(s => !String(s.episodeNumber ?? '').trim())
if (study && noNumber.length > 0) {
problems.push(warn(`${noNumber.length} lesson${noNumber.length === 1 ? '' : 's'} in "${study.title}" ${noNumber.length === 1 ? 'has' : 'have'} no episode number, so ${noNumber.length === 1 ? 'it' : 'they'} can't pick up audio.`, 'colossians-study'))
}
const noDate = sections.filter(s => String(s.episodeNumber ?? '').trim() && !s.releasedAt)
if (study && noDate.length > 0) {
problems.push(warn(`${noDate.length} lesson${noDate.length === 1 ? '' : 's'} with an episode number ${noDate.length === 1 ? 'has' : 'have'} no release date.`, 'colossians-study'))
}
const noHighlightNumber = laneHighlights.filter(h => !String(h.episodeNumber ?? '').trim())
if (noHighlightNumber.length > 0) {
problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights'))
}
// Series card
if (!cfg.imageUrl) problems.push(warn(`Lane ${lane} has no cover image.`, 'podcast:current-series'))
if (!cfg.description) problems.push(warn(`Lane ${lane} has no series description.`, 'podcast:current-series'))
if (!cfg.listenUrl) problems.push(warn(`Lane ${lane} has no listen link.`, 'podcast:current-series'))
if (!cfg.hasGuide) problems.push(warn(`Lane ${lane} has no companion study guide (title plus a download or purchase link).`, 'podcast:current-series'))
// Handover to the next season
const t = cfg.transition
if (t) {
if (!String(t.newTitle ?? '').trim() || !t.newSeason) {
problems.push(warn(`The next-season handover for Lane ${lane} is scheduled but is missing its title or season number.`, 'podcast:current-series'))
} else if (season && String(t.newSeason) === season) {
problems.push(warn(`The next-season handover for Lane ${lane} uses the same season number (${season}) as the current one.`, 'podcast:current-series'))
}
} else if (study && sections.length > 0 && sections.every(x => x.releasedAt && new Date(x.releasedAt) <= new Date())) {
problems.push(warn(`Every lesson in "${study.title}" is released, but no next season is scheduled for Lane ${lane}.`, 'podcast:current-series'))
}
// Episode numbers to show: every number any part of the season mentions, plus 1..lesson count
const numbers = new Set()
for (let n = 1; n <= sections.length; n++) numbers.add(n)
const addNumber = v => { const n = Number(v); if (Number.isInteger(n) && n > 0) numbers.add(n) }
sections.forEach(s => addNumber(s.episodeNumber))
laneHighlights.forEach(h => addNumber(h.episodeNumber))
for (const [key, item] of index) if (season && key.startsWith(`${season}-`)) addNumber(item.episode)
for (const p of wf.planned) if (season && String(p.season) === season) addNumber(p.episodeNumber)
for (const key of Object.keys(state.episodeScripts ?? {})) if (season && key.startsWith(`${season}-`)) addNumber(key.split('-')[1])
const episodes = [...numbers].sort((a, b) => a - b).map(n => {
const num = String(n)
const feedItem = season ? index.get(`${season}-${num}`) : undefined
const released = Boolean(feedItem)
const lessons = sections.filter(s => String(s.episodeNumber ?? '').trim() === num)
const hl = laneHighlights.filter(h => String(h.episodeNumber ?? '').trim() === num)
const lessonHasAudio = lessons.some(s => s.audioEmbedUrl?.trim() || (released && feedItem?.audioUrl))
if (lessons.length > 1) problems.push(warn(`Episode ${num}: ${lessons.length} lessons share this episode number.`, 'colossians-study'))
if (hl.length > 1) problems.push(warn(`Episode ${num}: ${hl.length} highlights share this episode number.`, 'podcast:highlights'))
if (season && !released && hl.some(h => !h.holdUntilReleased && h.lane !== 'general')) {
problems.push(warn(`Episode ${num}: the highlight will show on the site before the episode is out. Turn on "Publish when the episode is released".`, 'podcast:highlights'))
}
return {
number: n,
released,
releaseDate: feedItem?.pubDate ? new Date(feedItem.pubDate).toISOString().slice(0, 10) : '',
workflowStarted: season ? planned.has(scriptKey(season, num)) : false,
script: season ? Boolean(state.episodeScripts?.[scriptKey(season, num)]) : false,
lesson: lessons.length > 0,
lessonAudio: lessons.length > 0 ? lessonHasAudio : null,
highlight: hl.length > 0,
highlightHeld: hl.length > 0 ? hl.every(h => h.holdUntilReleased === true) : null,
}
})
lanes.push({
lane,
title: cfg.title,
season,
studyTitle: study?.title ?? '',
problems,
errors: problems.filter(p => p.severity === 'error').length,
warnings: problems.filter(p => p.severity === 'warn').length,
episodes,
})
}
// Scripts are filed by season + episode, so one with no season can't be matched to an episode
const general = []
const unseasoned = Object.entries(state.episodeScripts ?? {}).filter(([, entry]) => !String(entry?.season ?? '').trim())
if (unseasoned.length > 0) {
general.push(warn(`${unseasoned.length} episode script${unseasoned.length === 1 ? ' has' : 's have'} no season number, so ${unseasoned.length === 1 ? 'it' : 'they'} can't be matched to an episode. Use the season backfill in Episode Scripts.`, 'podcast:scripts'))
}
const all = [...lanes.flatMap(l => l.problems), ...general]
return {
lanes,
general,
total: all.length,
errors: all.filter(p => p.severity === 'error').length,
warnings: all.filter(p => p.severity === 'warn').length,
}
}
export function register(app) {
app.get('/api/admin/season-check', requireAdminAuth, async (_req, res) => {
try {
res.json(await buildSeasonCheck())
} catch (err) {
console.error('[season-check] failed:', err)
res.status(500).json({ message: 'Could not build the season check.' })
}
})
}