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
+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 }))}>