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,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')
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.') })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user