Add episode transcripts, expand chatbot Q&A database to 28 entries, and build scripts
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user