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
+1
View File
@@ -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')
+43
View File
@@ -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() {
+52
View File
@@ -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.') })
}
})
}
+3
View File
@@ -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 },