Implement email tab redesign, archive support, manual Q&A creation, URL linking, and question admin tools
- Add dedicated Emails tab with inbox/archive views and two-pane layout - Implement archive/unarchive for contact submissions with persisted state - Add manual question creation endpoint and admin form (non-contact origin) - Implement URL auto-linking in Q&A answers with safe rendering - Add question admin tools: search, filter (All/Pending/Approved/Answered/Unanswered), pagination - Expand admin panel widths to reduce cramping - Restore and enhance Asset Manager table layout - Update email reply template with fixed from-address and HTML support
This commit is contained in:
@@ -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 <hello@versebyversewithnate.us>'
|
||||
|
||||
function shouldSendWelcomeEmail({ subscribe }) {
|
||||
return subscribe === true
|
||||
}
|
||||
|
||||
function buildAdminReplyTemplate({ recipientName, message }) {
|
||||
const safeRecipientName = escapeHtml(recipientName || 'friend')
|
||||
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
|
||||
|
||||
return `
|
||||
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
|
||||
<tr>
|
||||
<td align="center" style="padding:28px 16px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
|
||||
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
|
||||
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">A Personal Reply</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:26px 24px 18px;">
|
||||
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
|
||||
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
|
||||
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Grace and peace,<br/>Verse by Verse with Nate</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
|
||||
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">From: hello@versebyversewithnate.us</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user