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