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:
nmemmert
2026-07-28 10:12:52 -04:00
parent 7670b44d27
commit ce235ba9ef
13 changed files with 1952 additions and 145 deletions
+1
View File
@@ -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')
+31
View File
@@ -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
View File
@@ -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>
+59 -7
View File
@@ -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
+3
View File
@@ -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 },