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{dripMsgText}
+ )}Sends to all subscribers in your Resend audience via the Resend Broadcasts API. Resend handles unsubscribes and compliance.
+ +No activity recorded yet.
} +