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