import { randomUUID } from 'node:crypto' import { requireAdminAuth } from '../auth.js' import { MAX_QUESTIONS } from '../config.js' import { state } from '../state.js' import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js' import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js' import { buildAbsoluteUrl, getClientIp } from '../helpers.js' import { Resend } from 'resend' import rateLimit from 'express-rate-limit' const upvoteRateLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, message: { message: 'Too many votes. Please try again later.' }, }) // One upvote per client per question until the process restarts. const upvotedBy = new Set() const UPVOTED_BY_MAX = 50000 // Only these fields are ever sent to the public; the stored record also holds the asker's email. function toPublicQuestion(q) { return { id: q.id, firstName: q.firstName, question: q.question, answer: q.answer, topic: q.topic || '', reference: q.reference || '', submittedAt: q.submittedAt, answeredAt: q.answeredAt, upvotes: q.upvotes ?? 0, pinned: q.pinned === true, } } const clean = (value, max) => (typeof value === 'string' ? value.trim().slice(0, max) : '') // Email the asker once when their answer goes live (if they asked to be told). async function notifyAsker(question) { if (!question.notifyOnAnswer || !question.email || !question.answer || question.notifiedAt) return try { const resend = new Resend(process.env.RESEND_API_KEY) const from = getResendFromAddress() const replyTo = getResendReplyToAddress() const baseUrl = getCanonicalBaseUrl() const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`) const template = buildQuestionAnsweredEmailTemplate({ firstName: question.firstName, question: question.question, answer: question.answer, questionUrl, }) await sendResendEmailWithRetry({ resend, payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html }, context: `notify-on-answer:${question.id}`, }) question.notifiedAt = new Date().toISOString() queueDraftQuestionsWrite() } catch (err) { console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err) } } function ensureDraftQuestions() { if (state.draftQuestions !== null) return state.draftQuestions = state.questions.slice(0, MAX_QUESTIONS) } export function register(app) { app.get('/api/questions', (_req, res) => { const sourceQuestions = state.draftQuestions ?? state.questions const publicQuestions = sourceQuestions .filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0) .sort((a, b) => { // Pinned first (they are also the ones featured on the home page), then newest answer first if (a.pinned && !b.pinned) return -1 if (!a.pinned && b.pinned) return 1 const at = new Date(a.answeredAt ?? a.submittedAt ?? 0).getTime() || 0 const bt = new Date(b.answeredAt ?? b.submittedAt ?? 0).getTime() || 0 return bt - at }) res.json({ questions: publicQuestions.map(toPublicQuestion) }) }) app.get('/api/admin-questions', requireAdminAuth, (_req, res) => { res.json({ questions: state.draftQuestions ?? state.questions }) }) app.post('/api/admin-questions', requireAdminAuth, (req, res) => { const firstName = typeof req.body?.firstName === 'string' ? req.body.firstName.trim() : '' const email = typeof req.body?.email === 'string' ? req.body.email.trim() : '' const questionText = typeof req.body?.question === 'string' ? req.body.question.trim() : '' const answerText = typeof req.body?.answer === 'string' ? req.body.answer.trim() : '' const approveNow = req.body?.approve === true if (!firstName || firstName.length > 100) { res.status(400).json({ message: 'First name is required and must be 100 characters or fewer.' }); return } if (!questionText || questionText.length < 5 || questionText.length > 3000) { res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }); return } if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { res.status(400).json({ message: 'If provided, email must be a valid email address.' }); return } if (answerText.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return } ensureDraftQuestions() const now = new Date().toISOString() const created = { id: randomUUID(), submittedAt: now, firstName, email, question: questionText, answer: answerText, answeredAt: answerText ? now : null, isApproved: approveNow, approvedAt: approveNow ? now : null, topic: clean(req.body?.topic, 60), reference: clean(req.body?.reference, 80), } state.draftQuestions.unshift(created) state.draftQuestions = state.draftQuestions.slice(0, MAX_QUESTIONS) queueDraftQuestionsWrite() res.status(201).json({ ok: true, question: created }) }) 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 } ensureDraftQuestions() const question = state.draftQuestions.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() queueDraftQuestionsWrite() res.json({ ok: true, question }) }) app.post('/api/admin-questions/:id/approve', requireAdminAuth, async (req, res) => { const { id } = req.params const { approved } = req.body ?? {} ensureDraftQuestions() const question = state.draftQuestions.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 queueDraftQuestionsWrite() if (approved === true) await notifyAsker(question) res.json({ ok: true, question }) }) // Edit any part of a question (typo fixes, answer, topic, scripture reference) app.patch('/api/admin-questions/:id', requireAdminAuth, (req, res) => { ensureDraftQuestions() const question = state.draftQuestions.find(q => q.id === req.params.id) if (!question) { res.status(404).json({ message: 'Question not found.' }); return } const body = req.body ?? {} if (typeof body.question === 'string') { const text = body.question.trim() if (text.length < 5 || text.length > 3000) { res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }); return } question.question = text } if (typeof body.answer === 'string') { const text = body.answer.trim() if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return } if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null question.answer = text } if (typeof body.firstName === 'string' && body.firstName.trim()) question.firstName = clean(body.firstName, 100) if (typeof body.topic === 'string') question.topic = clean(body.topic, 60) if (typeof body.reference === 'string') question.reference = clean(body.reference, 80) queueDraftQuestionsWrite() res.json({ ok: true, question }) }) // Save the answer, make it public and email the asker (once) — all in one step app.post('/api/admin-questions/:id/publish', requireAdminAuth, async (req, res) => { ensureDraftQuestions() const question = state.draftQuestions.find(q => q.id === req.params.id) if (!question) { res.status(404).json({ message: 'Question not found.' }); return } const body = req.body ?? {} if (typeof body.answer === 'string') { const text = body.answer.trim() if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return } if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null question.answer = text } if (typeof body.topic === 'string') question.topic = clean(body.topic, 60) if (typeof body.reference === 'string') question.reference = clean(body.reference, 80) if (!question.answer?.trim()) { res.status(400).json({ message: 'Write an answer before publishing.' }); return } question.isApproved = true question.approvedAt = new Date().toISOString() queueDraftQuestionsWrite() await notifyAsker(question) res.json({ ok: true, question }) }) app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => { const { id } = req.params ensureDraftQuestions() const index = state.draftQuestions.findIndex(q => q.id === id) if (index === -1) { res.status(404).json({ message: 'Question not found.' }); return } state.draftQuestions.splice(index, 1) queueDraftQuestionsWrite() res.json({ ok: true }) }) // Anonymous upvote — no auth required, lightweight increment app.post('/api/questions/:id/upvote', upvoteRateLimiter, (req, res) => { const { id } = req.params // Only approved questions are public, so only they can be upvoted. const source = state.draftQuestions ?? state.questions const question = source.find(q => q.id === id && q.isApproved === true) const liveQ = question && state.questions.includes(question) ? question : null const draftQ = question && state.draftQuestions?.includes(question) ? question : null if (!question) { res.status(404).json({ message: 'Question not found.' }); return } const voteKey = `${getClientIp(req)}|${id}` if (upvotedBy.has(voteKey)) { res.json({ ok: true, upvotes: question.upvotes ?? 0, duplicate: true }); return } if (upvotedBy.size >= UPVOTED_BY_MAX) upvotedBy.clear() upvotedBy.add(voteKey) question.upvotes = ((question.upvotes ?? 0) + 1) if (liveQ) queueQuestionsWrite() if (draftQ) queueDraftQuestionsWrite() res.json({ ok: true, upvotes: question.upvotes }) }) // Admin: pin / unpin a question app.post('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => { const { id } = req.params ensureDraftQuestions() const question = state.draftQuestions.find(q => q.id === id) if (!question) { res.status(404).json({ message: 'Question not found.' }); return } question.pinned = true queueDraftQuestionsWrite() res.json({ ok: true, question }) }) app.delete('/api/admin-questions/:id/pin', requireAdminAuth, (req, res) => { const { id } = req.params ensureDraftQuestions() const question = state.draftQuestions.find(q => q.id === id) if (!question) { res.status(404).json({ message: 'Question not found.' }); return } question.pinned = false queueDraftQuestionsWrite() res.json({ ok: true, question }) }) }