Add webhook outbound, Contact→Calendar, checklist automation; v1.1.26

- Webhook CRUD (GET/POST/DELETE /api/admin-webhooks, POST .../test) with
   Webhooks drawer in EmailPage; fires contact.new and reply.sent events
- Contact→Calendar: history panel shows checklist episodes within ±30 days
- Checklist automation: saveEdit auto-sets productionStatus (idea→scheduled
  for future dates, idea→published for past); ⚙ Tasks button generates
  Record (−14d) and Edit (−7d) calendar task events for scheduled episodes
- Audit log captures webhook-added, webhook-removed, webhook-test events
- Webhook infrastructure: state, disk persistence, fire-and-forget with
  AbortSignal.timeout(8000), loaded on startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-29 08:03:30 -04:00
parent f5c4557e9f
commit 111b559865
10 changed files with 322 additions and 25 deletions
+20
View File
@@ -11741,3 +11741,23 @@
.cal-sidebar { display: none; }
.cal-month-title { min-width: 120px; font-size: 0.95rem; }
}
/* ── Webhook management UI ──────────────────────────────────────────────── */
.em-webhook-list { display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 1.2rem; }
.em-webhook-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; padding: 0.6rem 0.8rem; }
.em-webhook-info { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.em-webhook-label { font-size: 0.75rem; color: #a0a0b8; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
.em-webhook-url { font-size: 0.8rem; color: #c0bcd4; word-break: break-all; }
.em-webhook-events { font-size: 0.72rem; color: #6b7280; font-family: monospace; }
.em-webhook-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.em-webhook-test-msg { font-size: 0.75rem; color: #4ade80; white-space: nowrap; }
.em-webhook-add-form { border-top: 1px solid rgba(255,255,255,0.07); padding-top: 1rem; }
.em-webhook-add-title { font-size: 0.85rem; font-weight: 600; color: #c0bcd4; margin: 0 0 0.75rem; }
/* ── Calendar flash / task generation ───────────────────────────────────── */
.cal-flash-msg { font-size: 0.78rem; color: #4ade80; padding: 0.2rem 0.5rem; border-radius: 4px; background: rgba(74,222,128,0.08); border: 1px solid rgba(74,222,128,0.2); }
/* ── Contacts → Calendar context ────────────────────────────────────────── */
.ct-history-ep-section { margin-top: 0.6rem; border-top: 1px solid rgba(255,255,255,0.07); padding-top: 0.6rem; }
.ct-history-ep-label { font-size: 0.72rem; color: #6b7280; margin: 0 0 0.4rem; text-transform: uppercase; letter-spacing: 0.04em; }
.ct-history-item--ep { background: rgba(99,102,241,0.07); border-left: 2px solid #6366f1; }
+44 -1
View File
@@ -294,6 +294,7 @@ function CalendarClient() {
// Drag state
const [dragOverKey, setDragOverKey] = useState<string | null>(null)
const [calFlash, setCalFlash] = useState('')
const dragDataRef = useRef<{ type: 'episode' | 'event'; id: string } | null>(null)
// ── Load ──
@@ -475,6 +476,16 @@ function CalendarClient() {
const newReminder = Number(editForm.reminderDays)
const dateChanged = newDate !== (editEp.datePublished?.trim() ?? '')
const reminderChanged = newReminder !== (editEp.reminderDays ?? 0)
let nextStatus = (editForm.productionStatus as ProductionStatus) || undefined
if (dateChanged && newDate) {
const today = new Date().toISOString().slice(0, 10)
const isPast = newDate <= today
if (!nextStatus || nextStatus === 'idea') {
nextStatus = isPast ? 'published' : 'scheduled'
}
}
const updated = checklist.episodes.map(ep =>
ep.id === editEp.id
? {
@@ -486,7 +497,7 @@ function CalendarClient() {
startTime: editForm.startTime || undefined,
reminderDays: newReminder,
reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt,
productionStatus: (editForm.productionStatus as ProductionStatus) || undefined,
productionStatus: nextStatus,
}
: ep
)
@@ -494,6 +505,36 @@ function CalendarClient() {
setEditEp(null)
}
async function generateTasks(ep: PodcastChecklistEpisode) {
if (!ep.datePublished) return
const epDate = new Date(ep.datePublished + 'T12:00:00')
const label = ep.title || [ep.series, ep.episodeNumber != null ? `Ep. ${ep.episodeNumber}` : null].filter(Boolean).join(' ')
const tasks = [
{ offsetDays: -14, title: `Record: ${label}` },
{ offsetDays: -7, title: `Edit: ${label}` },
]
for (const t of tasks) {
const d = new Date(epDate)
d.setDate(d.getDate() + t.offsetDays)
const dateKey = d.toISOString().slice(0, 10)
try {
const res = await fetch('/api/admin-calendar-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ type: 'task', title: t.title, date: dateKey, notes: '', reminderDays: 0 }),
})
if (res.ok) {
const data = await res.json() as { event: CalendarEvent }
setEvents(prev => [data.event, ...prev])
}
} catch { /* silent */ }
}
setCalFlash(`Tasks generated for "${label}"`)
setTimeout(() => setCalFlash(''), 3000)
setEditEp(null)
}
function unschedule(ep: PodcastChecklistEpisode) {
if (!checklist) return
const updated = checklist.episodes.map(e => e.id === ep.id ? { ...e, datePublished: '' } : e)
@@ -782,6 +823,7 @@ function CalendarClient() {
<button type="button" className="cal-nav-btn" onClick={nextPeriod} aria-label="Next"></button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={gotoToday}>Today</button>
{saving && <span className="cal-saving">Saving</span>}
{calFlash && <span className="cal-flash-msg">{calFlash}</span>}
</div>
<div className="cal-header-center">
<div className="cal-view-toggle">
@@ -977,6 +1019,7 @@ function CalendarClient() {
<div className="cal-popover-actions">
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEdit}>Save</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => announceEpisode(editEp)} title="Open email compose pre-filled to announce this episode"> Announce</button>
{editEp.datePublished && <button type="button" className="em-btn em-btn--ghost em-btn--sm" title="Create Record (14d) and Edit (7d) task events" onClick={() => generateTasks(editEp)}> Tasks</button>}
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => unschedule(editEp)}>Unschedule</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEp(null)}>Cancel</button>
</div>
+61 -23
View File
@@ -67,6 +67,14 @@ type ConversationItem =
| { kind: 'inbound'; date: string; name: string; message: string; source?: string; id: string }
| { kind: 'outbound'; date: string; subject: string; preview: string; toEmail: string }
interface ChecklistEpisode {
id: string
series: string
episodeNumber: number | null
title: string
datePublished: string
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const TAG_PALETTE = [
@@ -222,6 +230,7 @@ export default function ContactsShell() {
function ContactsClient() {
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
const [replyHistory, setReplyHistory] = useState<ReplyHistoryItem[]>([])
const [checklistEpisodes, setChecklistEpisodes] = useState<ChecklistEpisode[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [tagFilter, setTagFilter] = useState('')
@@ -268,9 +277,10 @@ function ContactsClient() {
const reload = useCallback(async () => {
try {
const [subRes, histRes] = await Promise.all([
const [subRes, histRes, clRes] = await Promise.all([
fetch('/api/admin-contact-submissions', { credentials: 'include' }),
fetch('/api/admin-contact-reply-history', { credentials: 'include' }),
fetch('/api/admin-podcast-checklist', { credentials: 'include' }),
])
if (subRes.ok) {
const d = await subRes.json() as { submissions: ContactSubmission[] }
@@ -280,6 +290,10 @@ function ContactsClient() {
const d = await histRes.json() as { items: ReplyHistoryItem[] }
setReplyHistory(d.items ?? [])
}
if (clRes.ok) {
const d = await clRes.json() as { checklist?: { episodes?: ChecklistEpisode[] } }
setChecklistEpisodes(d.checklist?.episodes ?? [])
}
} catch { /* silent */ }
setLoading(false)
}, [])
@@ -825,31 +839,55 @@ function ContactsClient() {
)}
{/* Conversation history panel */}
{historyOpen && !isEditing && (
<div className="ct-history-panel">
{history.length === 0 && <p className="ct-history-empty">No conversation history.</p>}
{history.map((item, i) => (
item.kind === 'inbound' ? (
<div key={item.id || i} className="ct-history-item ct-history-item--in">
<div className="ct-history-meta">
<span className="ct-history-who">{item.name}</span>
<span className="ct-history-date">{fmtShort(item.date)}</span>
{historyOpen && !isEditing && (() => {
const relatedEps = checklistEpisodes.filter(ep => {
if (!ep.datePublished) return false
const epMs = new Date(ep.datePublished + 'T12:00:00').getTime()
const refMs = new Date(c.latestAt).getTime()
return Math.abs(epMs - refMs) <= 30 * 24 * 60 * 60 * 1000
})
return (
<div className="ct-history-panel">
{history.length === 0 && relatedEps.length === 0 && <p className="ct-history-empty">No conversation history.</p>}
{history.map((item, i) => (
item.kind === 'inbound' ? (
<div key={item.id || i} className="ct-history-item ct-history-item--in">
<div className="ct-history-meta">
<span className="ct-history-who">{item.name}</span>
<span className="ct-history-date">{fmtShort(item.date)}</span>
</div>
<p className="ct-history-body">{item.message || '(no message body)'}</p>
</div>
<p className="ct-history-body">{item.message || '(no message body)'}</p>
</div>
) : (
<div key={i} className="ct-history-item ct-history-item--out">
<div className="ct-history-meta">
<span className="ct-history-who">You {item.toEmail}</span>
<span className="ct-history-date">{fmtShort(item.date)}</span>
) : (
<div key={i} className="ct-history-item ct-history-item--out">
<div className="ct-history-meta">
<span className="ct-history-who">You {item.toEmail}</span>
<span className="ct-history-date">{fmtShort(item.date)}</span>
</div>
<div className="ct-history-subject">{item.subject}</div>
<p className="ct-history-body">{item.preview}</p>
</div>
<div className="ct-history-subject">{item.subject}</div>
<p className="ct-history-body">{item.preview}</p>
)
))}
{relatedEps.length > 0 && (
<div className="ct-history-ep-section">
<p className="ct-history-ep-label">📅 Episodes near this contact</p>
{relatedEps.map(ep => {
const parts = [ep.series, ep.episodeNumber != null ? `Ep. ${ep.episodeNumber}` : null, ep.title].filter(Boolean)
return (
<div key={ep.id} className="ct-history-item ct-history-item--ep">
<div className="ct-history-meta">
<span className="ct-history-who">{parts.join(' ') || 'Untitled episode'}</span>
<span className="ct-history-date">{ep.datePublished}</span>
</div>
</div>
)
})}
</div>
)
))}
</div>
)}
)}
</div>
)
})()}
</div>
)
})}
+95
View File
@@ -247,6 +247,14 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
const [broadcastMsg, setBroadcastMsg] = useState('')
// Audit log
const [auditEntries, setAuditEntries] = useState<{ id: string; action: string; details: string; at: string }[]>([])
// Webhooks
const [showWebhooks, setShowWebhooks] = useState(false)
const [webhooks, setWebhooks] = useState<{ id: string; url: string; label: string; events: string[]; createdAt: string }[]>([])
const [wNewUrl, setWNewUrl] = useState('')
const [wNewLabel, setWNewLabel] = useState('')
const [wAddBusy, setWAddBusy] = useState(false)
const [wAddMsg, setWAddMsg] = useState('')
const [wTestMsg, setWTestMsg] = useState<Record<string, string>>({})
const [emailSettings, setEmailSettings] = useState<EmailSettings>({ signature: 'Grace and peace,\nVerse by Verse with Nate' })
const [settingsSig, setSettingsSig] = useState('')
const [settingsSaving, setSettingsSaving] = useState(false)
@@ -556,6 +564,51 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
} catch { /* silent */ }
}
async function loadWebhooks() {
try {
const res = await fetch('/api/admin-webhooks', { credentials: 'include' })
if (res.ok) {
const d = await res.json() as { webhooks: typeof webhooks }
setWebhooks(d.webhooks ?? [])
}
} catch { /* silent */ }
}
async function handleAddWebhook(e: React.FormEvent) {
e.preventDefault()
setWAddBusy(true); setWAddMsg('')
try {
const res = await fetch('/api/admin-webhooks', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: wNewUrl, label: wNewLabel, events: ['*'] }),
})
const d = await res.json() as { ok?: boolean; webhook?: typeof webhooks[0]; message?: string }
if (!res.ok) { setWAddMsg(d.message ?? 'Failed to add webhook.'); setWAddBusy(false); return }
setWebhooks(prev => [...prev, d.webhook!])
setWNewUrl(''); setWNewLabel('')
setWAddMsg('Webhook added.')
} catch { setWAddMsg('Network error.') }
setWAddBusy(false)
}
async function deleteWebhook(id: string) {
try {
await fetch(`/api/admin-webhooks/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include' })
setWebhooks(prev => prev.filter(w => w.id !== id))
} catch { /* silent */ }
}
async function testWebhook(id: string) {
setWTestMsg(prev => ({ ...prev, [id]: 'Testing…' }))
try {
const res = await fetch(`/api/admin-webhooks/${encodeURIComponent(id)}/test`, { method: 'POST', credentials: 'include' })
const d = await res.json() as { ok?: boolean; status?: number; message?: string }
setWTestMsg(prev => ({ ...prev, [id]: res.ok ? `${d.status}` : (d.message ?? 'Failed') }))
} catch { setWTestMsg(prev => ({ ...prev, [id]: 'Network error.' })) }
setTimeout(() => setWTestMsg(prev => { const n = { ...prev }; delete n[id]; return n }), 4000)
}
async function handleAIDraft(submissionId: string) {
setDraftBusy(true); setDraftMsg('')
try {
@@ -745,6 +798,7 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
<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>
<button type="button" className="em-btn em-btn--ghost" onClick={() => { setShowWebhooks(p => !p); if (!showWebhooks) loadWebhooks() }}> Webhooks</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>
@@ -1353,6 +1407,47 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
</div>
)}
{/* Webhooks drawer */}
{showWebhooks && (
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowWebhooks(false) }}>
<div className="em-drawer em-drawer--wide">
<div className="em-drawer-header">
<h3> Outbound Webhooks</h3>
<button type="button" className="em-compose-close" onClick={() => setShowWebhooks(false)}>×</button>
</div>
<div className="em-drawer-body">
<p className="em-settings-note">Webhooks fire on <code>contact.new</code> and <code>reply.sent</code> events. Connect Zapier or any endpoint.</p>
<div className="em-webhook-list">
{webhooks.length === 0 && <p className="em-list-empty">No webhooks configured.</p>}
{webhooks.map(wh => (
<div key={wh.id} className="em-webhook-item">
<div className="em-webhook-info">
{wh.label && <span className="em-webhook-label">{wh.label}</span>}
<span className="em-webhook-url">{wh.url}</span>
<span className="em-webhook-events">{(wh.events ?? []).join(', ')}</span>
</div>
<div className="em-webhook-actions">
{wTestMsg[wh.id] && <span className="em-webhook-test-msg">{wTestMsg[wh.id]}</span>}
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => testWebhook(wh.id)}>Test</button>
<button type="button" className="em-btn em-btn--danger em-btn--sm" onClick={() => deleteWebhook(wh.id)}>Delete</button>
</div>
</div>
))}
</div>
<form className="em-webhook-add-form" onSubmit={handleAddWebhook}>
<h4 className="em-webhook-add-title">Add Webhook</h4>
<label className="em-compose-label">Label (optional)<input className="em-compose-input" type="text" placeholder="My Zapier hook" value={wNewLabel} onChange={e => setWNewLabel(e.target.value)} /></label>
<label className="em-compose-label">URL *<input className="em-compose-input" type="url" required placeholder="https://hooks.zapier.com/…" value={wNewUrl} onChange={e => setWNewUrl(e.target.value)} /></label>
{wAddMsg && <p className={`em-status-msg${wAddMsg.startsWith('Webhook added') ? ' em-status-msg--ok' : ''}`}>{wAddMsg}</p>}
<div className="em-drawer-actions">
<button type="submit" className="em-btn em-btn--primary" disabled={wAddBusy || !wNewUrl}>{wAddBusy ? 'Adding…' : 'Add Webhook'}</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Footer toolbar */}
<div className="em-footer-toolbar">
{config && !config.canSendReplies && (