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
+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 ── */