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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user