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:
1 parent
e6bef529e6
commit
960ee41375
9 files changed
+1170
-2392
No files matched your search
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user