Add /contacts and /calendar pages; email signature settings; v1.1.12
- /contacts: standalone page with hybrid contact list (submissions + manual entry), inline edit, search, archive - /calendar: monthly release scheduling calendar reading/writing podcast checklist episode dates - /email settings: editable signature panel; signature persisted server-side and injected into outgoing emails - Move contacts out of /admin panel (now links to /contacts route) - Partial PATCH for contact submissions (name, notes, archived independently) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,7 @@ export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
|
||||
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
|
||||
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
|
||||
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
|
||||
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
DOWNLOAD_COUNTS_FILE,
|
||||
EPISODE_PLAYS_FILE,
|
||||
ANALYTICS_EVENTS_FILE,
|
||||
EMAIL_SETTINGS_FILE,
|
||||
EMPTY_HIT_STATS,
|
||||
EMPTY_VISITOR_STATS,
|
||||
DEFAULT_REPLY_TEMPLATES,
|
||||
@@ -385,6 +386,36 @@ export function loadReplyHistoryFromDisk() {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Email settings ────────────────────────────────────────────────────────
|
||||
|
||||
export function queueEmailSettingsWrite() {
|
||||
state.emailSettingsWritePromise = state.emailSettingsWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
EMAIL_SETTINGS_FILE,
|
||||
JSON.stringify({ settings: state.emailSettings, updatedAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[email-settings] failed to write settings:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export function loadEmailSettingsFromDisk() {
|
||||
return readFile(EMAIL_SETTINGS_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed?.settings && typeof parsed.settings === 'object') {
|
||||
state.emailSettings = { ...state.emailSettings, ...parsed.settings }
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep defaults from state initializer
|
||||
})
|
||||
}
|
||||
|
||||
// ── Podcast checklist ──────────────────────────────────────────────────────
|
||||
|
||||
export function queuePodcastChecklistWrite() {
|
||||
|
||||
+4
-2
@@ -350,9 +350,11 @@ export function buildContactAdminNotificationTemplate({
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAdminReplyTemplate({ recipientName, message }) {
|
||||
export function buildAdminReplyTemplate({ recipientName, message, signature }) {
|
||||
const safeRecipientName = escapeHtml(recipientName || 'friend')
|
||||
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
|
||||
const sig = typeof signature === 'string' && signature.trim() ? signature.trim() : 'Grace and peace,\nVerse by Verse with Nate'
|
||||
const safeSig = escapeHtml(sig).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;">
|
||||
@@ -369,7 +371,7 @@ export function buildAdminReplyTemplate({ recipientName, message }) {
|
||||
<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>
|
||||
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeSig}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
queueDraftQuestionsWrite,
|
||||
queueReplyTemplatesWrite,
|
||||
queueReplyHistoryWrite,
|
||||
queueEmailSettingsWrite,
|
||||
normalizeContactEmailStatus,
|
||||
normalizeMessageType,
|
||||
sanitizeReplyTemplates,
|
||||
@@ -398,18 +399,54 @@ export function register(app) {
|
||||
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contact-submissions/add', requireAdminAuth, (req, res) => {
|
||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
|
||||
const notes = typeof req.body?.notes === 'string' ? req.body.notes.trim().slice(0, 2000) : ''
|
||||
|
||||
if (!name && !email) {
|
||||
res.status(400).json({ message: 'Name or email is required.' }); return
|
||||
}
|
||||
if (email && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email)) {
|
||||
res.status(400).json({ message: 'Invalid email address.' }); return
|
||||
}
|
||||
|
||||
const submission = {
|
||||
id: randomUUID(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
name: name.slice(0, 200),
|
||||
email,
|
||||
message: '',
|
||||
messageType: 'general',
|
||||
subscribe: false,
|
||||
archived: false,
|
||||
source: 'manual',
|
||||
notes,
|
||||
emailStatus: normalizeContactEmailStatus(null, false),
|
||||
}
|
||||
|
||||
state.contactSubmissions.unshift(submission)
|
||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, submission })
|
||||
})
|
||||
|
||||
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
|
||||
const patch = {}
|
||||
if (typeof req.body?.archived === 'boolean') patch.archived = req.body.archived
|
||||
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim().slice(0, 200)
|
||||
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
|
||||
|
||||
let found = false
|
||||
state.contactSubmissions = state.contactSubmissions.map(item => {
|
||||
if (item.id !== id) return item
|
||||
found = true
|
||||
return { ...item, archived }
|
||||
return { ...item, ...patch }
|
||||
})
|
||||
|
||||
if (!found) {
|
||||
@@ -417,7 +454,7 @@ export function register(app) {
|
||||
}
|
||||
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, archived })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||||
@@ -493,11 +530,12 @@ export function register(app) {
|
||||
}
|
||||
|
||||
const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
|
||||
const html = buildAdminReplyTemplate({ recipientName, message })
|
||||
const signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate'
|
||||
const html = buildAdminReplyTemplate({ recipientName, message, signature })
|
||||
const replyToAddress = getResendReplyToAddress()
|
||||
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
|
||||
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
|
||||
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}`
|
||||
const text = `Hi ${recipientName},\n\n${message}\n\n${signature}\n${replyToAddress}`
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
const sendResult = await sendResendEmailWithRetry({
|
||||
@@ -576,11 +614,12 @@ export function register(app) {
|
||||
}
|
||||
|
||||
const recipientName = toName ? splitName(toName).firstName || toName : 'friend'
|
||||
const html = buildAdminReplyTemplate({ recipientName, message })
|
||||
const signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate'
|
||||
const html = buildAdminReplyTemplate({ recipientName, message, signature })
|
||||
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
|
||||
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
|
||||
const replyToAddress = getResendReplyToAddress()
|
||||
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate`
|
||||
const text = `Hi ${recipientName},\n\n${message}\n\n${signature}`
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
await sendResendEmailWithRetry({
|
||||
@@ -617,6 +656,19 @@ export function register(app) {
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-email-settings', requireAdminAuth, (_req, res) => {
|
||||
res.json(state.emailSettings ?? { signature: 'Grace and peace,\nVerse by Verse with Nate' })
|
||||
})
|
||||
|
||||
app.put('/api/admin-email-settings', requireAdminAuth, (req, res) => {
|
||||
const signature = typeof req.body?.signature === 'string'
|
||||
? req.body.signature.slice(0, 1000)
|
||||
: (state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate')
|
||||
state.emailSettings = { ...state.emailSettings, signature }
|
||||
queueEmailSettingsWrite()
|
||||
res.json({ ok: true, settings: state.emailSettings })
|
||||
})
|
||||
|
||||
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||||
const seen = new Set()
|
||||
const subscribers = state.contactSubmissions
|
||||
|
||||
@@ -90,6 +90,9 @@ export const state = {
|
||||
qrScans: [],
|
||||
qrCodesWritePromise: Promise.resolve(),
|
||||
|
||||
emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
|
||||
emailSettingsWritePromise: Promise.resolve(),
|
||||
|
||||
lastBackupStatus: { ok: true, at: null, error: null, file: null },
|
||||
lastCachePurgeStatus: { ok: true, at: null, error: null },
|
||||
lastDeployHookStatus: { ok: true, at: null, error: null },
|
||||
|
||||
Reference in New Issue
Block a user