From f5c4557e9f8e224227ccef70621db1da71087764 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 29 Jul 2026 07:52:07 -0400 Subject: [PATCH] Add Tier 2 remaining features: publishing pipeline, broadcasts, audit log, drip trigger, today banner; v1.1.25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 2 +- server.js | 2 + server/config.js | 1 + server/data.js | 32 +++++++++++++- server/routes/contact.js | 72 +++++++++++++++++++++++++++++++ server/state.js | 3 ++ src/App.css | 90 +++++++++++++++++++++++++++++++++++++++ src/CalendarPage.tsx | 48 ++++++++++++++++++++- src/ContactsPage.tsx | 26 ++++++++++++ src/EmailPage.tsx | 92 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 364 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 9294847..ebb1149 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.24", + "version": "1.1.25", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 251b00b..c24c484 100644 --- a/server.js +++ b/server.js @@ -25,6 +25,7 @@ import { loadAnalyticsEventsFromDisk, loadEmailSettingsFromDisk, loadCalendarEventsFromDisk, + loadAuditLogFromDisk, createBackupSnapshot, refreshContentCaches, queueHitStatsWrite, @@ -151,6 +152,7 @@ Promise.all([ loadAnalyticsEventsFromDisk(), loadEmailSettingsFromDisk(), loadCalendarEventsFromDisk(), + loadAuditLogFromDisk(), refreshContentCaches(), ]) .catch(err => { diff --git a/server/config.js b/server/config.js index efbc431..cf5ae96 100644 --- a/server/config.js +++ b/server/config.js @@ -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') diff --git a/server/data.js b/server/data.js index 9a4b545..6993583 100644 --- a/server/data.js +++ b/server/data.js @@ -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) { diff --git a/server/routes/contact.js b/server/routes/contact.js index 998cf12..399f9fa 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -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, '
')).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 } diff --git a/server/state.js b/server/state.js index 264ea71..79a159f 100644 --- a/server/state.js +++ b/server/state.js @@ -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 }, diff --git a/src/App.css b/src/App.css index c82626d..0bbdfb2 100644 --- a/src/App.css +++ b/src/App.css @@ -10730,6 +10730,90 @@ letter-spacing: -1px; } +/* Production status dot on episode chips */ +.cal-prod-status-dot { + border-radius: 50%; + display: inline-block; + flex-shrink: 0; + height: 6px; + margin-left: 2px; + width: 6px; +} + +/* Publishing today banner */ +.cal-publish-banner { + align-items: center; + background: #131f13; + border-bottom: 1px solid #1e3a1e; + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: 0.6rem; + padding: 0.6rem 1.25rem; +} + +.cal-publish-banner-icon { font-size: 1.1rem; } + +.cal-publish-banner-text { + color: #4ade80; + flex: 1; + font-size: 0.88rem; + font-weight: 500; + min-width: 160px; +} + +/* Broadcast form */ +.em-broadcast-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.em-broadcast-body { min-height: 200px; resize: vertical; } + +/* Audit log */ +.em-audit-list { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.em-audit-item { + align-items: baseline; + background: #141414; + border-radius: 6px; + display: flex; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + flex-wrap: wrap; +} + +.em-audit-action { + background: #2a2a2a; + border-radius: 10px; + color: #c0b8a8; + font-size: 0.72rem; + font-weight: 600; + padding: 0.1rem 0.5rem; + white-space: nowrap; +} + +.em-audit-details { + color: #9a9088; + flex: 1; + font-size: 0.8rem; + min-width: 0; + word-break: break-word; +} + +.em-audit-date { + color: #6b6560; + font-size: 0.72rem; + white-space: nowrap; +} + +.em-status-msg--ok { color: #4ade80; } + /* Delivery status pills (email page) */ .em-delivery-pill { border-radius: 10px; @@ -10745,6 +10829,12 @@ .em-delivery-pill--sent { background: #1a1a1a; color: #6b6560; } .em-delivery-pill--bounced { background: #2e1a1a; color: #f87171; } +.ct-drip-msg { + color: #60a5fa; + font-size: 0.78rem; + margin: 0.25rem 0 0; +} + /* Flash message */ .ct-flash { background: #1a2a1a; diff --git a/src/CalendarPage.tsx b/src/CalendarPage.tsx index 7b1770b..bdf9ad1 100644 --- a/src/CalendarPage.tsx +++ b/src/CalendarPage.tsx @@ -9,6 +9,16 @@ interface PodcastChecklistTask { required: boolean } +type ProductionStatus = 'idea' | 'recorded' | 'edited' | 'scheduled' | 'published' + +const PROD_STATUS_OPTIONS: { value: ProductionStatus; label: string; color: string }[] = [ + { value: 'idea', label: 'Idea', color: '#4a4040' }, + { value: 'recorded', label: 'Recorded', color: '#2a3a5a' }, + { value: 'edited', label: 'Edited', color: '#3a2a5a' }, + { value: 'scheduled', label: 'Scheduled', color: '#1a3a4a' }, + { value: 'published', label: 'Published', color: '#1a3a1a' }, +] + interface PodcastChecklistEpisode { id: string series: string @@ -20,6 +30,7 @@ interface PodcastChecklistEpisode { tasks: Record reminderDays?: number reminderSentAt?: string + productionStatus?: ProductionStatus } type RecurrenceFreq = 'none' | 'weekly' | 'biweekly' | 'monthly' @@ -264,7 +275,7 @@ function CalendarClient() { // Episode edit popover const [editEp, setEditEp] = useState(null) - const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0' }) + const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0', productionStatus: '' as ProductionStatus | '' }) // New episode form const [newEpOpen, setNewEpOpen] = useState(false) @@ -454,6 +465,7 @@ function CalendarClient() { datePublished: ep.datePublished?.trim() ?? '', startTime: ep.startTime ?? '', reminderDays: String(ep.reminderDays ?? 0), + productionStatus: ep.productionStatus ?? '', }) } @@ -474,6 +486,7 @@ function CalendarClient() { startTime: editForm.startTime || undefined, reminderDays: newReminder, reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt, + productionStatus: (editForm.productionStatus as ProductionStatus) || undefined, } : ep ) @@ -670,6 +683,7 @@ function CalendarClient() { // ── Render chip ── function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) { + const statusOpt = PROD_STATUS_OPTIONS.find(o => o.value === ep.productionStatus) return ( ) @@ -848,6 +864,27 @@ function CalendarClient() { )} + {/* Publishing today banner */} + {!loading && (() => { + const todayEps = (checklist?.episodes ?? []).filter(ep => ep.datePublished === todayKey) + if (todayEps.length === 0) return null + return ( +
+ 📣 + + {todayEps.length === 1 + ? `"${todayEps[0].title || `Ep ${todayEps[0].episodeNumber}`}" publishes today` + : `${todayEps.length} episodes publish today`} + + {todayEps.map(ep => ( + + ))} +
+ ) + })()} + {/* Calendar body */}
@@ -922,6 +959,13 @@ function CalendarClient() { + + +