Add episode transcripts, expand chatbot Q&A database to 28 entries, and build scripts

This commit is contained in:
nmemmert
2026-04-13 10:29:06 -04:00
parent 6e8b360398
commit 063f5d2a0e
31 changed files with 2779 additions and 134 deletions
+193 -5
View File
@@ -29,6 +29,8 @@ const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
const CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json')
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
const DIST_DIR = path.join(__dirname, 'dist')
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
@@ -67,10 +69,13 @@ const EMPTY_VISITOR_STATS = {
const MAX_CONTACT_SUBMISSIONS = 5000
const MAX_QUESTIONS = 1000
let visitorStats = { ...EMPTY_VISITOR_STATS }
let visitorStatsWritePromise = Promise.resolve()
let contactSubmissions = []
let contactSubmissionsWritePromise = Promise.resolve()
let questions = []
let questionsWritePromise = Promise.resolve()
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
let lastHitStatsWrite = { ok: true, at: null, error: null }
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
@@ -641,6 +646,7 @@ function normalizeHitPath(pathname) {
function shouldCountHit(req) {
if (req.method !== 'GET') return false
if (req.path.startsWith('/api/')) return false
if (req.path === '/admin' || req.path.startsWith('/admin/')) return false
if (req.path === '/favicon.ico') return false
// Ignore direct asset requests and only count document-like requests.
@@ -730,6 +736,108 @@ app.get('/api/admin-content', async (_req, res) => {
}
})
// ── Chatbot knowledge base ──────────────────────────────────────────────────
const MAX_CHATBOT_ENTRIES = 500
let chatbotEntries = []
let chatbotWritePromise = Promise.resolve()
function queueChatbotWrite() {
chatbotWritePromise = chatbotWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
CHATBOT_FILE,
JSON.stringify(chatbotEntries, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[chatbot] failed to write chatbot content:', err)
})
}
function loadChatbotFromDisk() {
return readFile(CHATBOT_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
chatbotEntries = Array.isArray(parsed) ? parsed.slice(0, MAX_CHATBOT_ENTRIES) : []
})
.catch(() => {
chatbotEntries = []
})
}
// Public: return all chatbot entries for client-side matching
app.get('/api/chatbot-content', (req, res) => {
res.json(chatbotEntries)
})
// Admin: get all entries
app.get('/api/admin/chatbot-content', (req, res) => {
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
res.json(chatbotEntries)
})
// Admin: save full list (replace all)
app.post('/api/admin/chatbot-content', (req, res) => {
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
const body = req.body
if (!Array.isArray(body)) { res.status(400).json({ message: 'Expected array.' }); return }
const sanitized = body
.filter(e => e && typeof e.title === 'string' && typeof e.content === 'string')
.slice(0, MAX_CHATBOT_ENTRIES)
.map(e => ({
id: typeof e.id === 'string' && e.id ? e.id : randomUUID(),
type: ['qa', 'topic', 'episode'].includes(e.type) ? e.type : 'qa',
title: String(e.title).trim().slice(0, 500),
content: String(e.content).trim().slice(0, 4000),
sourceLabel: typeof e.sourceLabel === 'string' ? e.sourceLabel.trim().slice(0, 160) : '',
priority: e.priority === true,
keywords: Array.isArray(e.keywords)
? e.keywords.filter(k => typeof k === 'string').map(k => k.trim().toLowerCase()).slice(0, 20)
: [],
createdAt: typeof e.createdAt === 'string' ? e.createdAt : new Date().toISOString(),
updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : new Date().toISOString(),
}))
chatbotEntries = sanitized
queueChatbotWrite()
res.json({ ok: true, count: chatbotEntries.length })
})
function queueQuestionsWrite() {
questionsWritePromise = questionsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
QUESTIONS_FILE,
JSON.stringify({
questions,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[questions] failed to write questions:', err)
})
}
function loadQuestionsFromDisk() {
return readFile(QUESTIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
questions = parsed.slice(0, MAX_QUESTIONS)
} else if (Array.isArray(parsed?.questions)) {
questions = parsed.questions.slice(0, MAX_QUESTIONS)
} else {
questions = []
}
})
.catch(() => {
questions = []
})
}
app.get('/api/admin-auth/status', (req, res) => {
res.json({
authenticated: isValidAdminSession(req),
@@ -801,9 +909,6 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
const bibleQuestions = contactSubmissions
.filter(entry => normalizeMessageType(entry?.messageType) === 'question')
.slice(0, 100)
res.json({
totalHits: hitStats.totalHits,
@@ -829,7 +934,6 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
visitorStats: lastVisitorStatsWrite,
backups: lastBackupStatus,
},
bibleQuestions,
contactTotals: {
totalSubmissions: contactSubmissions.length,
totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
@@ -983,6 +1087,23 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
subscribe,
})
// If this is a question, also add to questions array for public Q&A section
if (normalizedMessageType === 'question') {
const question = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
firstName: splitName(trimmedName).firstName,
email: trimmedEmail,
question: trimmedMessage,
answer: '',
answeredAt: null,
isApproved: false,
approvedAt: null,
}
questions.unshift(question)
questions = questions.slice(0, MAX_QUESTIONS)
queueQuestionsWrite()
}
const resend = new Resend(process.env.RESEND_API_KEY)
if (subscribe === true) {
@@ -1073,6 +1194,73 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
}
})
// Get all questions (for admin)
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
res.json({ questions })
})
// Get only approved public questions (for homepage)
app.get('/api/questions', (_req, res) => {
const publicQuestions = questions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
res.json({ questions: publicQuestions })
})
// Answer a question (admin)
app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
const { id } = req.params
const { answer } = req.body ?? {}
if (!answer || typeof answer !== 'string' || answer.trim().length < 1 || answer.trim().length > 5000) {
res.status(400).json({ message: 'Answer must be between 1 and 5000 characters.' })
return
}
const question = questions.find(q => q.id === id)
if (!question) {
res.status(404).json({ message: 'Question not found.' })
return
}
question.answer = answer.trim()
question.answeredAt = new Date().toISOString()
queueQuestionsWrite()
res.json({ ok: true, question })
})
// Approve/unapprove a question (admin)
app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
const { id } = req.params
const { approved } = req.body ?? {}
const question = questions.find(q => q.id === id)
if (!question) {
res.status(404).json({ message: 'Question not found.' })
return
}
question.isApproved = approved === true
question.approvedAt = approved === true ? new Date().toISOString() : null
queueQuestionsWrite()
res.json({ ok: true, question })
})
// Delete a question (admin)
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
const index = questions.findIndex(q => q.id === id)
if (index === -1) {
res.status(404).json({ message: 'Question not found.' })
return
}
questions.splice(index, 1)
queueQuestionsWrite()
res.json({ ok: true })
})
app.use(express.static(DIST_DIR))
app.use(async (_req, res) => {
@@ -1085,7 +1273,7 @@ app.use(async (_req, res) => {
})
const PORT = Number(process.env.PORT ?? 4173)
Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk()])
Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk(), loadQuestionsFromDisk(), loadChatbotFromDisk()])
.catch(err => {
console.error('[stats] failed to load persisted stats:', err)
})