From 63d4f9e316cd2f5ad70ad0a2aa728a85f3a9ff35 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 3 Sep 2026 09:54:43 -0400 Subject: [PATCH] v1.2.0: Broadcast emails, webhook admin UI, dynamic sitemap, scripture index - Admin > Audience > Broadcast Email: compose and send one-off HTML emails to all subscribers via Resend batch API; {{name}} personalization; confirm step - Admin > Configure > Webhooks: CRUD UI for managing POST event webhooks; backend GET/POST/DELETE /api/admin-webhooks routes wired to existing state.webhooks - Sitemap: dynamically appends released study lesson URLs (/study/:slug/:id) and episode highlight URLs (/episodes/:id) to /sitemap.xml - Scripture Index: public /scripture page listing every released lesson grouped by study with passage reference links; served by /api/public/scripture-index Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- server/routes/admin-content.js | 36 ++++++++ server/routes/contact.js | 46 +++++++++ server/routes/public.js | 36 ++++++++ src/AdminPage.tsx | 164 ++++++++++++++++++++++++++++++++- src/App.css | 17 ++++ src/App.tsx | 50 ++++++++++ 7 files changed, 348 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index c7d4e9e..b17cabf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.52", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/admin-content.js b/server/routes/admin-content.js index ebb0378..fa4cc3d 100644 --- a/server/routes/admin-content.js +++ b/server/routes/admin-content.js @@ -25,6 +25,7 @@ import { queueReplyTemplatesWrite, queueReplyHistoryWrite, queuePodcastChecklistWrite, + queueWebhooksWrite, createBackupSnapshot, listBackupPreviews, readBackupPreview, @@ -331,4 +332,39 @@ export function register(app) { res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' }) } }) + + // ── Webhooks ────────────────────────────────────────────────────────────── + + app.get('/api/admin-webhooks', requireAdminAuth, (_req, res) => { + res.json({ webhooks: state.webhooks }) + }) + + app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => { + const { url, events } = req.body ?? {} + if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) { + res.status(400).json({ message: 'A valid HTTPS URL is required.' }) + return + } + if (state.webhooks.length >= 50) { + res.status(400).json({ message: 'Maximum of 50 webhooks reached.' }) + return + } + const webhook = { + id: crypto.randomUUID(), + url: url.trim().slice(0, 500), + events: Array.isArray(events) ? events.map(String).slice(0, 20) : (typeof events === 'string' && events.trim() ? events.split(',').map(e => e.trim()).filter(Boolean) : ['*']), + createdAt: new Date().toISOString(), + } + state.webhooks.push(webhook) + queueWebhooksWrite() + res.status(201).json({ webhook }) + }) + + app.delete('/api/admin-webhooks/:id', requireAdminAuth, (req, res) => { + const idx = state.webhooks.findIndex(w => w.id === req.params.id) + if (idx === -1) { res.status(404).json({ message: 'Not found.' }); return } + state.webhooks.splice(idx, 1) + queueWebhooksWrite() + res.json({ ok: true }) + }) } diff --git a/server/routes/contact.js b/server/routes/contact.js index 436c8a9..e1d2810 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -386,4 +386,50 @@ export function register(app) { res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`) res.send(csv) }) + + app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => { + const { subject, html } = req.body ?? {} + if (!subject || typeof subject !== 'string' || !subject.trim()) { + res.status(400).json({ message: 'Subject is required.' }); return + } + if (!html || typeof html !== 'string' || !html.trim()) { + res.status(400).json({ message: 'Email body is required.' }); return + } + if (!process.env.RESEND_API_KEY) { + res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return + } + + const seen = new Set() + const recipients = state.contactSubmissions + .filter(e => e.subscribe === true && e.email && !seen.has(e.email) && seen.add(e.email)) + .map(e => ({ name: e.name ?? '', email: e.email })) + + if (recipients.length === 0) { + res.status(400).json({ message: 'No subscribers to send to.' }); return + } + + const resend = new Resend(process.env.RESEND_API_KEY) + const from = getResendFromAddress() + const replyTo = getResendReplyToAddress() + const BATCH = 50 + let sent = 0 + let failed = 0 + + for (let i = 0; i < recipients.length; i += BATCH) { + const batch = recipients.slice(i, i + BATCH) + const emails = batch.map(r => ({ + from, + to: r.email, + reply_to: replyTo, + subject: subject.trim(), + html: html.replace(/\{\{name\}\}/g, escapeHtml(r.name || 'Friend')), + })) + try { + const { error } = await resend.batch.send(emails) + if (error) { failed += batch.length } else { sent += batch.length } + } catch { failed += batch.length } + } + + res.json({ ok: true, sent, failed, total: recipients.length }) + }) } diff --git a/server/routes/public.js b/server/routes/public.js index 13007e6..bed6cfa 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -63,6 +63,23 @@ export function register(app) { .filter(Boolean) .map(item => `${root}${item}`) + // Dynamic study lesson URLs + const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : [] + const now = new Date() + for (const study of studies) { + if (!study.slug || !Array.isArray(study.sections)) continue + for (const section of study.sections) { + if (section.id && section.releasedAt && new Date(section.releasedAt) <= now) { + urls.push(`${root}/study/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`) + } + } + } + // Dynamic episode highlight URLs + const highlights = Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : [] + for (const ep of highlights) { + if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`) + } + const xml = [ '', '', @@ -142,6 +159,25 @@ export function register(app) { `) }) + // Scripture index — all released study lessons with their passage references + app.get('/api/public/scripture-index', (_req, res) => { + const content = state.cachedSiteContent + const allStudies = Array.isArray(content?.studies) ? content.studies : [] + const cutoff = new Date() + const studies = allStudies + .filter(s => s.slug && Array.isArray(s.sections)) + .map(s => ({ + slug: s.slug, + title: s.title || s.slug, + entries: s.sections + .filter(sec => sec.id && sec.reference && sec.releasedAt && new Date(sec.releasedAt) <= cutoff) + .map(sec => ({ sectionId: sec.id, title: sec.title || '', reference: sec.reference })) + .sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true })), + })) + .filter(s => s.entries.length > 0) + res.json({ studies }) + }) + // SPA static files and fallback app.use(express.static(DIST_DIR)) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 56630bd..a6805a1 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -612,8 +612,8 @@ type AdminView = | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' - | 'subscribers' | 'study-users' | 'email-templates' - | 'seo' | 'legal' | 'security' | 'brand' | 'global' + | 'subscribers' | 'study-users' | 'email-templates' | 'broadcast' + | 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks' interface AdminSectionLink { id: string @@ -675,6 +675,7 @@ const ADMIN_NAV: NavGroup[] = [ { kind: 'leaf', view: 'questions', label: 'Questions' }, { kind: 'leaf', view: 'study-comments', label: 'Study Comments' }, { kind: 'leaf', view: 'subscribers', label: 'Subscribers' }, + { kind: 'leaf', view: 'broadcast', label: 'Broadcast Email' }, { kind: 'leaf', view: 'study-users', label: 'Study Users' }, ], }, @@ -692,6 +693,7 @@ const ADMIN_NAV: NavGroup[] = [ { kind: 'leaf', view: 'global', label: 'Footer & Global' }, { kind: 'leaf', view: 'seo', label: 'SEO & Redirects' }, { kind: 'leaf', view: 'email-templates', label: 'Email Templates' }, + { kind: 'leaf', view: 'webhooks', label: 'Webhooks' }, { kind: 'leaf', view: 'legal', label: 'Legal Pages' }, { kind: 'leaf', view: 'security', label: 'Security' }, { kind: 'leaf', view: 'brand', label: 'Brand Kit' }, @@ -1114,6 +1116,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [selectedHighlightId, setSelectedHighlightId] = useState(null) const [selectedResourceId, setSelectedResourceId] = useState(null) + // Webhooks state + interface WebhookEntry { id: string; url: string; events: string[]; createdAt: string } + const [webhooks, setWebhooks] = useState([]) + const [webhooksLoaded, setWebhooksLoaded] = useState(false) + const [webhookUrl, setWebhookUrl] = useState('') + const [webhookEvents, setWebhookEvents] = useState('*') + const [webhookMsg, setWebhookMsg] = useState('') + + // Broadcast state + const [broadcastSubject, setBroadcastSubject] = useState('') + const [broadcastBody, setBroadcastBody] = useState('') + const [broadcastSending, setBroadcastSending] = useState(false) + const [broadcastResult, setBroadcastResult] = useState<{ sent: number; failed: number; total: number } | null>(null) + const [broadcastMsg, setBroadcastMsg] = useState('') + const [broadcastConfirm, setBroadcastConfirm] = useState(false) + const [dashboardNow, setDashboardNow] = useState(() => new Date()) const [manualQuestion, setManualQuestion] = useState({ firstName: '', @@ -2320,6 +2338,14 @@ async function handleAssetUpload(event: ChangeEvent) { } } + async function loadWebhooksAdmin() { + try { + const res = await fetch('/api/admin-webhooks') + const data = await res.json() + if (Array.isArray(data.webhooks)) { setWebhooks(data.webhooks); setWebhooksLoaded(true) } + } catch { /* silent */ } + } + async function loadQrCodes() { setQrLoading(true) setQrMsg('') @@ -5108,6 +5134,140 @@ async function handleAssetUpload(event: ChangeEvent) { )} + {/* ── BROADCAST EMAIL ── */} + {adminView === 'broadcast' && (() => { + const recipientCount = (() => { + const seen = new Set() + return subscribers.filter(s => s.email && !seen.has(s.email) && !!seen.add(s.email)).length + })() + async function sendBroadcast() { + if (!broadcastSubject.trim() || !broadcastBody.trim()) { + setBroadcastMsg('Subject and body are required.'); return + } + setBroadcastSending(true); setBroadcastMsg(''); setBroadcastResult(null) + try { + const r = await fetch('/api/admin-broadcast', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody }), + }) + const d = await r.json() + if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else { + setBroadcastResult(d); setBroadcastConfirm(false) + setBroadcastMsg(`Sent to ${d.sent} subscriber${d.sent !== 1 ? 's' : ''}${d.failed > 0 ? ` (${d.failed} failed)` : ''}.`) + } + } catch { setBroadcastMsg('Network error. Try again.') } + finally { setBroadcastSending(false) } + } + return ( +
+
+

Broadcast Email

+

Compose and send a one-off email to all {subscribers.length} subscribers.

+
+
+ + setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" /> +
+
+ +