Remove dead admin routes and broken UI elements; v1.1.45
Publish Container / docker (push) Successful in 14m54s
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:
1 parent
fc63a34ef2
commit
0d830d6e79
4 files changed
+6
-601
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "1.1.2",
|
"version": "1.1.44",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "1.1.2",
|
"version": "1.1.44",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.44",
|
"version": "1.1.45",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -7,24 +7,14 @@ import {
|
|||||||
MAX_QUESTIONS,
|
MAX_QUESTIONS,
|
||||||
USE_RESEND_AUTOMATION_WELCOME,
|
USE_RESEND_AUTOMATION_WELCOME,
|
||||||
DEFAULT_SEO,
|
DEFAULT_SEO,
|
||||||
ADMIN_REPLY_FROM,
|
|
||||||
ADMIN_REPLY_FROM_OPTIONS,
|
|
||||||
} from '../config.js'
|
} from '../config.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import {
|
import {
|
||||||
queueContactSubmissionsWrite,
|
queueContactSubmissionsWrite,
|
||||||
queueQuestionsWrite,
|
queueQuestionsWrite,
|
||||||
queueDraftQuestionsWrite,
|
queueDraftQuestionsWrite,
|
||||||
queueReplyTemplatesWrite,
|
|
||||||
queueReplyHistoryWrite,
|
|
||||||
queueEmailSettingsWrite,
|
|
||||||
normalizeContactEmailStatus,
|
normalizeContactEmailStatus,
|
||||||
normalizeMessageType,
|
normalizeMessageType,
|
||||||
sanitizeReplyTemplates,
|
|
||||||
sanitizeReplyHistory,
|
|
||||||
appendAuditEntry,
|
|
||||||
queueWebhooksWrite,
|
|
||||||
fireWebhooks,
|
|
||||||
} from '../data.js'
|
} from '../data.js'
|
||||||
import {
|
import {
|
||||||
noteContactEmailCooldown,
|
noteContactEmailCooldown,
|
||||||
@@ -36,10 +26,8 @@ import {
|
|||||||
getResendFromAddress,
|
getResendFromAddress,
|
||||||
getResendReplyToAddress,
|
getResendReplyToAddress,
|
||||||
getResendInboxAddress,
|
getResendInboxAddress,
|
||||||
getAddressDomain,
|
|
||||||
buildContactWelcomeEmailTemplate,
|
buildContactWelcomeEmailTemplate,
|
||||||
buildContactAdminNotificationTemplate,
|
buildContactAdminNotificationTemplate,
|
||||||
buildAdminReplyTemplate,
|
|
||||||
sendResendEmailWithRetry,
|
sendResendEmailWithRetry,
|
||||||
syncContactToResend,
|
syncContactToResend,
|
||||||
} from '../email.js'
|
} from '../email.js'
|
||||||
@@ -364,435 +352,11 @@ export function register(app) {
|
|||||||
res.json({ ok: true })
|
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) => {
|
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
|
||||||
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
|
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) => {
|
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
const subscribers = state.contactSubmissions
|
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.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
|
||||||
res.send(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
@@ -698,7 +698,7 @@ type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | '
|
|||||||
|
|
||||||
type AdminView =
|
type AdminView =
|
||||||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||||||
| 'podcast' | 'current-series' | 'episode-highlights'
|
| 'podcast'
|
||||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||||
| 'subscribers' | 'study-users' | 'email-templates'
|
| '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-value">{unreadEmailCount}</div>
|
||||||
<div className="admin-dashboard-card-label">Inbox Messages</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>
|
<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>
|
||||||
<div className="admin-dashboard-card">
|
<div className="admin-dashboard-card">
|
||||||
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
||||||
@@ -3320,7 +3319,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CURRENT SERIES */}
|
{/* CURRENT SERIES */}
|
||||||
{(adminView === 'current-series' || (adminView === 'podcast' && podcastTab === 'current-series')) && (
|
{(adminView === 'podcast' && podcastTab === 'current-series') && (
|
||||||
<section className="admin-panel-section">
|
<section className="admin-panel-section">
|
||||||
<div className="admin-panel-head">
|
<div className="admin-panel-head">
|
||||||
<h2>Current Series</h2>
|
<h2>Current Series</h2>
|
||||||
@@ -3404,7 +3403,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* EPISODE HIGHLIGHTS */}
|
{/* EPISODE HIGHLIGHTS */}
|
||||||
{(adminView === 'episode-highlights' || (adminView === 'podcast' && podcastTab === 'episode-highlights')) && (
|
{(adminView === 'podcast' && podcastTab === 'episode-highlights') && (
|
||||||
<section className="admin-panel-section" aria-label="Episode highlights">
|
<section className="admin-panel-section" aria-label="Episode highlights">
|
||||||
<div className="admin-panel-head">
|
<div className="admin-panel-head">
|
||||||
<h2>Episode Highlights</h2>
|
<h2>Episode Highlights</h2>
|
||||||
|
|||||||
Reference in new issue
Block a user