Add Bible Questions inbox from contact submissions
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user