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