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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.24", "version": "1.1.25",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -25,6 +25,7 @@ import {
loadAnalyticsEventsFromDisk, loadAnalyticsEventsFromDisk,
loadEmailSettingsFromDisk, loadEmailSettingsFromDisk,
loadCalendarEventsFromDisk, loadCalendarEventsFromDisk,
loadAuditLogFromDisk,
createBackupSnapshot, createBackupSnapshot,
refreshContentCaches, refreshContentCaches,
queueHitStatsWrite, queueHitStatsWrite,
@@ -151,6 +152,7 @@ Promise.all([
loadAnalyticsEventsFromDisk(), loadAnalyticsEventsFromDisk(),
loadEmailSettingsFromDisk(), loadEmailSettingsFromDisk(),
loadCalendarEventsFromDisk(), loadCalendarEventsFromDisk(),
loadAuditLogFromDisk(),
refreshContentCaches(), refreshContentCaches(),
]) ])
.catch(err => { .catch(err => {
+1
View File
@@ -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 ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.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 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 MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
export const DIST_DIR = path.join(ROOT_DIR, 'dist') export const DIST_DIR = path.join(ROOT_DIR, 'dist')
+31 -1
View File
@@ -32,6 +32,7 @@ import {
ANALYTICS_EVENTS_FILE, ANALYTICS_EVENTS_FILE,
EMAIL_SETTINGS_FILE, EMAIL_SETTINGS_FILE,
CALENDAR_EVENTS_FILE, CALENDAR_EVENTS_FILE,
AUDIT_LOG_FILE,
EMPTY_HIT_STATS, EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS, EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES, 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 ────────────────────────────────────────────────────── // ── Podcast checklist ──────────────────────────────────────────────────────
export function queuePodcastChecklistWrite() { 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 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 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 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) { if (episodes.length === 0) {
+72
View File
@@ -22,6 +22,7 @@ import {
normalizeMessageType, normalizeMessageType,
sanitizeReplyTemplates, sanitizeReplyTemplates,
sanitizeReplyHistory, sanitizeReplyHistory,
appendAuditEntry,
} from '../data.js' } from '../data.js'
import { import {
noteContactEmailCooldown, noteContactEmailCooldown,
@@ -479,6 +480,7 @@ export function register(app) {
} }
queueContactSubmissionsWrite() queueContactSubmissionsWrite()
appendAuditEntry('submission-deleted', `id: ${id}`)
res.json({ ok: true }) res.json({ ok: true })
}) })
@@ -523,6 +525,7 @@ export function register(app) {
return { ...s, email: keepEmail } return { ...s, email: keepEmail }
}) })
queueContactSubmissionsWrite() queueContactSubmissionsWrite()
appendAuditEntry('contacts-merged', `${mergeEmail}${keepEmail} (${affected} submission${affected !== 1 ? 's' : ''})`)
res.json({ ok: true, affected }) res.json({ ok: true, affected })
}) })
@@ -566,6 +569,7 @@ export function register(app) {
} }
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
if (created > 0) queueContactSubmissionsWrite() if (created > 0) queueContactSubmissionsWrite()
if (created > 0) appendAuditEntry('contacts-imported', `${created} contacts imported, ${skipped} skipped`)
res.json({ ok: true, created, skipped }) res.json({ ok: true, created, skipped })
}) })
@@ -688,6 +692,7 @@ export function register(app) {
}) })
state.replyHistory = state.replyHistory.slice(0, 500) state.replyHistory = state.replyHistory.slice(0, 500)
queueReplyHistoryWrite() queueReplyHistoryWrite()
appendAuditEntry('reply-sent', `To: ${submission.email} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null }) res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
} catch (err) { } catch (err) {
@@ -762,6 +767,7 @@ export function register(app) {
}) })
state.replyHistory = state.replyHistory.slice(0, 500) state.replyHistory = state.replyHistory.slice(0, 500)
queueReplyHistoryWrite() queueReplyHistoryWrite()
appendAuditEntry('email-sent', `To: ${to} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null }) res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
} catch (err) { } catch (err) {
@@ -813,6 +819,72 @@ export function register(app) {
res.send(csv) 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) => { app.post('/api/admin-contact-submissions/:id/draft-reply', requireAdminAuth, async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) { res.status(503).json({ message: 'ANTHROPIC_API_KEY is not configured on the server.' }); return } if (!apiKey) { res.status(503).json({ message: 'ANTHROPIC_API_KEY is not configured on the server.' }); return }
+3
View File
@@ -96,6 +96,9 @@ export const state = {
calendarEvents: [], calendarEvents: [],
calendarEventsWritePromise: Promise.resolve(), calendarEventsWritePromise: Promise.resolve(),
auditLog: [],
auditLogWritePromise: Promise.resolve(),
lastBackupStatus: { ok: true, at: null, error: null, file: null }, lastBackupStatus: { ok: true, at: null, error: null, file: null },
lastCachePurgeStatus: { ok: true, at: null, error: null }, lastCachePurgeStatus: { ok: true, at: null, error: null },
lastDeployHookStatus: { ok: true, at: null, error: null }, lastDeployHookStatus: { ok: true, at: null, error: null },
+90
View File
@@ -10730,6 +10730,90 @@
letter-spacing: -1px; 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) */ /* Delivery status pills (email page) */
.em-delivery-pill { .em-delivery-pill {
border-radius: 10px; border-radius: 10px;
@@ -10745,6 +10829,12 @@
.em-delivery-pill--sent { background: #1a1a1a; color: #6b6560; } .em-delivery-pill--sent { background: #1a1a1a; color: #6b6560; }
.em-delivery-pill--bounced { background: #2e1a1a; color: #f87171; } .em-delivery-pill--bounced { background: #2e1a1a; color: #f87171; }
.ct-drip-msg {
color: #60a5fa;
font-size: 0.78rem;
margin: 0.25rem 0 0;
}
/* Flash message */ /* Flash message */
.ct-flash { .ct-flash {
background: #1a2a1a; background: #1a2a1a;
+46 -2
View File
@@ -9,6 +9,16 @@ interface PodcastChecklistTask {
required: boolean 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 { interface PodcastChecklistEpisode {
id: string id: string
series: string series: string
@@ -20,6 +30,7 @@ interface PodcastChecklistEpisode {
tasks: Record<string, boolean> tasks: Record<string, boolean>
reminderDays?: number reminderDays?: number
reminderSentAt?: string reminderSentAt?: string
productionStatus?: ProductionStatus
} }
type RecurrenceFreq = 'none' | 'weekly' | 'biweekly' | 'monthly' type RecurrenceFreq = 'none' | 'weekly' | 'biweekly' | 'monthly'
@@ -264,7 +275,7 @@ function CalendarClient() {
// Episode edit popover // Episode edit popover
const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(null) const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(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 // New episode form
const [newEpOpen, setNewEpOpen] = useState(false) const [newEpOpen, setNewEpOpen] = useState(false)
@@ -454,6 +465,7 @@ function CalendarClient() {
datePublished: ep.datePublished?.trim() ?? '', datePublished: ep.datePublished?.trim() ?? '',
startTime: ep.startTime ?? '', startTime: ep.startTime ?? '',
reminderDays: String(ep.reminderDays ?? 0), reminderDays: String(ep.reminderDays ?? 0),
productionStatus: ep.productionStatus ?? '',
}) })
} }
@@ -474,6 +486,7 @@ function CalendarClient() {
startTime: editForm.startTime || undefined, startTime: editForm.startTime || undefined,
reminderDays: newReminder, reminderDays: newReminder,
reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt, reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt,
productionStatus: (editForm.productionStatus as ProductionStatus) || undefined,
} }
: ep : ep
) )
@@ -670,6 +683,7 @@ function CalendarClient() {
// ── Render chip ── // ── Render chip ──
function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) { function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) {
const statusOpt = PROD_STATUS_OPTIONS.find(o => o.value === ep.productionStatus)
return ( return (
<button <button
key={ep.id} key={ep.id}
@@ -677,12 +691,14 @@ function CalendarClient() {
className="cal-ep-chip" className="cal-ep-chip"
draggable draggable
onDragStart={e => onChipDragStart(e, 'episode', ep.id)} onDragStart={e => onChipDragStart(e, 'episode', ep.id)}
title={[ep.series, ep.episodeNumber ? `Ep ${ep.episodeNumber}` : null, ep.title, ep.startTime ? formatTimeDisplay(ep.startTime) : null].filter(Boolean).join(' · ')} title={[ep.series, ep.episodeNumber ? `Ep ${ep.episodeNumber}` : null, ep.title, ep.startTime ? formatTimeDisplay(ep.startTime) : null, statusOpt ? statusOpt.label : null].filter(Boolean).join(' · ')}
style={statusOpt ? { borderLeft: `3px solid ${statusOpt.color}` } : undefined}
onClick={e => { e.stopPropagation(); openEdit(ep) }} onClick={e => { e.stopPropagation(); openEdit(ep) }}
> >
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'} {ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'}
{ep.title && <span className="cal-ep-chip-title"> {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''}</span>} {ep.title && <span className="cal-ep-chip-title"> {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''}</span>}
{ep.startTime && <span className="cal-chip-time">{formatTimeDisplay(ep.startTime)}</span>} {ep.startTime && <span className="cal-chip-time">{formatTimeDisplay(ep.startTime)}</span>}
{statusOpt && <span className="cal-prod-status-dot" style={{ background: statusOpt.color }} title={statusOpt.label} />}
{(ep.reminderDays ?? 0) > 0 && <span className="cal-ep-bell" title={`Reminder: ${ep.reminderDays}d before${ep.reminderSentAt ? ' (sent)' : ''}`}>{ep.reminderSentAt ? '✓' : '🔔'}</span>} {(ep.reminderDays ?? 0) > 0 && <span className="cal-ep-bell" title={`Reminder: ${ep.reminderDays}d before${ep.reminderSentAt ? ' (sent)' : ''}`}>{ep.reminderSentAt ? '✓' : '🔔'}</span>}
</button> </button>
) )
@@ -848,6 +864,27 @@ function CalendarClient() {
</div> </div>
)} )}
{/* Publishing today banner */}
{!loading && (() => {
const todayEps = (checklist?.episodes ?? []).filter(ep => ep.datePublished === todayKey)
if (todayEps.length === 0) return null
return (
<div className="cal-publish-banner">
<span className="cal-publish-banner-icon">📣</span>
<span className="cal-publish-banner-text">
{todayEps.length === 1
? `"${todayEps[0].title || `Ep ${todayEps[0].episodeNumber}`}" publishes today`
: `${todayEps.length} episodes publish today`}
</span>
{todayEps.map(ep => (
<button key={ep.id} type="button" className="em-btn em-btn--sm em-btn--secondary" onClick={() => announceEpisode(ep)}>
Draft announcement
</button>
))}
</div>
)
})()}
{/* Calendar body */} {/* Calendar body */}
<div className="cal-body"> <div className="cal-body">
<div className="cal-main"> <div className="cal-main">
@@ -922,6 +959,13 @@ function CalendarClient() {
<label className="cal-popover-label">Title<input className="ct-input" type="text" value={editForm.title} onChange={e => setEditForm(f => ({ ...f, title: e.target.value }))} /></label> <label className="cal-popover-label">Title<input className="ct-input" type="text" value={editForm.title} onChange={e => setEditForm(f => ({ ...f, title: e.target.value }))} /></label>
<label className="cal-popover-label">Date Published<input className="ct-input" type="date" value={editForm.datePublished} onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))} /></label> <label className="cal-popover-label">Date Published<input className="ct-input" type="date" value={editForm.datePublished} onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))} /></label>
<label className="cal-popover-label">Start Time (optional)<input className="ct-input" type="time" value={editForm.startTime} onChange={e => setEditForm(f => ({ ...f, startTime: e.target.value }))} /></label> <label className="cal-popover-label">Start Time (optional)<input className="ct-input" type="time" value={editForm.startTime} onChange={e => setEditForm(f => ({ ...f, startTime: e.target.value }))} /></label>
<label className="cal-popover-label">
Production Status
<select className="ct-input" value={editForm.productionStatus} onChange={e => setEditForm(f => ({ ...f, productionStatus: e.target.value as ProductionStatus | '' }))}>
<option value=""> not set </option>
{PROD_STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
<label className="cal-popover-label"> <label className="cal-popover-label">
Email Reminder Email Reminder
<select className="ct-input" value={editForm.reminderDays} onChange={e => setEditForm(f => ({ ...f, reminderDays: e.target.value }))}> <select className="ct-input" value={editForm.reminderDays} onChange={e => setEditForm(f => ({ ...f, reminderDays: e.target.value }))}>
+26
View File
@@ -240,6 +240,10 @@ function ContactsClient() {
const [mergeSearch, setMergeSearch] = useState('') const [mergeSearch, setMergeSearch] = useState('')
const [mergeBusy, setMergeBusy] = useState(false) const [mergeBusy, setMergeBusy] = useState(false)
const [mergeMsg, setMergeMsg] = useState('') const [mergeMsg, setMergeMsg] = useState('')
// Drip trigger
const [dripBusyKey, setDripBusyKey] = useState<string | null>(null)
const [dripMsgKey, setDripMsgKey] = useState<string | null>(null)
const [dripMsgText, setDripMsgText] = useState('')
// History state // History state
const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(null) const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(null)
@@ -447,6 +451,20 @@ function ContactsClient() {
// ── Merge ── // ── Merge ──
async function triggerDrip(c: Contact) {
setDripBusyKey(c.key); setDripMsgKey(c.key); setDripMsgText('')
try {
const res = await fetch('/api/admin-contacts/trigger-drip', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: c.email }),
})
const d = await res.json() as { ok?: boolean; note?: string; message?: string }
setDripMsgText(res.ok ? (d.note ?? 'Drip triggered.') : (d.message ?? 'Failed.'))
} catch { setDripMsgText('Network error.') }
setDripBusyKey(null)
}
async function doMerge(keepContact: Contact, mergeContact: Contact) { async function doMerge(keepContact: Contact, mergeContact: Contact) {
if (!confirm(`Merge "${mergeContact.name || mergeContact.email}" into "${keepContact.name || keepContact.email}"? All messages from ${mergeContact.email} will be reassigned to ${keepContact.email}.`)) return if (!confirm(`Merge "${mergeContact.name || mergeContact.email}" into "${keepContact.name || keepContact.email}"? All messages from ${mergeContact.email} will be reassigned to ${keepContact.email}.`)) return
setMergeBusy(true) setMergeBusy(true)
@@ -735,8 +753,16 @@ function ContactsClient() {
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setMergePickerKey(prev => prev === c.key ? null : c.key); setMergeSearch(''); setMergeMsg('') }}> <button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setMergePickerKey(prev => prev === c.key ? null : c.key); setMergeSearch(''); setMergeMsg('') }}>
{isMergeTarget ? 'Cancel merge' : 'Merge with…'} {isMergeTarget ? 'Cancel merge' : 'Merge with…'}
</button> </button>
{c.email && (
<button type="button" className="em-btn em-btn--ghost em-btn--sm" title="Sync to Resend audience and trigger drip automation" onClick={() => triggerDrip(c)} disabled={dripBusyKey === c.key}>
{dripBusyKey === c.key ? 'Triggering…' : '▶ Drip'}
</button>
)}
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={cancelEdit}>Cancel</button> <button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={cancelEdit}>Cancel</button>
</div> </div>
{dripMsgKey === c.key && dripMsgText && (
<p className="ct-drip-msg">{dripMsgText}</p>
)}
</div> </div>
) : ( ) : (
/* ── Display mode ── */ /* ── Display mode ── */
+92
View File
@@ -237,6 +237,16 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false) const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
const [showHistory, setShowHistory] = useState(false) const [showHistory, setShowHistory] = useState(false)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [showBroadcasts, setShowBroadcasts] = useState(false)
const [showAuditLog, setShowAuditLog] = useState(false)
// Broadcasts
const [broadcastSubject, setBroadcastSubject] = useState('')
const [broadcastPreview, setBroadcastPreview] = useState('')
const [broadcastBody, setBroadcastBody] = useState('')
const [broadcastBusy, setBroadcastBusy] = useState(false)
const [broadcastMsg, setBroadcastMsg] = useState('')
// Audit log
const [auditEntries, setAuditEntries] = useState<{ id: string; action: string; details: string; at: string }[]>([])
const [emailSettings, setEmailSettings] = useState<EmailSettings>({ signature: 'Grace and peace,\nVerse by Verse with Nate' }) const [emailSettings, setEmailSettings] = useState<EmailSettings>({ signature: 'Grace and peace,\nVerse by Verse with Nate' })
const [settingsSig, setSettingsSig] = useState('') const [settingsSig, setSettingsSig] = useState('')
const [settingsSaving, setSettingsSaving] = useState(false) const [settingsSaving, setSettingsSaving] = useState(false)
@@ -520,6 +530,32 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
setTimeout(() => setActionMsg(''), 3000) setTimeout(() => setActionMsg(''), 3000)
} }
async function handleBroadcast(e: React.FormEvent) {
e.preventDefault(); setBroadcastBusy(true); setBroadcastMsg('')
try {
const res = await fetch('/api/admin-broadcasts/send', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject: broadcastSubject, previewText: broadcastPreview, text: broadcastBody, from: REPLY_FROM_OPTIONS[0].value }),
})
const d = await res.json() as { ok?: boolean; broadcastId?: string; message?: string }
if (!res.ok) { setBroadcastMsg(d.message ?? 'Broadcast failed.'); setBroadcastBusy(false); return }
setBroadcastMsg(`Broadcast sent! (id: ${d.broadcastId ?? '?'})`)
setBroadcastSubject(''); setBroadcastPreview(''); setBroadcastBody('')
} catch { setBroadcastMsg('Network error.') }
setBroadcastBusy(false)
}
async function loadAuditLog() {
try {
const res = await fetch('/api/admin-audit-log', { credentials: 'include' })
if (res.ok) {
const d = await res.json() as { entries: { id: string; action: string; details: string; at: string }[] }
setAuditEntries(d.entries ?? [])
}
} catch { /* silent */ }
}
async function handleAIDraft(submissionId: string) { async function handleAIDraft(submissionId: string) {
setDraftBusy(true); setDraftMsg('') setDraftBusy(true); setDraftMsg('')
try { try {
@@ -707,6 +743,8 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
</div> </div>
<div className="em-header-actions"> <div className="em-header-actions">
<button type="button" className="em-btn em-btn--primary" onClick={openCompose}>Compose</button> <button type="button" className="em-btn em-btn--primary" onClick={openCompose}>Compose</button>
<button type="button" className="em-btn em-btn--ghost" onClick={() => { setShowBroadcasts(p => !p); setBroadcastMsg('') }}>📢 Broadcast</button>
<button type="button" className="em-btn em-btn--ghost" onClick={() => { setShowAuditLog(p => !p); if (!showAuditLog) loadAuditLog() }}>📋 Audit</button>
<Link to="/contacts" className="em-btn em-btn--ghost">Contacts</Link> <Link to="/contacts" className="em-btn em-btn--ghost">Contacts</Link>
<Link to="/calendar" className="em-btn em-btn--ghost">Calendar</Link> <Link to="/calendar" className="em-btn em-btn--ghost">Calendar</Link>
<Link to="/admin" className="em-btn em-btn--ghost"> Admin</Link> <Link to="/admin" className="em-btn em-btn--ghost"> Admin</Link>
@@ -1261,6 +1299,60 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
</div> </div>
)} )}
{/* Broadcasts drawer */}
{showBroadcasts && (
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowBroadcasts(false) }}>
<div className="em-drawer em-drawer--wide">
<div className="em-drawer-header">
<h3>📢 Send Broadcast</h3>
<button type="button" className="em-compose-close" onClick={() => setShowBroadcasts(false)}>×</button>
</div>
<div className="em-drawer-body">
<p className="em-settings-note">Sends to all subscribers in your Resend audience via the Resend Broadcasts API. Resend handles unsubscribes and compliance.</p>
<form className="em-broadcast-form" onSubmit={handleBroadcast}>
<label className="em-compose-label">Subject *<input className="em-compose-input" type="text" required placeholder="New episode: …" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} /></label>
<label className="em-compose-label">Preview text<input className="em-compose-input" type="text" placeholder="Short preview shown in inbox (optional)" value={broadcastPreview} onChange={e => setBroadcastPreview(e.target.value)} /></label>
<label className="em-compose-label">
Body *
<textarea className="em-compose-body em-broadcast-body" required rows={10} placeholder="Write your newsletter in plain text. Double line-break = new paragraph." value={broadcastBody} onChange={e => setBroadcastBody(e.target.value)} />
</label>
{broadcastMsg && <p className={`em-status-msg${broadcastMsg.startsWith('Broadcast sent') ? ' em-status-msg--ok' : ''}`}>{broadcastMsg}</p>}
<div className="em-drawer-actions">
<button type="submit" className="em-btn em-btn--primary" disabled={broadcastBusy || !broadcastSubject || !broadcastBody}>
{broadcastBusy ? 'Sending…' : 'Send Broadcast'}
</button>
<button type="button" className="em-btn em-btn--ghost" onClick={() => setShowBroadcasts(false)}>Cancel</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Audit log drawer */}
{showAuditLog && (
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowAuditLog(false) }}>
<div className="em-drawer em-drawer--wide">
<div className="em-drawer-header">
<h3>📋 Activity Log</h3>
<button type="button" className="em-compose-close" onClick={() => setShowAuditLog(false)}>×</button>
</div>
<div className="em-drawer-body">
{auditEntries.length === 0 && <p className="em-list-empty">No activity recorded yet.</p>}
<div className="em-audit-list">
{auditEntries.map(entry => (
<div key={entry.id} className="em-audit-item">
<span className="em-audit-action">{entry.action}</span>
<span className="em-audit-details">{entry.details}</span>
<span className="em-audit-date">{formatShortDate(entry.at)}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Footer toolbar */} {/* Footer toolbar */}
<div className="em-footer-toolbar"> <div className="em-footer-toolbar">
{config && !config.canSendReplies && ( {config && !config.canSendReplies && (