v1.2.0: Broadcast emails, webhook admin UI, dynamic sitemap, scripture index
Publish Container / docker (push) Successful in 14m11s
Publish Container / docker (push) Successful in 14m11s
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0bf2d92b1f
commit
63d4f9e316
7 files changed
+348
-3
No files matched your search
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
@@ -142,6 +159,25 @@ export function register(app) {
|
||||
</html>`)
|
||||
})
|
||||
|
||||
// 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))
|
||||
|
||||
|
||||
Reference in new issue
Block a user