Add Bible Questions inbox from contact submissions

This commit is contained in:
nmemmert
2026-04-12 13:25:59 -04:00
parent dcfce9dc24
commit f07d8fecfb
5 changed files with 168 additions and 5 deletions
+92 -3
View File
@@ -28,6 +28,7 @@ const DATA_DIR = path.join(__dirname, 'data')
const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
const DIST_DIR = path.join(__dirname, 'dist')
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
@@ -61,8 +62,12 @@ const EMPTY_VISITOR_STATS = {
geoCacheByIp: {},
}
const MAX_CONTACT_SUBMISSIONS = 5000
let visitorStats = { ...EMPTY_VISITOR_STATS }
let visitorStatsWritePromise = Promise.resolve()
let contactSubmissions = []
let contactSubmissionsWritePromise = Promise.resolve()
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
let lastHitStatsWrite = { ok: true, at: null, error: null }
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
@@ -160,6 +165,59 @@ function queueVisitorStatsWrite() {
})
}
function queueContactSubmissionsWrite() {
contactSubmissionsWritePromise = contactSubmissionsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
CONTACT_SUBMISSIONS_FILE,
JSON.stringify({
submissions: contactSubmissions,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[contact] failed to write submissions:', err)
})
}
function loadContactSubmissionsFromDisk() {
return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
contactSubmissions = Array.isArray(parsed?.submissions)
? parsed.submissions.slice(0, MAX_CONTACT_SUBMISSIONS)
: []
})
.catch(() => {
contactSubmissions = []
})
}
function normalizeMessageType(value) {
if (value === 'question' || value === 'testimony' || value === 'topic') return value
return 'general'
}
function addContactSubmission({ name, email, message, messageType, subscribe }) {
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name,
email,
message,
messageType: normalizeMessageType(messageType),
subscribe: subscribe === true,
}
contactSubmissions.unshift(submission)
contactSubmissions = contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
return submission
}
function sanitizeUserAgent(userAgent) {
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
return userAgent.trim().slice(0, 300) || 'unknown'
@@ -397,6 +455,7 @@ async function createBackupSnapshot(reason = 'scheduled') {
adminContent: null,
hitStats,
visitorStats,
contactSubmissions,
}
try {
@@ -494,6 +553,11 @@ function sanitizeLoadedVisitorStats(value) {
}
}
function sanitizeLoadedContactSubmissions(value) {
if (!Array.isArray(value)) return []
return value.slice(0, MAX_CONTACT_SUBMISSIONS)
}
async function restoreFromBackup(filename) {
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
throw new Error('Invalid backup filename')
@@ -512,11 +576,13 @@ async function restoreFromBackup(filename) {
hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
queueHitStatsWrite()
queueVisitorStatsWrite()
queueContactSubmissionsWrite()
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise])
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise])
await createBackupSnapshot('post-restore')
}
@@ -655,6 +721,9 @@ app.get('/api/admin-stats', (_req, res) => {
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
const bibleQuestions = contactSubmissions
.filter(entry => normalizeMessageType(entry?.messageType) === 'question')
.slice(0, 100)
res.json({
totalHits: hitStats.totalHits,
@@ -680,6 +749,11 @@ app.get('/api/admin-stats', (_req, res) => {
visitorStats: lastVisitorStatsWrite,
backups: lastBackupStatus,
},
bibleQuestions,
contactTotals: {
totalSubmissions: contactSubmissions.length,
totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
},
})
})
@@ -697,6 +771,7 @@ app.get('/api/admin-stats/export', async (_req, res) => {
adminContent,
hitStats,
visitorStats,
contactSubmissions,
})
})
@@ -784,7 +859,7 @@ function contactRateLimit(req, res, next) {
app.post('/api/contact', contactRateLimit, async (req, res) => {
try {
const { name, email, message, subscribe, _honey } = req.body ?? {}
const { name, email, message, messageType, subscribe, _honey } = req.body ?? {}
// Honeypot — silently discard if filled by a bot
if (_honey) {
@@ -814,11 +889,20 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
const trimmedName = name.trim()
const trimmedEmail = email.trim()
const trimmedMessage = message.trim()
const normalizedMessageType = normalizeMessageType(messageType)
const submittedAt = new Date().toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
})
addContactSubmission({
name: trimmedName,
email: trimmedEmail,
message: trimmedMessage,
messageType: normalizedMessageType,
subscribe,
})
const resend = new Resend(process.env.RESEND_API_KEY)
if (subscribe === true) {
@@ -860,6 +944,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
subject: `Verse by Verse contact form: ${trimmedName}`,
text:
`New contact form submission\n\n` +
`Message Type: ${normalizedMessageType}\n` +
`Name: ${trimmedName}\n` +
`Email: ${trimmedEmail}\n` +
`Submitted: ${submittedAt}\n\n` +
@@ -875,6 +960,10 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
`</tr>` +
@@ -916,7 +1005,7 @@ app.use(async (_req, res) => {
})
const PORT = Number(process.env.PORT ?? 4173)
Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk()])
Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk()])
.catch(err => {
console.error('[stats] failed to load persisted stats:', err)
})