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.' }) } }) }