Rework Q&A: admin inbox with one-step publish, topics and scripture references; simpler public page with ask-a-question; stop exposing asker emails; v2.22.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 14:27:17 -04:00
1 parent e6bef529e6
commit 960ee41375
9 files changed
+1170 -2392

No files matched your search

+2 -1
View File
@@ -153,7 +153,8 @@ export function register(app) {
if (!id || !/^[\w-]{1,120}$/.test(id)) {
res.redirect(302, '/questions'); return
}
const sourceQuestions = state.questions
// Same source as /api/questions, so a freshly published answer's share link works straight away
const sourceQuestions = state.draftQuestions ?? state.questions
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
if (!question) {
res.redirect(302, '/questions'); return
+98 -23
View File
@@ -20,6 +20,51 @@ const upvoteRateLimiter = rateLimit({
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)
@@ -36,7 +81,7 @@ export function register(app) {
if (!a.pinned && b.pinned) return 1
return 0
})
res.json({ questions: publicQuestions })
res.json({ questions: publicQuestions.map(toPublicQuestion) })
})
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
@@ -75,6 +120,8 @@ export function register(app) {
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)
@@ -119,32 +166,60 @@ export function register(app) {
question.approvedAt = approved === true ? new Date().toISOString() : null
queueDraftQuestionsWrite()
if (approved === true && question.notifyOnAnswer && question.email && question.answer) {
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}`,
})
} catch (err) {
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
}
}
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()