Add Tier 2 remaining features: publishing pipeline, broadcasts, audit log, drip trigger, today banner; v1.1.25

- CalendarPage: productionStatus field on episodes (idea→recorded→edited→scheduled→published), color dot on chips, dropdown in edit popover; "publishing today" banner with draft announcement button
- EmailPage: Broadcasts drawer (plain-text → Resend Broadcasts API); Audit Log drawer showing activity history; em-status-msg--ok style
- ContactsPage: ▶ Drip button in edit mode syncs contact to Resend audience and triggers drip automation
- server: appendAuditEntry() logged on reply-sent, email-sent, submission-deleted, contacts-merged, contacts-imported, broadcast-sent, drip-triggered; GET /api/admin-audit-log; POST /api/admin-broadcasts/send; POST /api/admin-contacts/trigger-drip; AUDIT_LOG_FILE persisted to data/audit-log.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-29 07:52:07 -04:00
parent bf16deea92
commit f5c4557e9f
10 changed files with 364 additions and 4 deletions
+72
View File
@@ -22,6 +22,7 @@ import {
normalizeMessageType,
sanitizeReplyTemplates,
sanitizeReplyHistory,
appendAuditEntry,
} from '../data.js'
import {
noteContactEmailCooldown,
@@ -479,6 +480,7 @@ export function register(app) {
}
queueContactSubmissionsWrite()
appendAuditEntry('submission-deleted', `id: ${id}`)
res.json({ ok: true })
})
@@ -523,6 +525,7 @@ export function register(app) {
return { ...s, email: keepEmail }
})
queueContactSubmissionsWrite()
appendAuditEntry('contacts-merged', `${mergeEmail}${keepEmail} (${affected} submission${affected !== 1 ? 's' : ''})`)
res.json({ ok: true, affected })
})
@@ -566,6 +569,7 @@ export function register(app) {
}
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 })
})
@@ -688,6 +692,7 @@ export function register(app) {
})
state.replyHistory = state.replyHistory.slice(0, 500)
queueReplyHistoryWrite()
appendAuditEntry('reply-sent', `To: ${submission.email} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
} catch (err) {
@@ -762,6 +767,7 @@ export function register(app) {
})
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) {
@@ -813,6 +819,72 @@ export function register(app) {
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 }