From 111b5598654a6f2f3acacb2ab418e01583476f1f Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 29 Jul 2026 08:03:30 -0400 Subject: [PATCH] =?UTF-8?q?Add=20webhook=20outbound,=20Contact=E2=86=92Cal?= =?UTF-8?q?endar,=20checklist=20automation;=20v1.1.26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- package.json | 2 +- server.js | 2 + server/config.js | 1 + server/data.js | 43 ++++++++++++++++++ server/routes/contact.js | 52 ++++++++++++++++++++++ server/state.js | 3 ++ src/App.css | 20 +++++++++ src/CalendarPage.tsx | 45 ++++++++++++++++++- src/ContactsPage.tsx | 84 +++++++++++++++++++++++++---------- src/EmailPage.tsx | 95 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 322 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index ebb1149..5310597 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.25", + "version": "1.1.26", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index c24c484..04cfa7a 100644 --- a/server.js +++ b/server.js @@ -26,6 +26,7 @@ import { loadEmailSettingsFromDisk, loadCalendarEventsFromDisk, loadAuditLogFromDisk, + loadWebhooksFromDisk, createBackupSnapshot, refreshContentCaches, queueHitStatsWrite, @@ -153,6 +154,7 @@ Promise.all([ loadEmailSettingsFromDisk(), loadCalendarEventsFromDisk(), loadAuditLogFromDisk(), + loadWebhooksFromDisk(), refreshContentCaches(), ]) .catch(err => { diff --git a/server/config.js b/server/config.js index cf5ae96..3503e9b 100644 --- a/server/config.js +++ b/server/config.js @@ -61,6 +61,7 @@ export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json' export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json') export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json') export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json') +export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json') export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon export const DIST_DIR = path.join(ROOT_DIR, 'dist') diff --git a/server/data.js b/server/data.js index 6993583..84c2187 100644 --- a/server/data.js +++ b/server/data.js @@ -33,6 +33,7 @@ import { EMAIL_SETTINGS_FILE, CALENDAR_EVENTS_FILE, AUDIT_LOG_FILE, + WEBHOOKS_FILE, EMPTY_HIT_STATS, EMPTY_VISITOR_STATS, DEFAULT_REPLY_TEMPLATES, @@ -475,6 +476,48 @@ export function loadAuditLogFromDisk() { .catch(() => {}) } +// ── Webhooks ────────────────────────────────────────────────────────────── + +export function queueWebhooksWrite() { + state.webhooksWritePromise = state.webhooksWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(WEBHOOKS_FILE, JSON.stringify({ webhooks: state.webhooks }, null, 2), 'utf8') + }) + .catch(err => { console.error('[webhooks] write failed:', err) }) +} + +export function loadWebhooksFromDisk() { + return readFile(WEBHOOKS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed?.webhooks)) state.webhooks = parsed.webhooks.slice(0, 50) + }) + .catch(() => {}) +} + +export async function fireWebhooks(eventType, payload) { + const targets = state.webhooks.filter(w => + Array.isArray(w.events) && (w.events.includes(eventType) || w.events.includes('*')) + ) + if (targets.length === 0) return + const body = JSON.stringify({ event: eventType, data: payload, firedAt: new Date().toISOString() }) + for (const wh of targets) { + fetch(wh.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' }, + body, + signal: AbortSignal.timeout(8000), + }) + .then(r => { + appendAuditEntry('webhook-fired', `${eventType} → ${wh.url} (${r.status})`) + }) + .catch(err => { + appendAuditEntry('webhook-error', `${eventType} → ${wh.url}: ${String(err?.message ?? err).slice(0, 200)}`) + }) + } +} + // ── Podcast checklist ────────────────────────────────────────────────────── export function queuePodcastChecklistWrite() { diff --git a/server/routes/contact.js b/server/routes/contact.js index 399f9fa..49d893b 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -23,6 +23,8 @@ import { sanitizeReplyTemplates, sanitizeReplyHistory, appendAuditEntry, + queueWebhooksWrite, + fireWebhooks, } from '../data.js' import { noteContactEmailCooldown, @@ -154,6 +156,7 @@ export function register(app) { state.contactSubmissions.unshift(submission) state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) queueContactSubmissionsWrite() + fireWebhooks('contact.new', { name: trimmedName, email: trimmedEmail, message: trimmedMessage, messageType: normalizedMessageType, submittedAt: submission.submittedAt }) if (normalizedMessageType === 'question') { const question = { @@ -693,6 +696,7 @@ export function register(app) { state.replyHistory = state.replyHistory.slice(0, 500) queueReplyHistoryWrite() appendAuditEntry('reply-sent', `To: ${submission.email} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`) + fireWebhooks('reply.sent', { toEmail: submission.email, toName: submission.name, subject, sentAt: new Date().toISOString() }) res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null }) } catch (err) { @@ -928,4 +932,52 @@ export function register(app) { res.status(502).json({ message: 'AI draft request failed.' }) } }) + + // ── Webhook CRUD ─────────────────────────────────────────────────────────── + + app.get('/api/admin-webhooks', requireAdminAuth, (_req, res) => { + res.json({ webhooks: state.webhooks }) + }) + + app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => { + const url = typeof req.body?.url === 'string' ? req.body.url.trim() : '' + const events = Array.isArray(req.body?.events) ? req.body.events.filter(e => typeof e === 'string' && e.trim()) : ['*'] + const label = typeof req.body?.label === 'string' ? req.body.label.trim().slice(0, 100) : '' + if (!url || !/^https?:\/\/./.test(url)) { + res.status(400).json({ message: 'A valid http/https URL is required.' }); return + } + const webhook = { id: randomUUID(), url: url.slice(0, 500), label, events, createdAt: new Date().toISOString() } + state.webhooks.push(webhook) + state.webhooks = state.webhooks.slice(0, 50) + queueWebhooksWrite() + appendAuditEntry('webhook-added', url) + res.json({ ok: true, webhook }) + }) + + app.delete('/api/admin-webhooks/:id', requireAdminAuth, (req, res) => { + const { id } = req.params + const before = state.webhooks.length + state.webhooks = state.webhooks.filter(w => w.id !== id) + if (state.webhooks.length === before) { res.status(404).json({ message: 'Webhook not found.' }); return } + queueWebhooksWrite() + appendAuditEntry('webhook-removed', `id: ${id}`) + res.json({ ok: true }) + }) + + app.post('/api/admin-webhooks/:id/test', requireAdminAuth, async (req, res) => { + const wh = state.webhooks.find(w => w.id === req.params.id) + if (!wh) { res.status(404).json({ message: 'Webhook not found.' }); return } + try { + const r = await fetch(wh.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' }, + body: JSON.stringify({ event: 'test', data: { message: 'Test event from Siteforge.' }, firedAt: new Date().toISOString() }), + signal: AbortSignal.timeout(8000), + }) + appendAuditEntry('webhook-test', `${wh.url} → ${r.status}`) + res.json({ ok: true, status: r.status }) + } catch (err) { + res.status(502).json({ message: String(err?.message ?? 'Test failed.') }) + } + }) } diff --git a/server/state.js b/server/state.js index 79a159f..1f83a66 100644 --- a/server/state.js +++ b/server/state.js @@ -99,6 +99,9 @@ export const state = { auditLog: [], auditLogWritePromise: Promise.resolve(), + webhooks: [], + webhooksWritePromise: Promise.resolve(), + lastBackupStatus: { ok: true, at: null, error: null, file: null }, lastCachePurgeStatus: { ok: true, at: null, error: null }, lastDeployHookStatus: { ok: true, at: null, error: null }, diff --git a/src/App.css b/src/App.css index 0bbdfb2..b9304bc 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/CalendarPage.tsx b/src/CalendarPage.tsx index bdf9ad1..45f9320 100644 --- a/src/CalendarPage.tsx +++ b/src/CalendarPage.tsx @@ -294,6 +294,7 @@ function CalendarClient() { // Drag state const [dragOverKey, setDragOverKey] = useState(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() { {saving && Saving…} + {calFlash && {calFlash}}
@@ -977,6 +1019,7 @@ function CalendarClient() {
+ {editEp.datePublished && }
diff --git a/src/ContactsPage.tsx b/src/ContactsPage.tsx index d171660..0fb03c7 100644 --- a/src/ContactsPage.tsx +++ b/src/ContactsPage.tsx @@ -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([]) const [replyHistory, setReplyHistory] = useState([]) + const [checklistEpisodes, setChecklistEpisodes] = useState([]) 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 && ( -
- {history.length === 0 &&

No conversation history.

} - {history.map((item, i) => ( - item.kind === 'inbound' ? ( -
-
- {item.name} - {fmtShort(item.date)} + {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 ( +
+ {history.length === 0 && relatedEps.length === 0 &&

No conversation history.

} + {history.map((item, i) => ( + item.kind === 'inbound' ? ( +
+
+ {item.name} + {fmtShort(item.date)} +
+

{item.message || '(no message body)'}

-

{item.message || '(no message body)'}

-
- ) : ( -
-
- You → {item.toEmail} - {fmtShort(item.date)} + ) : ( +
+
+ You → {item.toEmail} + {fmtShort(item.date)} +
+
{item.subject}
+

{item.preview}

-
{item.subject}
-

{item.preview}

+ ) + ))} + {relatedEps.length > 0 && ( +
+

📅 Episodes near this contact

+ {relatedEps.map(ep => { + const parts = [ep.series, ep.episodeNumber != null ? `Ep. ${ep.episodeNumber}` : null, ep.title].filter(Boolean) + return ( +
+
+ {parts.join(' – ') || 'Untitled episode'} + {ep.datePublished} +
+
+ ) + })}
- ) - ))} -
- )} + )} +
+ ) + })()}
) })} diff --git a/src/EmailPage.tsx b/src/EmailPage.tsx index df15867..ff9e7e2 100644 --- a/src/EmailPage.tsx +++ b/src/EmailPage.tsx @@ -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>({}) const [emailSettings, setEmailSettings] = useState({ 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 }) { + Contacts Calendar ← Admin @@ -1353,6 +1407,47 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
)} + {/* Webhooks drawer */} + {showWebhooks && ( +
{ if (e.target === e.currentTarget) setShowWebhooks(false) }}> +
+
+

⚡ Outbound Webhooks

+ +
+
+

Webhooks fire on contact.new and reply.sent events. Connect Zapier or any endpoint.

+
+ {webhooks.length === 0 &&

No webhooks configured.

} + {webhooks.map(wh => ( +
+
+ {wh.label && {wh.label}} + {wh.url} + {(wh.events ?? []).join(', ')} +
+
+ {wTestMsg[wh.id] && {wTestMsg[wh.id]}} + + +
+
+ ))} +
+
+

Add Webhook

+ + + {wAddMsg &&

{wAddMsg}

} +
+ +
+
+
+
+
+ )} + {/* Footer toolbar */}
{config && !config.canSendReplies && (