From 960ee4137557f5477b61bafab94c1e3de2ec140c Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 12 Oct 2026 14:27:17 -0400 Subject: [PATCH] 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 --- package-lock.json | 4 +- package.json | 2 +- server/routes/public.js | 3 +- server/routes/questions.js | 121 ++- src/AdminPage.tsx | 385 +-------- src/App.css | 1134 +++++-------------------- src/components/QASection.tsx | 1308 ++++++----------------------- src/components/QuestionsAdmin.tsx | 363 ++++++++ src/components/qaScripture.tsx | 242 ++++++ 9 files changed, 1170 insertions(+), 2392 deletions(-) create mode 100644 src/components/QuestionsAdmin.tsx create mode 100644 src/components/qaScripture.tsx diff --git a/package-lock.json b/package-lock.json index 5087a42..195d541 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.21.0", + "version": "2.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.21.0", + "version": "2.22.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 7ec96af..3da809f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.21.0", + "version": "2.22.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/public.js b/server/routes/public.js index 4ea8129..c255ab5 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -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 diff --git a/server/routes/questions.js b/server/routes/questions.js index ede1ae3..83b880e 100644 --- a/server/routes/questions.js +++ b/server/routes/questions.js @@ -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() diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 46af67c..841e838 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -9,6 +9,7 @@ import { DEFAULTS } from './content' import { AnalyticsPanel } from './components/AnalyticsPanel' import { AdminCollapsibleCard } from './components/AdminCollapsibleCard' import { AdminDashboardExtras } from './components/AdminDashboardExtras' +import { QuestionsAdmin, type AdminQuestion } from './components/QuestionsAdmin' import { useAutosave } from './hooks/useAutosave' import QRCode from 'qrcode' import InboxPanel, { type InboxSubmission } from './InboxPanel' @@ -685,19 +686,7 @@ interface OpsStatus { deployHook: { ok: boolean; at: string | null; error: string | null } } -interface Question { - id: string - submittedAt: string - firstName: string - email: string - question: string - answer: string - answeredAt: string | null - isApproved: boolean - approvedAt: string | null - upvotes?: number - pinned?: boolean -} +type Question = AdminQuestion type ContactSubmission = InboxSubmission @@ -2285,12 +2274,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [questions, setQuestions] = useState([]) const [contactSubmissions, setContactSubmissions] = useState([]) - const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({}) - const [editingQuestionId, setEditingQuestionId] = useState(null) - const [questionSearch, setQuestionSearch] = useState('') - const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all') - const [questionPage, setQuestionPage] = useState(0) - const [selectedQuestionIds, setSelectedQuestionIds] = useState>(new Set()) const [mobileNavOpen, setMobileNavOpen] = useState(false) const [navSearch, setNavSearch] = useState('') const [subscribers, setSubscribers] = useState([]) @@ -2382,15 +2365,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [nlPublishingId, setNlPublishingId] = useState(null) const [dashboardNow, setDashboardNow] = useState(() => new Date()) - const [manualQuestion, setManualQuestion] = useState({ - firstName: '', - email: '', - question: '', - answer: '', - approve: false, - }) - const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') - const [manualQuestionMsg, setManualQuestionMsg] = useState('') const [previewOpen, setPreviewOpen] = useState(false) const previewIframeRef = useRef(null) const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) @@ -2644,10 +2618,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) } - useEffect(() => { - setQuestionPage(0) - }, [questionSearch, questionFilter]) - async function reloadBackups() { const r = await fetch('/api/admin-stats/backups') if (!r.ok) throw new Error('Could not refresh backups') @@ -3545,63 +3515,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) })) } - async function handleAnswerQuestion(questionId: string, answer: string) { - if (!answer.trim()) return - try { - const res = await fetch(`/api/admin-questions/${questionId}/answer`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ answer: answer.trim() }), - }) - if (!res.ok) throw new Error('Failed to answer question') - setQuestions(qs => - qs.map(q => - q.id === questionId - ? { ...q, answer: answer.trim(), answeredAt: new Date().toISOString() } - : q - ) - ) - setEditingQuestionId(null) - setAnsweredQuestions(a => ({ ...a, [questionId]: '' })) - } catch { - alert('Failed to save answer') - } - } - - async function handleApproveQuestion(questionId: string, approved: boolean) { - try { - const res = await fetch(`/api/admin-questions/${questionId}/approve`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ approved }), - }) - if (!res.ok) throw new Error('Failed to update question') - setQuestions(qs => - qs.map(q => - q.id === questionId - ? { ...q, isApproved: approved, approvedAt: approved ? new Date().toISOString() : null } - : q - ) - ) - } catch { - alert('Failed to update question') - } - } - - async function handlePinQuestion(questionId: string, pin: boolean) { - try { - const method = pin ? 'POST' : 'DELETE' - const res = await fetch(`/api/admin-questions/${questionId}/pin`, { method }) - if (!res.ok) throw new Error('Failed to update pin') - const data = await res.json().catch(() => ({})) as { question?: Question } - if (data.question) { - setQuestions(items => items.map(q => q.id === questionId ? { ...q, pinned: data.question!.pinned } : q)) - } - } catch (err) { - console.error(err) - } - } - async function loadWebhooksAdmin() { try { const res = await fetch('/api/admin-webhooks') @@ -3852,102 +3765,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) } catch { alert('Failed to delete script.') } } - async function handleDeleteQuestion(questionId: string) { - if (!confirm('Delete this question permanently?')) return - try { - const res = await fetch(`/api/admin-questions/${questionId}`, { method: 'DELETE' }) - if (!res.ok) throw new Error('Failed to delete question') - setQuestions(qs => qs.filter(q => q.id !== questionId)) - setSelectedQuestionIds(prev => { const next = new Set(prev); next.delete(questionId); return next }) - } catch { - alert('Failed to delete question') - } - } - - async function handleBulkDeleteQuestions() { - if (selectedQuestionIds.size === 0) return - if (!confirm(`Delete ${selectedQuestionIds.size} question${selectedQuestionIds.size === 1 ? '' : 's'} permanently?`)) return - const ids = Array.from(selectedQuestionIds) - let deletedCount = 0 - for (const id of ids) { - try { - const res = await fetch(`/api/admin-questions/${id}`, { method: 'DELETE' }) - if (res.ok) { - deletedCount++ - setQuestions(qs => qs.filter(q => q.id !== id)) - } - } catch { - // continue with remaining - } - } - setSelectedQuestionIds(new Set()) - if (deletedCount < ids.length) alert(`Deleted ${deletedCount} of ${ids.length} questions.`) - } - - async function handleCreateManualQuestion() { - if (!manualQuestion.firstName.trim() || !manualQuestion.question.trim()) { - setManualQuestionStatus('error') - setManualQuestionMsg('First name and question are required.') - return - } - - setManualQuestionStatus('saving') - setManualQuestionMsg('') - - try { - const res = await fetch('/api/admin-questions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: manualQuestion.firstName, - email: manualQuestion.email, - question: manualQuestion.question, - answer: manualQuestion.answer, - approve: manualQuestion.approve, - }), - }) - - const data = await res.json().catch(() => ({})) as { question?: Question; message?: string } - if (!res.ok || !data.question) { - throw new Error(data.message ?? 'Failed to add question.') - } - - setQuestions(items => [data.question as Question, ...items]) - setManualQuestion({ firstName: '', email: '', question: '', answer: '', approve: false }) - setManualQuestionStatus('saved') - setManualQuestionMsg('Question added to draft Q&A list.') - } catch (err) { - setManualQuestionStatus('error') - setManualQuestionMsg(err instanceof Error ? err.message : 'Failed to add question.') - } - } - const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources') - const filteredAdminQuestions = questions.filter(question => { - const search = questionSearch.trim().toLowerCase() - const matchesSearch = !search - || question.firstName.toLowerCase().includes(search) - || question.question.toLowerCase().includes(search) - || question.answer.toLowerCase().includes(search) - - const matchesFilter = ( - questionFilter === 'all' - || (questionFilter === 'pending' && !question.isApproved) - || (questionFilter === 'approved' && question.isApproved) - || (questionFilter === 'answered' && Boolean(question.answer?.trim())) - || (questionFilter === 'unanswered' && !question.answer?.trim()) - ) - - return matchesSearch && matchesFilter - }) - - const QUESTION_PAGE_SIZE = 20 - const totalQuestionPages = Math.max(1, Math.ceil(filteredAdminQuestions.length / QUESTION_PAGE_SIZE)) - const visibleAdminQuestions = filteredAdminQuestions.slice( - questionPage * QUESTION_PAGE_SIZE, - (questionPage + 1) * QUESTION_PAGE_SIZE, - ) function renderSaveStatus() { return ( <> @@ -4169,7 +3988,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{unansweredCount}
Unanswered Questions
{answeredCount} answered · {approvedCount} approved of {questions.length}
- {unansweredCount > 0 && } + {unansweredCount > 0 && }
0 ? ' admin-dashboard-card--alert' : ''}`}>
{unreadEmailCount}
@@ -6840,203 +6659,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) )} {/* QUESTIONS */} - {adminView === 'questions' && ( -
-
-

Bible Questions & Answers

-

Manage submitted questions, add manual questions, provide answers, and approve for public display.

-
- -
- setQuestionSearch(e.target.value)} - placeholder="Search by name, question, or answer..." - style={{ minWidth: '320px', maxWidth: '520px', width: '100%' }} - /> - - - - - -
-

Showing {filteredAdminQuestions.length} of {questions.length} questions.

- - {selectedQuestionIds.size > 0 && ( -
- {selectedQuestionIds.size} selected - - -
- )} - -
-

Add Question Manually

-
-
-
- - setManualQuestion(curr => ({ ...curr, firstName: e.target.value }))} - placeholder="Nate" - /> -
-
- - setManualQuestion(curr => ({ ...curr, email: e.target.value }))} - placeholder="optional@email.com" - /> -
-
- -