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:
@@ -60,6 +60,7 @@ export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
|
||||
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
|
||||
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
|
||||
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
|
||||
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||
|
||||
+31
-1
@@ -32,6 +32,7 @@ import {
|
||||
ANALYTICS_EVENTS_FILE,
|
||||
EMAIL_SETTINGS_FILE,
|
||||
CALENDAR_EVENTS_FILE,
|
||||
AUDIT_LOG_FILE,
|
||||
EMPTY_HIT_STATS,
|
||||
EMPTY_VISITOR_STATS,
|
||||
DEFAULT_REPLY_TEMPLATES,
|
||||
@@ -447,6 +448,33 @@ export function loadCalendarEventsFromDisk() {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function appendAuditEntry(action, details) {
|
||||
state.auditLog.unshift({
|
||||
id: randomUUID(),
|
||||
action: String(action).slice(0, 80),
|
||||
details: typeof details === 'string' ? details.slice(0, 400) : JSON.stringify(details ?? {}).slice(0, 400),
|
||||
at: new Date().toISOString(),
|
||||
})
|
||||
if (state.auditLog.length > 500) state.auditLog = state.auditLog.slice(0, 500)
|
||||
state.auditLogWritePromise = state.auditLogWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(AUDIT_LOG_FILE, JSON.stringify({ entries: state.auditLog }, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => { console.error('[audit-log] write failed:', err) })
|
||||
}
|
||||
|
||||
export function loadAuditLogFromDisk() {
|
||||
return readFile(AUDIT_LOG_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed?.entries)) state.auditLog = parsed.entries.slice(0, 500)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// ── Podcast checklist ──────────────────────────────────────────────────────
|
||||
|
||||
export function queuePodcastChecklistWrite() {
|
||||
@@ -1263,7 +1291,9 @@ export function sanitizePodcastChecklist(value) {
|
||||
const reminderDays = Number.isFinite(Number(item.reminderDays)) && Number(item.reminderDays) >= 0 ? Number(item.reminderDays) : 0
|
||||
const reminderSentAt = typeof item.reminderSentAt === 'string' && item.reminderSentAt ? item.reminderSentAt : undefined
|
||||
const startTime = typeof item.startTime === 'string' && /^\d{2}:\d{2}$/.test(item.startTime) ? item.startTime : undefined
|
||||
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}) })
|
||||
const PROD_STATUSES = ['idea', 'recorded', 'edited', 'scheduled', 'published']
|
||||
const productionStatus = PROD_STATUSES.includes(item.productionStatus) ? item.productionStatus : undefined
|
||||
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}), ...(productionStatus ? { productionStatus } : {}) })
|
||||
}
|
||||
|
||||
if (episodes.length === 0) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -96,6 +96,9 @@ export const state = {
|
||||
calendarEvents: [],
|
||||
calendarEventsWritePromise: Promise.resolve(),
|
||||
|
||||
auditLog: [],
|
||||
auditLogWritePromise: Promise.resolve(),
|
||||
|
||||
lastBackupStatus: { ok: true, at: null, error: null, file: null },
|
||||
lastCachePurgeStatus: { ok: true, at: null, error: null },
|
||||
lastDeployHookStatus: { ok: true, at: null, error: null },
|
||||
|
||||
Reference in New Issue
Block a user