diff --git a/data/admin-reply-history.json b/data/admin-reply-history.json new file mode 100644 index 0000000..2e8219d --- /dev/null +++ b/data/admin-reply-history.json @@ -0,0 +1,15 @@ +{ + "items": [ + { + "id": "da3f5f52-f0a3-4a36-9f52-7938b82e899a", + "submissionId": "0c2e3dc4-5ee3-42e9-a81b-172bedc16713", + "toEmail": "hello@versebyversewithnate.us", + "toName": "NoAdmin Signup", + "fromEmail": "hello@versebyversewithnate.us", + "subject": "Thanks for reaching out to Verse by Verse with Nate", + "preview": "Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally. I just wanted to say thank you for your email.\n\n~Nate", + "sentAt": "2026-05-07T12:54:06.683Z" + } + ], + "updatedAt": "2026-05-07T12:54:06.685Z" +} \ No newline at end of file diff --git a/data/questions-draft.json b/data/questions-draft.json new file mode 100644 index 0000000..c92b3ce --- /dev/null +++ b/data/questions-draft.json @@ -0,0 +1,102 @@ +{ + "questions": [ + { + "id": "cb_001", + "firstName": "Nate", + "question": "What Bible translation do you use?", + "answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.", + "isApproved": true, + "topic": "Bible Study", + "answeredAt": "2026-05-07T13:36:16.859Z" + }, + { + "id": "cb_002", + "firstName": "Nate", + "question": "How do you approach a difficult or confusing Bible passage?", + "answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.", + "isApproved": true, + "topic": "Bible Study" + }, + { + "id": "cb_003", + "firstName": "Nate", + "question": "How do I stay consistent with daily Bible reading?", + "answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.", + "isApproved": true, + "topic": "Bible Study" + }, + { + "id": "cb_007", + "firstName": "Nate", + "question": "How do I submit a question to Nate?", + "answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!", + "isApproved": true, + "topic": "Podcast" + }, + { + "id": "cb_faith_family_001", + "firstName": "Nate", + "question": "How can I share my faith with skeptical family?", + "answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.", + "isApproved": true, + "topic": "Faith & Life" + }, + { + "id": "cb_blessed_hope_001", + "firstName": "Nate", + "question": "What is the blessed hope?", + "answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.", + "isApproved": true, + "topic": "Titus" + }, + { + "id": "cb_who_is_titus_001", + "firstName": "Nate", + "question": "Who was Titus?", + "answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.", + "isApproved": true, + "topic": "Titus" + }, + { + "id": "cb_deny_works_001", + "firstName": "Nate", + "question": "What does it mean to deny God by your works?", + "answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.", + "isApproved": true, + "topic": "Titus" + }, + { + "id": "cb_grace_trains_001", + "firstName": "Nate", + "question": "What does grace actually train us to do?", + "answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.", + "isApproved": true, + "topic": "Titus" + }, + { + "id": "cb_leadership_titus1_001", + "firstName": "Nate", + "question": "What does Titus 1 teach about church leadership?", + "answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.", + "isApproved": true, + "topic": "Titus" + }, + { + "id": "cb_titus347_summary_001", + "firstName": "Nate", + "question": "Give me a summary of Titus 3:4-7", + "answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.", + "isApproved": true, + "topic": "Titus" + }, + { + "id": "cb_christians_politics_001", + "firstName": "Nate", + "question": "How should Christians engage with politics and public life?", + "answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.", + "isApproved": true, + "topic": "Faith & Life" + } + ], + "updatedAt": "2026-05-07T13:36:16.859Z" +} \ No newline at end of file diff --git a/server.js b/server.js index e56edc1..40d2dcf 100644 --- a/server.js +++ b/server.js @@ -53,6 +53,8 @@ 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 DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json') +const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json') +const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json') const BACKUP_DIR = path.join(DATA_DIR, 'backups') const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') @@ -159,11 +161,36 @@ const DEFAULT_PUBLISH_STATE = { publishedAt: null, } +const DEFAULT_REPLY_TEMPLATES = [ + { + id: 'thanks-for-reaching-out', + label: 'Thank You Reply', + subject: 'Thanks for reaching out to Verse by Verse with Nate', + message: 'Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.', + }, + { + id: 'question-received', + label: 'Question Received', + subject: 'Your Bible question was received', + message: 'Thank you for sending your Bible question.\n\nI have received it, and I appreciate you taking the time to write in.', + }, + { + id: 'testimony-thank-you', + label: 'Testimony Thank You', + subject: 'Thank you for sharing your testimony', + message: 'Thank you for sharing what the Lord is doing in your life.\n\nYour message was an encouragement to read.', + }, +] + let cachedSiteContent = null let cachedDraftSiteContent = null let publishState = { ...DEFAULT_PUBLISH_STATE } let draftQuestions = null let draftQuestionsWritePromise = Promise.resolve() +let replyTemplates = [...DEFAULT_REPLY_TEMPLATES] +let replyTemplatesWritePromise = Promise.resolve() +let replyHistory = [] +let replyHistoryWritePromise = Promise.resolve() async function loadSiteContentFile(filePath) { const raw = await readFile(filePath, 'utf8') @@ -331,6 +358,38 @@ let lastBackupStatus = { ok: true, at: null, error: null, file: null } let lastCachePurgeStatus = { ok: true, at: null, error: null } let lastDeployHookStatus = { ok: true, at: null, error: null } +function sanitizeReplyTemplates(value) { + if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES] + const out = value + .filter(item => item && typeof item === 'object') + .map(item => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + label: typeof item.label === 'string' ? item.label.trim().slice(0, 80) : '', + subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '', + message: typeof item.message === 'string' ? item.message.trim().slice(0, 6000) : '', + })) + .filter(item => item.label && item.subject && item.message) + + return out.length > 0 ? out : [...DEFAULT_REPLY_TEMPLATES] +} + +function sanitizeReplyHistory(value) { + if (!Array.isArray(value)) return [] + return value + .filter(item => item && typeof item === 'object') + .slice(0, 500) + .map(item => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + submissionId: typeof item.submissionId === 'string' ? item.submissionId : '', + toEmail: typeof item.toEmail === 'string' ? item.toEmail.trim().slice(0, 320) : '', + toName: typeof item.toName === 'string' ? item.toName.trim().slice(0, 200) : '', + fromEmail: typeof item.fromEmail === 'string' ? item.fromEmail.trim().slice(0, 320) : 'hello@versebyversewithnate.us', + subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '', + preview: typeof item.preview === 'string' ? item.preview.trim().slice(0, 500) : '', + sentAt: typeof item.sentAt === 'string' ? item.sentAt : new Date().toISOString(), + })) +} + function normalizeIp(rawIp) { if (!rawIp) return 'unknown' @@ -403,6 +462,36 @@ function queueContactSubmissionsWrite() { }) } +function queueReplyTemplatesWrite() { + replyTemplatesWritePromise = replyTemplatesWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + REPLY_TEMPLATES_FILE, + JSON.stringify({ templates: replyTemplates, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[reply-templates] failed to write templates:', err) + }) +} + +function queueReplyHistoryWrite() { + replyHistoryWritePromise = replyHistoryWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + REPLY_HISTORY_FILE, + JSON.stringify({ items: replyHistory, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[reply-history] failed to write history:', err) + }) +} + function loadContactSubmissionsFromDisk() { return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8') .then(raw => { @@ -416,17 +505,76 @@ function loadContactSubmissionsFromDisk() { }) } +function loadReplyTemplatesFromDisk() { + return readFile(REPLY_TEMPLATES_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + replyTemplates = sanitizeReplyTemplates(parsed?.templates) + }) + .catch(() => { + replyTemplates = [...DEFAULT_REPLY_TEMPLATES] + }) +} + +function loadReplyHistoryFromDisk() { + return readFile(REPLY_HISTORY_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + replyHistory = sanitizeReplyHistory(parsed?.items) + }) + .catch(() => { + replyHistory = [] + }) +} + function normalizeMessageType(value) { if (value === 'question' || value === 'testimony' || value === 'topic') return value return 'general' } const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true' +const ADMIN_REPLY_FROM = 'Verse by Verse with Nate ' function shouldSendWelcomeEmail({ subscribe }) { return subscribe === true } +function buildAdminReplyTemplate({ recipientName, message }) { + const safeRecipientName = escapeHtml(recipientName || 'friend') + const safeMessage = escapeHtml(message).replace(/\n/g, '
') + + return ` +
+ + + + +
+ + + + + + + + + + +
+
Verse by Verse with Nate
+

A Personal Reply

+
+

Hi ${safeRecipientName},

+
${safeMessage}
+

Grace and peace,
Verse by Verse with Nate

+
+

From: hello@versebyversewithnate.us

+
+
+
+ ` +} + function addContactSubmission({ name, email, message, messageType, subscribe }) { const submission = { id: randomUUID(), @@ -436,6 +584,7 @@ function addContactSubmission({ name, email, message, messageType, subscribe }) message, messageType: normalizeMessageType(messageType), subscribe: subscribe === true, + archived: false, } contactSubmissions.unshift(submission) @@ -735,6 +884,8 @@ async function createBackupSnapshot(reason = 'scheduled') { hitStats, visitorStats, contactSubmissions, + replyTemplates, + replyHistory, } try { @@ -841,7 +992,19 @@ function sanitizeLoadedVisitorStats(value) { function sanitizeLoadedContactSubmissions(value) { if (!Array.isArray(value)) return [] - return value.slice(0, MAX_CONTACT_SUBMISSIONS) + return value + .slice(0, MAX_CONTACT_SUBMISSIONS) + .filter(entry => entry && typeof entry === 'object') + .map(entry => ({ + id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : randomUUID(), + submittedAt: typeof entry.submittedAt === 'string' ? entry.submittedAt : new Date().toISOString(), + name: typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : '', + email: typeof entry.email === 'string' ? entry.email.trim().slice(0, 320) : '', + message: typeof entry.message === 'string' ? entry.message.trim().slice(0, 3000) : '', + messageType: normalizeMessageType(entry.messageType), + subscribe: entry.subscribe === true, + archived: entry.archived === true, + })) } async function restoreFromBackup(filename) { @@ -875,12 +1038,16 @@ async function restoreFromBackup(filename) { hitStats = sanitizeLoadedHitStats(parsed?.hitStats) visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) + replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates) + replyHistory = sanitizeReplyHistory(parsed?.replyHistory) queueHitStatsWrite() queueVisitorStatsWrite() queueContactSubmissionsWrite() + queueReplyTemplatesWrite() + queueReplyHistoryWrite() - await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise]) + await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise, replyTemplatesWritePromise, replyHistoryWritePromise]) await refreshContentCaches() await createBackupSnapshot('post-restore') } @@ -974,7 +1141,8 @@ function loadHitStatsFromDisk() { const app = express() app.use(express.json({ limit: '10mb' })) -app.set('trust proxy', true) +const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1) +app.set('trust proxy', Number.isFinite(trustProxyHops) && trustProxyHops >= 0 ? trustProxyHops : 1) app.get('/api/admin-content', async (req, res) => { const source = req.query?.source === 'draft' ? 'draft' : 'published' @@ -1482,6 +1650,152 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { }) }) +app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => { + res.json({ submissions: contactSubmissions.slice(0, 300) }) +}) + +app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => { + const { id } = req.params + if (typeof id !== 'string' || !id.trim()) { + res.status(400).json({ message: 'Invalid submission id.' }) + return + } + + const archived = req.body?.archived === true + let found = false + contactSubmissions = contactSubmissions.map(item => { + if (item.id !== id) return item + found = true + return { ...item, archived } + }) + + if (!found) { + res.status(404).json({ message: 'Submission not found.' }) + return + } + + queueContactSubmissionsWrite() + res.json({ ok: true, archived }) +}) + +app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => { + res.json({ + fromEmail: 'hello@versebyversewithnate.us', + fromIdentity: ADMIN_REPLY_FROM, + resendApiConfigured: Boolean(process.env.RESEND_API_KEY), + canSendReplies: Boolean(process.env.RESEND_API_KEY), + note: process.env.RESEND_API_KEY + ? 'App is configured to attempt sends through Resend. Delivery still depends on Resend sender/domain verification.' + : 'RESEND_API_KEY is missing, so admin replies cannot be sent yet.', + }) +}) + +app.get('/api/admin-contact-reply-templates', requireAdminAuth, (_req, res) => { + res.json({ templates: replyTemplates }) +}) + +app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => { + const nextTemplates = sanitizeReplyTemplates(req.body?.templates) + replyTemplates = nextTemplates + queueReplyTemplatesWrite() + res.json({ ok: true, templates: replyTemplates }) +}) + +app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => { + res.json({ items: replyHistory.slice(0, 100) }) +}) + +app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => { + const { id } = req.params + if (typeof id !== 'string' || !id.trim()) { + res.status(400).json({ message: 'Invalid submission id.' }) + return + } + + const startLength = contactSubmissions.length + contactSubmissions = contactSubmissions.filter(item => item.id !== id) + if (contactSubmissions.length === startLength) { + res.status(404).json({ message: 'Submission not found.' }) + return + } + + queueContactSubmissionsWrite() + res.json({ ok: true }) +}) + +app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (req, res) => { + try { + if (!process.env.RESEND_API_KEY) { + res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' }) + return + } + + const { id } = req.params + const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : '' + const message = typeof req.body?.message === 'string' ? req.body.message.trim() : '' + + if (!id || typeof id !== 'string') { + res.status(400).json({ message: 'Invalid submission id.' }) + return + } + + if (!subject || subject.length > 180) { + res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' }) + return + } + + if (!message || message.length > 6000) { + res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' }) + return + } + + const submission = contactSubmissions.find(entry => entry.id === id) + if (!submission) { + res.status(404).json({ message: 'Submission not found.' }) + return + } + + if (!submission.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(submission.email)) { + res.status(400).json({ message: 'Submission does not have a valid email address.' }) + return + } + + const recipientName = splitName(submission.name).firstName || submission.name || 'friend' + const html = buildAdminReplyTemplate({ recipientName, message }) + const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\nhello@versebyversewithnate.us` + const resend = new Resend(process.env.RESEND_API_KEY) + + const { error } = await resend.emails.send({ + from: ADMIN_REPLY_FROM, + to: [submission.email], + subject, + replyTo: 'hello@versebyversewithnate.us', + text, + html, + }) + + if (error) throw error + + replyHistory.unshift({ + id: randomUUID(), + submissionId: submission.id, + toEmail: submission.email, + toName: submission.name, + fromEmail: 'hello@versebyversewithnate.us', + subject, + preview: message.slice(0, 500), + sentAt: new Date().toISOString(), + }) + replyHistory = replyHistory.slice(0, 500) + queueReplyHistoryWrite() + + res.json({ ok: true }) + } catch (err) { + console.error('[admin-reply] send error:', err) + res.status(500).json({ message: 'Failed to send reply email.' }) + } +}) + app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => { let adminContent = null let draftContent = null @@ -1507,6 +1821,8 @@ app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => { hitStats, visitorStats, contactSubmissions, + replyTemplates, + replyHistory, }) }) @@ -2095,6 +2411,55 @@ app.get('/api/questions', (_req, res) => { res.json({ questions: publicQuestions }) }) +// Create a manual question (admin) +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, + } + + draftQuestions.unshift(created) + draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS) + queueDraftQuestionsWrite() + + res.status(201).json({ ok: true, question: created }) +}) + // Answer a question (admin) app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => { const { id } = req.params @@ -2320,6 +2685,8 @@ Promise.all([ loadHitStatsFromDisk(), loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk(), + loadReplyTemplatesFromDisk(), + loadReplyHistoryFromDisk(), loadQuestionsFromDisk(), loadDraftQuestionsFromDisk(), refreshContentCaches(), diff --git a/server/helpers.js b/server/helpers.js index a843e2b..ad17b09 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -174,6 +174,9 @@ function sanitizeCustomLinks(value) { description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '', amazonUrl: sanitizeUrl(item.amazonUrl), amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '', + tags: Array.isArray(item.tags) + ? item.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean).slice(0, 20) + : [], placement, } }) @@ -188,6 +191,9 @@ function sanitizeCustomBlocks(value) { id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '', body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '', + page: item?.page === 'homepage' || item?.page === 'start-here' || item?.page === 'episodes' || item?.page === 'downloads' || item?.page === 'about' || item?.page === 'contact' || item?.page === 'questions' + ? item.page + : 'downloads', })) .filter(item => item.heading || item.body) } @@ -235,6 +241,13 @@ function sanitizeArchivedSeries(value) { studyGuideUrl: sanitizeUrl(item.studyGuideUrl), resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks), notes: sanitizeArchivedSeriesNotes(item.notes), + episodeRange: + Number.isInteger(item?.episodeRange?.from) + && Number.isInteger(item?.episodeRange?.to) + && item.episodeRange.from > 0 + && item.episodeRange.to >= item.episodeRange.from + ? { from: item.episodeRange.from, to: item.episodeRange.to } + : undefined, })) .filter( item => diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index d82338d..7fe3dc7 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -93,6 +93,52 @@ interface Question { isApproved: boolean approvedAt: string | null } + +interface ContactSubmission { + id: string + submittedAt: string + name: string + email: string + message: string + messageType: 'question' | 'testimony' | 'topic' | 'general' + subscribe: boolean + archived?: boolean +} + +interface ContactReplyDraft { + submissionId: string + recipientName: string + recipientEmail: string + subject: string + message: string +} + +interface ContactReplyTemplate { + id: string + label: string + subject: string + message: string +} + +interface ContactReplyHistoryItem { + id: string + submissionId: string + toEmail: string + toName: string + fromEmail: string + subject: string + preview: string + sentAt: string +} + +interface ContactReplyConfig { + fromEmail: string + fromIdentity: string + resendApiConfigured: boolean + canSendReplies: boolean + note: string +} + type StringField = Exclude type AdminView = @@ -100,6 +146,7 @@ type AdminView = | 'current-series' | 'episode-highlights' | 'archived-series' | 'downloads' | 'custom-links' | 'content-blocks' | 'questions' | 'analytics' | 'assets' + | 'emails' | 'seo' | 'legal' | 'security' | 'brand' | 'global' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'global' @@ -251,8 +298,31 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [assetUploadPending, setAssetUploadPending] = useState(false) const [questions, setQuestions] = useState([]) + const [contactSubmissions, setContactSubmissions] = useState([]) + const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [contactReplyDraft, setContactReplyDraft] = useState(null) + const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle') + const [contactReplyMsg, setContactReplyMsg] = useState('') + const [contactReplyTemplates, setContactReplyTemplates] = useState([]) + const [contactReplyHistory, setContactReplyHistory] = useState([]) + const [contactReplyConfig, setContactReplyConfig] = useState(null) + const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') + const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox') + const [selectedEmailId, setSelectedEmailId] = useState(null) 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 [manualQuestion, setManualQuestion] = useState({ + firstName: '', + email: '', + question: '', + answer: '', + approve: false, + }) + const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') + const [manualQuestionMsg, setManualQuestionMsg] = useState('') const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({}) const [previewOpen, setPreviewOpen] = useState(false) const previewIframeRef = useRef(null) @@ -312,6 +382,38 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setQuestions((data as { questions: Question[] }).questions ?? []) }) .catch(() => {}) + + fetch('/api/admin-contact-submissions') + .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load contact submissions')))) + .then(data => { + setContactSubmissions((data as { submissions: ContactSubmission[] }).submissions ?? []) + setContactStatus('ready') + }) + .catch(() => { + setContactStatus('error') + }) + + fetch('/api/admin-contact-reply-templates') + .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply templates')))) + .then(data => { + setContactReplyTemplates((data as { templates: ContactReplyTemplate[] }).templates ?? []) + }) + .catch(() => {}) + + fetch('/api/admin-contact-reply-history') + .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply history')))) + .then(data => { + setContactReplyHistory((data as { items: ContactReplyHistoryItem[] }).items ?? []) + }) + .catch(() => {}) + + fetch('/api/admin-reply-config') + .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply config')))) + .then(data => { + setContactReplyConfig(data as ContactReplyConfig) + }) + .catch(() => {}) + fetch('/api/admin-stats/backups') .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups')))) .then(data => { @@ -375,6 +477,43 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setStatsStatus('ready') } + async function reloadContactSubmissions() { + const r = await fetch('/api/admin-contact-submissions') + if (!r.ok) throw new Error('Could not refresh contact submissions') + const data = await r.json() as { submissions?: ContactSubmission[] } + setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : []) + setContactStatus('ready') + } + + useEffect(() => { + const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)) + if (visible.length === 0) { + setSelectedEmailId(null) + return + } + if (!selectedEmailId || !visible.some(item => item.id === selectedEmailId)) { + setSelectedEmailId(visible[0].id) + } + }, [contactSubmissions, emailMailboxView, selectedEmailId]) + + useEffect(() => { + setQuestionPage(0) + }, [questionSearch, questionFilter]) + + async function reloadContactReplyHistory() { + const r = await fetch('/api/admin-contact-reply-history') + if (!r.ok) throw new Error('Could not refresh reply history') + const data = await r.json() as { items?: ContactReplyHistoryItem[] } + setContactReplyHistory(Array.isArray(data.items) ? data.items : []) + } + + async function reloadContactReplyTemplates() { + const r = await fetch('/api/admin-contact-reply-templates') + if (!r.ok) throw new Error('Could not refresh reply templates') + const data = await r.json() as { templates?: ContactReplyTemplate[] } + setContactReplyTemplates(Array.isArray(data.templates) ? data.templates : []) + } + async function reloadBackups() { const r = await fetch('/api/admin-stats/backups') if (!r.ok) throw new Error('Could not refresh backups') @@ -1237,11 +1376,200 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { } } + 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.') + } + } + + async function handleDeleteContactSubmission(submissionId: string) { + if (!confirm('Delete this contact submission permanently?')) return + try { + const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Failed to delete submission') + await reloadContactSubmissions() + await reloadStats() + setMaintenanceMsg('Contact submission deleted.') + } catch { + setMaintenanceMsg('Failed to delete contact submission.') + } + } + + async function handleArchiveContactSubmission(submissionId: string, archived: boolean) { + try { + const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ archived }), + }) + if (!res.ok) throw new Error('Failed to update submission') + await reloadContactSubmissions() + await reloadStats() + setContactReplyMsg(archived ? 'Message archived.' : 'Message moved back to inbox.') + } catch { + setContactReplyMsg('Failed to update archive status.') + } + } + + function openContactReplyComposer(submission: ContactSubmission) { + const firstName = submission.name?.trim().split(/\s+/)[0] || 'there' + setContactReplyDraft({ + submissionId: submission.id, + recipientName: submission.name, + recipientEmail: submission.email, + subject: 'Thanks for reaching out to Verse by Verse with Nate', + message: `Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.`, + }) + setContactReplyStatus('idle') + setContactReplyMsg(`Composing a reply to ${firstName}.`) + } + + function applyContactReplyTemplate(templateId: string) { + if (!contactReplyDraft) return + const template = contactReplyTemplates.find(item => item.id === templateId) + if (!template) return + + setContactReplyDraft({ + ...contactReplyDraft, + subject: template.subject, + message: template.message, + }) + setContactReplyMsg(`Applied template: ${template.label}.`) + } + + function addContactReplyTemplate() { + setContactReplyTemplates(items => ([ + ...items, + { + id: Date.now().toString(36), + label: '', + subject: '', + message: '', + }, + ])) + setContactTemplateStatus('idle') + } + + function updateContactReplyTemplate(id: string, field: keyof ContactReplyTemplate, value: string) { + setContactReplyTemplates(items => items.map(item => item.id === id ? { ...item, [field]: value } : item)) + setContactTemplateStatus('idle') + } + + function removeContactReplyTemplate(id: string) { + setContactReplyTemplates(items => items.filter(item => item.id !== id)) + setContactTemplateStatus('idle') + } + + async function handleSaveContactReplyTemplates() { + setContactTemplateStatus('saving') + try { + const res = await fetch('/api/admin-contact-reply-templates', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ templates: contactReplyTemplates }), + }) + if (!res.ok) throw new Error('Failed to save templates') + await reloadContactReplyTemplates() + setContactTemplateStatus('saved') + setContactReplyMsg('Reply templates saved.') + } catch { + setContactTemplateStatus('error') + setContactReplyMsg('Failed to save reply templates.') + } + } + + async function handleSendContactReply() { + if (!contactReplyDraft) return + setContactReplyStatus('sending') + setContactReplyMsg('') + + try { + const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(contactReplyDraft.submissionId)}/reply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subject: contactReplyDraft.subject, + message: contactReplyDraft.message, + }), + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error((data as { message?: string }).message ?? 'Failed to send email.') + } + + setContactReplyStatus('sent') + setContactReplyMsg(`Reply sent to ${contactReplyDraft.recipientEmail} from hello@versebyversewithnate.us.`) + await reloadContactReplyHistory() + setContactReplyDraft(null) + } catch (err) { + setContactReplyStatus('error') + setContactReplyMsg(err instanceof Error ? err.message : 'Failed to send email.') + } + } + const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources') const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => { return count + (series.resourceLinks ?? []).length }, 0) + 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 ( <> @@ -1324,8 +1652,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
Manage + - +
@@ -2018,13 +2347,104 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {

Bible Questions & Answers

-

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

+

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.

+ +
+

Add Question Manually

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