Remove dead admin routes and broken UI elements; v1.1.45
Publish Container / docker (push) Successful in 14m54s

- Deleted ~24 unused API routes from contact.js (email inbox/CRM feature
  never wired to frontend): reply, compose, draft, broadcast, webhooks,
  audit-log, contact CRUD, merge, import, and health endpoints
- Removed dead imports from contact.js (config, data, email modules)
- Removed broken /email Link from admin dashboard Inbox card
- Cleaned up AdminView type: removed unreachable 'current-series' and
  'episode-highlights' top-level values (only valid as podcast sub-tabs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-31 12:48:24 -04:00
1 parent fc63a34ef2
commit 0d830d6e79
4 files changed
+6 -601

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "siteforge",
"version": "1.1.2",
"version": "1.1.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "siteforge",
"version": "1.1.2",
"version": "1.1.44",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "1.1.44",
"version": "1.1.45",
"type": "module",
"scripts": {
"dev": "vite",
-594
View File
@@ -7,24 +7,14 @@ import {
MAX_QUESTIONS,
USE_RESEND_AUTOMATION_WELCOME,
DEFAULT_SEO,
ADMIN_REPLY_FROM,
ADMIN_REPLY_FROM_OPTIONS,
} from '../config.js'
import { state } from '../state.js'
import {
queueContactSubmissionsWrite,
queueQuestionsWrite,
queueDraftQuestionsWrite,
queueReplyTemplatesWrite,
queueReplyHistoryWrite,
queueEmailSettingsWrite,
normalizeContactEmailStatus,
normalizeMessageType,
sanitizeReplyTemplates,
sanitizeReplyHistory,
appendAuditEntry,
queueWebhooksWrite,
fireWebhooks,
} from '../data.js'
import {
noteContactEmailCooldown,
@@ -36,10 +26,8 @@ import {
getResendFromAddress,
getResendReplyToAddress,
getResendInboxAddress,
getAddressDomain,
buildContactWelcomeEmailTemplate,
buildContactAdminNotificationTemplate,
buildAdminReplyTemplate,
sendResendEmailWithRetry,
syncContactToResend,
} from '../email.js'
@@ -364,435 +352,11 @@ export function register(app) {
res.json({ ok: true })
})
app.get('/api/admin-contact-email-health', requireAdminAuth, (_req, res) => {
const fromAddress = getResendFromAddress()
const replyToAddress = getResendReplyToAddress()
const fromDomain = getAddressDomain(fromAddress)
const replyDomain = getAddressDomain(replyToAddress)
const warnings = []
if (!process.env.RESEND_API_KEY) warnings.push('RESEND_API_KEY is missing.')
if (!fromDomain) warnings.push('RESEND_FROM is missing or invalid.')
if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Prefer a verified custom domain.')
if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('Sender and reply-to domains are different.')
if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not configured.')
warnings.push('Verify SPF, DKIM, and DMARC for the sender domain.')
const recent = state.contactSubmissions.slice(0, 300)
const failed = recent.filter(item => {
const status = normalizeContactEmailStatus(item.emailStatus, item.subscribe === true)
return ['failed', 'bounced', 'complained'].includes(status.welcome.status)
|| ['failed', 'bounced', 'complained'].includes(status.adminNotification.status)
|| ['failed', 'bounced', 'complained'].includes(status.adminReply.status)
}).length
res.json({
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
webhookConfigured: Boolean(process.env.RESEND_WEBHOOK_TOKEN),
fromAddress,
replyToAddress,
fromDomain,
replyDomain,
warnings,
recentSubmissionFailures: failed,
trackedSubmissions: recent.length,
})
})
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
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 patch = {}
if (typeof req.body?.archived === 'boolean') patch.archived = req.body.archived
if (typeof req.body?.starred === 'boolean') patch.starred = req.body.starred
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)
if (Array.isArray(req.body?.tags)) {
patch.tags = [...new Set(req.body.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)))].slice(0, 20)
}
if ('snoozedUntil' in (req.body ?? {})) {
const v = req.body.snoozedUntil
patch.snoozedUntil = v === null ? null : (typeof v === 'string' && !isNaN(Date.parse(v)) ? v : undefined)
if (patch.snoozedUntil === undefined) delete patch.snoozedUntil
}
let found = false
state.contactSubmissions = state.contactSubmissions.map(item => {
if (item.id !== id) return item
found = true
return { ...item, ...patch }
})
if (!found) {
res.status(404).json({ message: 'Submission not found.' }); return
}
queueContactSubmissionsWrite()
res.json({ ok: true })
})
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 = state.contactSubmissions.length
state.contactSubmissions = state.contactSubmissions.filter(item => item.id !== id)
if (state.contactSubmissions.length === startLength) {
res.status(404).json({ message: 'Submission not found.' }); return
}
queueContactSubmissionsWrite()
appendAuditEntry('submission-deleted', `id: ${id}`)
res.json({ ok: true })
})
app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => {
const { ids, action } = req.body ?? {}
if (!Array.isArray(ids) || !['archive', 'unarchive', 'delete', 'star', 'unstar'].includes(action)) {
res.status(400).json({ message: 'Invalid bulk action.' }); return
}
const idSet = new Set(ids.filter(id => typeof id === 'string'))
if (idSet.size === 0) { res.json({ ok: true, affected: 0 }); return }
let affected = 0
if (action === 'delete') {
const before = state.contactSubmissions.length
state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id))
affected = before - state.contactSubmissions.length
} else {
const patch = action === 'archive' ? { archived: true }
: action === 'unarchive' ? { archived: false }
: action === 'star' ? { starred: true }
: { starred: false }
state.contactSubmissions = state.contactSubmissions.map(s => {
if (!idSet.has(s.id)) return s
affected++
return { ...s, ...patch }
})
}
queueContactSubmissionsWrite()
res.json({ ok: true, affected })
})
app.post('/api/admin-contacts/merge', requireAdminAuth, (req, res) => {
const keepEmail = typeof req.body?.keepEmail === 'string' ? req.body.keepEmail.trim().toLowerCase() : ''
const mergeEmail = typeof req.body?.mergeEmail === 'string' ? req.body.mergeEmail.trim().toLowerCase() : ''
if (!keepEmail || !mergeEmail || keepEmail === mergeEmail) {
res.status(400).json({ message: 'keepEmail and mergeEmail must be different non-empty addresses.' }); return
}
let affected = 0
state.contactSubmissions = state.contactSubmissions.map(s => {
if ((s.email ?? '').trim().toLowerCase() !== mergeEmail) return s
affected++
return { ...s, email: keepEmail }
})
queueContactSubmissionsWrite()
appendAuditEntry('contacts-merged', `${mergeEmail} → ${keepEmail} (${affected} submission${affected !== 1 ? 's' : ''})`)
res.json({ ok: true, affected })
})
app.post('/api/admin-contacts/import', requireAdminAuth, (req, res) => {
const rows = req.body?.rows
if (!Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ message: 'rows must be a non-empty array.' }); return
}
const EMAIL_RE = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
let created = 0
let skipped = 0
const toAdd = []
for (const row of rows.slice(0, 1000)) {
const name = typeof row.name === 'string' ? row.name.trim().slice(0, 200) : ''
const email = typeof row.email === 'string' ? row.email.trim().toLowerCase().slice(0, 320) : ''
const notes = typeof row.notes === 'string' ? row.notes.trim().slice(0, 2000) : ''
const tags = Array.isArray(row.tags)
? row.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)).slice(0, 20)
: (typeof row.tags === 'string' ? row.tags.split(';').map(t => t.trim()).filter(Boolean).slice(0, 20) : [])
if (!name && !email) { skipped++; continue }
if (email && !EMAIL_RE.test(email)) { skipped++; continue }
toAdd.push({ name, email, notes, tags })
}
for (const row of toAdd) {
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name: row.name,
email: row.email,
message: '',
messageType: 'general',
subscribe: false,
archived: false,
source: 'manual',
notes: row.notes,
tags: row.tags,
emailStatus: normalizeContactEmailStatus(null, false),
}
state.contactSubmissions.unshift(submission)
created++
}
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
if (created > 0) queueContactSubmissionsWrite()
if (created > 0) appendAuditEntry('contacts-imported', `${created} contacts imported, ${skipped} skipped`)
res.json({ ok: true, created, skipped })
})
app.get('/api/admin-contact-submissions/:id/attachments/:attachmentId', requireAdminAuth, (req, res) => {
const { id, attachmentId } = req.params
const submission = state.contactSubmissions.find(s => s.id === id)
if (!submission) { res.status(404).send('Not found.'); return }
const attachment = (submission.attachments ?? []).find(a => a.id === attachmentId)
if (!attachment) { res.status(404).send('Attachment not found.'); return }
const safe = attachment.filename.replace(/[^\w.\-]/g, '_')
res.setHeader('Content-Disposition', `attachment; filename="${safe}"`)
res.setHeader('Content-Type', attachment.contentType || 'application/octet-stream')
res.send(Buffer.from(attachment.data, 'base64'))
})
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
res.json({
fromEmail: getResendReplyToAddress(),
fromIdentity: getResendFromAddress() || 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: state.replyTemplates })
})
app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => {
const nextTemplates = sanitizeReplyTemplates(req.body?.templates)
state.replyTemplates = nextTemplates
queueReplyTemplatesWrite()
res.json({ ok: true, templates: state.replyTemplates })
})
app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => {
res.json({ items: state.replyHistory.slice(0, 100) })
})
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() : ''
const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.trim() : ''
const scheduledAt = typeof req.body?.scheduledAt === 'string' && !isNaN(Date.parse(req.body.scheduledAt)) && new Date(req.body.scheduledAt) > new Date() ? req.body.scheduledAt : null
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 = state.contactSubmissions.find(entry => entry.id === id)
if (!submission) {
res.status(404).json({ message: 'Submission not found.' }); return
}
if (!submission.email || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.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 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\n${signature}\n${replyToAddress}`
const resend = new Resend(process.env.RESEND_API_KEY)
const sendResult = await sendResendEmailWithRetry({
resend,
context: 'admin-contact-reply',
payload: {
from: fromAddress,
to: [submission.email],
subject,
replyTo: replyToAddress,
...(scheduledAt ? { scheduledAt } : {}),
tags: [
{ name: 'flow', value: 'admin-reply' },
{ name: 'message_type', value: submission.messageType ?? 'general' },
{ name: 'submission_id', value: submission.id },
],
headers: {
'X-Contact-Submission-Id': submission.id,
...(typeof submission.messageId === 'string' && MESSAGE_ID_RE.test(submission.messageId) ? {
'In-Reply-To': submission.messageId,
'References': submission.messageId,
} : {}),
},
text,
html,
},
})
registerResendMessageForSubmission(submission.id, 'adminReply', sendResult)
upsertContactEmailStatus(submission.id, 'adminReply', { status: 'sent', lastEventType: 'email.sent', error: null })
state.replyHistory.unshift({
id: randomUUID(),
submissionId: submission.id,
toEmail: submission.email,
toName: submission.name,
fromEmail: replyToAddress,
subject,
preview: message.slice(0, 500),
sentAt: new Date().toISOString(),
scheduledAt: scheduledAt ?? null,
})
state.replyHistory = state.replyHistory.slice(0, 500)
queueReplyHistoryWrite()
appendAuditEntry('reply-sent', `To: ${submission.email} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
fireWebhooks('reply.sent', { toEmail: submission.email, toName: submission.name, subject, sentAt: new Date().toISOString() })
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
} catch (err) {
if (typeof req.params?.id === 'string' && req.params.id.trim()) {
upsertContactEmailStatus(req.params.id.trim(), 'adminReply', {
status: 'failed',
lastEventType: 'email.failed',
error: String(err?.message ?? err ?? 'unknown error').slice(0, 600),
})
}
console.error('[admin-reply] send error:', err)
res.status(500).json({ message: 'Failed to send reply email.' })
}
})
app.post('/api/admin-email/compose', 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 to = typeof req.body?.to === 'string' ? req.body.to.trim() : ''
const toName = typeof req.body?.toName === 'string' ? req.body.toName.trim() : ''
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
const requestedFrom = typeof req.body?.fromAddress === 'string' ? req.body.fromAddress.trim() : ''
const scheduledAt = typeof req.body?.scheduledAt === 'string' && !isNaN(Date.parse(req.body.scheduledAt)) && new Date(req.body.scheduledAt) > new Date() ? req.body.scheduledAt : null
if (!to || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(to)) {
res.status(400).json({ message: 'A valid recipient email address is required.' }); 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 recipientName = toName ? splitName(toName).firstName || toName : 'friend'
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\n${signature}`
const resend = new Resend(process.env.RESEND_API_KEY)
await sendResendEmailWithRetry({
resend,
context: 'admin-compose',
payload: {
from: fromAddress,
to: [to],
subject,
replyTo: replyToAddress,
...(scheduledAt ? { scheduledAt } : {}),
tags: [{ name: 'flow', value: 'admin-reply' }],
text,
html,
},
})
state.replyHistory.unshift({
id: randomUUID(),
submissionId: '',
toEmail: to,
toName: toName || to,
fromEmail: replyToAddress,
subject,
preview: message.slice(0, 500),
sentAt: new Date().toISOString(),
scheduledAt: scheduledAt ?? null,
})
state.replyHistory = state.replyHistory.slice(0, 500)
queueReplyHistoryWrite()
appendAuditEntry('email-sent', `To: ${to} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
} catch (err) {
console.error('[admin-compose] send error:', err)
res.status(500).json({ message: 'Failed to send email.' })
}
})
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
@@ -822,162 +386,4 @@ export function register(app) {
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
res.send(csv)
})
app.get('/api/admin-audit-log', requireAdminAuth, (_req, res) => {
res.json({ entries: state.auditLog.slice(0, 200) })
})
app.post('/api/admin-broadcasts/send', requireAdminAuth, async (req, res) => {
if (!process.env.RESEND_API_KEY) { res.status(503).json({ message: 'RESEND_API_KEY is not configured.' }); return }
const audienceId = process.env.RESEND_AUDIENCE_ID
if (!audienceId) { res.status(503).json({ message: 'RESEND_AUDIENCE_ID is not configured.' }); return }
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
const fromLabel = typeof req.body?.from === 'string' ? req.body.from.trim() : getResendFromAddress()
const previewText = typeof req.body?.previewText === 'string' ? req.body.previewText.trim() : ''
if (!subject) { res.status(400).json({ message: 'Subject is required.' }); return }
if (!text) { res.status(400).json({ message: 'Message body is required.' }); return }
const paragraphs = text.split(/\n{2,}/).map(p => p.replace(/\n/g, '<br>')).map(p => `<p>${p}</p>`).join('')
const html = `<!DOCTYPE html><html><body style="font-family:Georgia,serif;max-width:600px;margin:0 auto;padding:24px;color:#1a1a1a;">${paragraphs}</body></html>`
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data: created, error: createErr } = await resend.broadcasts.create({
audienceId,
from: fromLabel,
subject,
...(previewText ? { previewText } : {}),
html,
text,
})
if (createErr || !created?.id) {
res.status(502).json({ message: createErr?.message || 'Broadcast creation failed.' }); return
}
const { error: sendErr } = await resend.broadcasts.send(created.id)
if (sendErr) { res.status(502).json({ message: sendErr.message || 'Broadcast send failed.' }); return }
appendAuditEntry('broadcast-sent', `Subject: ${subject} | AudienceId: ${audienceId}`)
res.json({ ok: true, broadcastId: created.id })
} catch (err) {
res.status(502).json({ message: String(err?.message ?? 'Broadcast failed.') })
}
})
app.post('/api/admin-contacts/trigger-drip', requireAdminAuth, async (req, res) => {
if (!process.env.RESEND_API_KEY) { res.status(503).json({ message: 'RESEND_API_KEY is not configured.' }); return }
const automationId = process.env.RESEND_AUTOMATION_WELCOME || ''
if (!automationId) { res.status(503).json({ message: 'No RESEND_AUTOMATION_* configured.' }); return }
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
const tag = typeof req.body?.tag === 'string' ? req.body.tag.trim().slice(0, 50) : ''
if (!email) { res.status(400).json({ message: 'email is required.' }); return }
try {
const resend = new Resend(process.env.RESEND_API_KEY)
// Add or update the contact in the Resend audience so the automation can fire
const audienceId = process.env.RESEND_AUDIENCE_ID || ''
if (audienceId) {
await resend.contacts.create({ audienceId, email, unsubscribed: false }).catch(() => {})
}
appendAuditEntry('drip-triggered', `email: ${email}${tag ? ` | tag: ${tag}` : ''}`)
res.json({ ok: true, note: 'Contact synced to Resend audience; automation will fire based on your Resend settings.' })
} catch (err) {
res.status(502).json({ message: String(err?.message ?? 'Drip trigger failed.') })
}
})
app.post('/api/admin-contact-submissions/:id/draft-reply', requireAdminAuth, async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) { res.status(503).json({ message: 'ANTHROPIC_API_KEY is not configured on the server.' }); return }
const { id } = req.params
if (!id || typeof id !== 'string') { res.status(400).json({ message: 'Invalid submission id.' }); return }
const submission = state.contactSubmissions.find(s => s.id === id.trim())
if (!submission) { res.status(404).json({ message: 'Submission not found.' }); return }
const senderName = submission.name?.trim() || 'this listener'
const messageText = submission.message?.trim() || '(no message body)'
try {
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 400,
messages: [{
role: 'user',
content: `Draft a warm, personal reply to this message from a podcast listener. Write only the reply body — no greeting line (handled separately), no sign-off (handled by a signature). Keep it under 150 words. Be genuine and specific to their message.\n\nFrom: ${senderName}\nMessage:\n${messageText.slice(0, 1500)}`,
}],
}),
})
if (!apiRes.ok) {
const err = await apiRes.json().catch(() => ({}))
res.status(502).json({ message: err?.error?.message || 'AI draft request failed.' }); return
}
const data = await apiRes.json()
const draft = String(data?.content?.[0]?.text ?? '').trim()
res.json({ ok: true, draft })
} catch {
res.status(502).json({ message: 'AI draft request failed.' })
}
})
// ── Webhook CRUD ───────────────────────────────────────────────────────────
app.get('/api/admin-webhooks', requireAdminAuth, (_req, res) => {
res.json({ webhooks: state.webhooks })
})
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => {
const url = typeof req.body?.url === 'string' ? req.body.url.trim() : ''
const events = Array.isArray(req.body?.events) ? req.body.events.filter(e => typeof e === 'string' && e.trim()) : ['*']
const label = typeof req.body?.label === 'string' ? req.body.label.trim().slice(0, 100) : ''
if (!url || !/^https?:\/\/./.test(url)) {
res.status(400).json({ message: 'A valid http/https URL is required.' }); return
}
const webhook = { id: randomUUID(), url: url.slice(0, 500), label, events, createdAt: new Date().toISOString() }
state.webhooks.push(webhook)
state.webhooks = state.webhooks.slice(0, 50)
queueWebhooksWrite()
appendAuditEntry('webhook-added', url)
res.json({ ok: true, webhook })
})
app.delete('/api/admin-webhooks/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
const before = state.webhooks.length
state.webhooks = state.webhooks.filter(w => w.id !== id)
if (state.webhooks.length === before) { res.status(404).json({ message: 'Webhook not found.' }); return }
queueWebhooksWrite()
appendAuditEntry('webhook-removed', `id: ${id}`)
res.json({ ok: true })
})
app.post('/api/admin-webhooks/:id/test', requireAdminAuth, async (req, res) => {
const wh = state.webhooks.find(w => w.id === req.params.id)
if (!wh) { res.status(404).json({ message: 'Webhook not found.' }); return }
try {
const r = await fetch(wh.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' },
body: JSON.stringify({ event: 'test', data: { message: 'Test event from Siteforge.' }, firedAt: new Date().toISOString() }),
signal: AbortSignal.timeout(8000),
})
appendAuditEntry('webhook-test', `${wh.url} → ${r.status}`)
res.json({ ok: true, status: r.status })
} catch (err) {
res.status(502).json({ message: String(err?.message ?? 'Test failed.') })
}
})
}
+3 -4
View File
@@ -698,7 +698,7 @@ type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | '
type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'podcast' | 'current-series' | 'episode-highlights'
| 'podcast'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates'
@@ -2808,7 +2808,6 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
<div className="admin-dashboard-card-label">Inbox Messages</div>
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</div>
<Link to="/email" className="admin-dashboard-card-action" style={{ textDecoration: 'none' }}>Open Inbox →</Link>
</div>
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
@@ -3320,7 +3319,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)}
{/* CURRENT SERIES */}
{(adminView === 'current-series' || (adminView === 'podcast' && podcastTab === 'current-series')) && (
{(adminView === 'podcast' && podcastTab === 'current-series') && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Current Series</h2>
@@ -3404,7 +3403,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)}
{/* EPISODE HIGHLIGHTS */}
{(adminView === 'episode-highlights' || (adminView === 'podcast' && podcastTab === 'episode-highlights')) && (
{(adminView === 'podcast' && podcastTab === 'episode-highlights') && (
<section className="admin-panel-section" aria-label="Episode highlights">
<div className="admin-panel-head">
<h2>Episode Highlights</h2>