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
+90
View File
@@ -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;
+46 -2
View File
@@ -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<string, boolean>
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<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
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 (
<button
key={ep.id}
@@ -677,12 +691,14 @@ function CalendarClient() {
className="cal-ep-chip"
draggable
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) }}
>
{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.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>}
</button>
)
@@ -848,6 +864,27 @@ function CalendarClient() {
</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 */}
<div className="cal-body">
<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">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">
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">
Email Reminder
<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 [mergeBusy, setMergeBusy] = useState(false)
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
const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(null)
@@ -447,6 +451,20 @@ function ContactsClient() {
// ── 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) {
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)
@@ -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('') }}>
{isMergeTarget ? 'Cancel merge' : 'Merge with…'}
</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>
</div>
{dripMsgKey === c.key && dripMsgText && (
<p className="ct-drip-msg">{dripMsgText}</p>
)}
</div>
) : (
/* ── Display mode ── */
+92
View File
@@ -237,6 +237,16 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
const [showHistory, setShowHistory] = 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 [settingsSig, setSettingsSig] = useState('')
const [settingsSaving, setSettingsSaving] = useState(false)
@@ -520,6 +530,32 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
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) {
setDraftBusy(true); setDraftMsg('')
try {
@@ -707,6 +743,8 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
</div>
<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--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="/calendar" className="em-btn em-btn--ghost">Calendar</Link>
<Link to="/admin" className="em-btn em-btn--ghost"> Admin</Link>
@@ -1261,6 +1299,60 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
</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 */}
<div className="em-footer-toolbar">
{config && !config.canSendReplies && (