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