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:
+90
@@ -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
@@ -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 }))}>
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user