diff --git a/package-lock.json b/package-lock.json index 39defd4..41a847b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fb416f7..1c3e768 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.44", + "version": "1.1.45", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/contact.js b/server/routes/contact.js index 49d893b..436c8a9 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -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, '
')).map(p => `

${p}

`).join('') - const html = `${paragraphs}` - - 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.') }) - } - }) } diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index cdf2fc5..4621d18 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -698,7 +698,7 @@ type StringField = Exclude) {
{unreadEmailCount}
Inbox Messages
{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total
- Open Inbox →
{thisWeekReal.toLocaleString()}
@@ -3320,7 +3319,7 @@ async function handleAssetUpload(event: ChangeEvent) { )} {/* CURRENT SERIES */} - {(adminView === 'current-series' || (adminView === 'podcast' && podcastTab === 'current-series')) && ( + {(adminView === 'podcast' && podcastTab === 'current-series') && (

Current Series

@@ -3404,7 +3403,7 @@ async function handleAssetUpload(event: ChangeEvent) { )} {/* EPISODE HIGHLIGHTS */} - {(adminView === 'episode-highlights' || (adminView === 'podcast' && podcastTab === 'episode-highlights')) && ( + {(adminView === 'podcast' && podcastTab === 'episode-highlights') && (

Episode Highlights