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 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.25", "version": "1.1.26",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -26,6 +26,7 @@ import {
loadEmailSettingsFromDisk, loadEmailSettingsFromDisk,
loadCalendarEventsFromDisk, loadCalendarEventsFromDisk,
loadAuditLogFromDisk, loadAuditLogFromDisk,
loadWebhooksFromDisk,
createBackupSnapshot, createBackupSnapshot,
refreshContentCaches, refreshContentCaches,
queueHitStatsWrite, queueHitStatsWrite,
@@ -153,6 +154,7 @@ Promise.all([
loadEmailSettingsFromDisk(), loadEmailSettingsFromDisk(),
loadCalendarEventsFromDisk(), loadCalendarEventsFromDisk(),
loadAuditLogFromDisk(), loadAuditLogFromDisk(),
loadWebhooksFromDisk(),
refreshContentCaches(), refreshContentCaches(),
]) ])
.catch(err => { .catch(err => {
+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 EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.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 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 MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
export const DIST_DIR = path.join(ROOT_DIR, 'dist') export const DIST_DIR = path.join(ROOT_DIR, 'dist')
+43
View File
@@ -33,6 +33,7 @@ import {
EMAIL_SETTINGS_FILE, EMAIL_SETTINGS_FILE,
CALENDAR_EVENTS_FILE, CALENDAR_EVENTS_FILE,
AUDIT_LOG_FILE, AUDIT_LOG_FILE,
WEBHOOKS_FILE,
EMPTY_HIT_STATS, EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS, EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES, DEFAULT_REPLY_TEMPLATES,
@@ -475,6 +476,48 @@ export function loadAuditLogFromDisk() {
.catch(() => {}) .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 ────────────────────────────────────────────────────── // ── Podcast checklist ──────────────────────────────────────────────────────
export function queuePodcastChecklistWrite() { export function queuePodcastChecklistWrite() {
+52
View File
@@ -23,6 +23,8 @@ import {
sanitizeReplyTemplates, sanitizeReplyTemplates,
sanitizeReplyHistory, sanitizeReplyHistory,
appendAuditEntry, appendAuditEntry,
queueWebhooksWrite,
fireWebhooks,
} from '../data.js' } from '../data.js'
import { import {
noteContactEmailCooldown, noteContactEmailCooldown,
@@ -154,6 +156,7 @@ export function register(app) {
state.contactSubmissions.unshift(submission) state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite() queueContactSubmissionsWrite()
fireWebhooks('contact.new', { name: trimmedName, email: trimmedEmail, message: trimmedMessage, messageType: normalizedMessageType, submittedAt: submission.submittedAt })
if (normalizedMessageType === 'question') { if (normalizedMessageType === 'question') {
const question = { const question = {
@@ -693,6 +696,7 @@ export function register(app) {
state.replyHistory = state.replyHistory.slice(0, 500) state.replyHistory = state.replyHistory.slice(0, 500)
queueReplyHistoryWrite() queueReplyHistoryWrite()
appendAuditEntry('reply-sent', `To: ${submission.email} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`) 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 }) res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
} catch (err) { } catch (err) {
@@ -928,4 +932,52 @@ export function register(app) {
res.status(502).json({ message: 'AI draft request failed.' }) 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: [], auditLog: [],
auditLogWritePromise: Promise.resolve(), auditLogWritePromise: Promise.resolve(),
webhooks: [],
webhooksWritePromise: Promise.resolve(),
lastBackupStatus: { ok: true, at: null, error: null, file: null }, lastBackupStatus: { ok: true, at: null, error: null, file: null },
lastCachePurgeStatus: { ok: true, at: null, error: null }, lastCachePurgeStatus: { ok: true, at: null, error: null },
lastDeployHookStatus: { ok: true, at: null, error: null }, lastDeployHookStatus: { ok: true, at: null, error: null },
+20
View File
@@ -11741,3 +11741,23 @@
.cal-sidebar { display: none; } .cal-sidebar { display: none; }
.cal-month-title { min-width: 120px; font-size: 0.95rem; } .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; }
+44 -1
View File
@@ -294,6 +294,7 @@ function CalendarClient() {
// Drag state // Drag state
const [dragOverKey, setDragOverKey] = useState<string | null>(null) const [dragOverKey, setDragOverKey] = useState<string | null>(null)
const [calFlash, setCalFlash] = useState('')
const dragDataRef = useRef<{ type: 'episode' | 'event'; id: string } | null>(null) const dragDataRef = useRef<{ type: 'episode' | 'event'; id: string } | null>(null)
// ── Load ── // ── Load ──
@@ -475,6 +476,16 @@ function CalendarClient() {
const newReminder = Number(editForm.reminderDays) const newReminder = Number(editForm.reminderDays)
const dateChanged = newDate !== (editEp.datePublished?.trim() ?? '') const dateChanged = newDate !== (editEp.datePublished?.trim() ?? '')
const reminderChanged = newReminder !== (editEp.reminderDays ?? 0) 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 => const updated = checklist.episodes.map(ep =>
ep.id === editEp.id ep.id === editEp.id
? { ? {
@@ -486,7 +497,7 @@ function CalendarClient() {
startTime: editForm.startTime || undefined, startTime: editForm.startTime || undefined,
reminderDays: newReminder, reminderDays: newReminder,
reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt, reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt,
productionStatus: (editForm.productionStatus as ProductionStatus) || undefined, productionStatus: nextStatus,
} }
: ep : ep
) )
@@ -494,6 +505,36 @@ function CalendarClient() {
setEditEp(null) 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) { function unschedule(ep: PodcastChecklistEpisode) {
if (!checklist) return if (!checklist) return
const updated = checklist.episodes.map(e => e.id === ep.id ? { ...e, datePublished: '' } : e) 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="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> <button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={gotoToday}>Today</button>
{saving && <span className="cal-saving">Saving</span>} {saving && <span className="cal-saving">Saving</span>}
{calFlash && <span className="cal-flash-msg">{calFlash}</span>}
</div> </div>
<div className="cal-header-center"> <div className="cal-header-center">
<div className="cal-view-toggle"> <div className="cal-view-toggle">
@@ -977,6 +1019,7 @@ function CalendarClient() {
<div className="cal-popover-actions"> <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--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> <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={() => unschedule(editEp)}>Unschedule</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEp(null)}>Cancel</button> <button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEp(null)}>Cancel</button>
</div> </div>
+61 -23
View File
@@ -67,6 +67,14 @@ type ConversationItem =
| { kind: 'inbound'; date: string; name: string; message: string; source?: string; id: string } | { kind: 'inbound'; date: string; name: string; message: string; source?: string; id: string }
| { kind: 'outbound'; date: string; subject: string; preview: string; toEmail: 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 ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const TAG_PALETTE = [ const TAG_PALETTE = [
@@ -222,6 +230,7 @@ export default function ContactsShell() {
function ContactsClient() { function ContactsClient() {
const [submissions, setSubmissions] = useState<ContactSubmission[]>([]) const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
const [replyHistory, setReplyHistory] = useState<ReplyHistoryItem[]>([]) const [replyHistory, setReplyHistory] = useState<ReplyHistoryItem[]>([])
const [checklistEpisodes, setChecklistEpisodes] = useState<ChecklistEpisode[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [tagFilter, setTagFilter] = useState('') const [tagFilter, setTagFilter] = useState('')
@@ -268,9 +277,10 @@ function ContactsClient() {
const reload = useCallback(async () => { const reload = useCallback(async () => {
try { 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-submissions', { credentials: 'include' }),
fetch('/api/admin-contact-reply-history', { credentials: 'include' }), fetch('/api/admin-contact-reply-history', { credentials: 'include' }),
fetch('/api/admin-podcast-checklist', { credentials: 'include' }),
]) ])
if (subRes.ok) { if (subRes.ok) {
const d = await subRes.json() as { submissions: ContactSubmission[] } const d = await subRes.json() as { submissions: ContactSubmission[] }
@@ -280,6 +290,10 @@ function ContactsClient() {
const d = await histRes.json() as { items: ReplyHistoryItem[] } const d = await histRes.json() as { items: ReplyHistoryItem[] }
setReplyHistory(d.items ?? []) setReplyHistory(d.items ?? [])
} }
if (clRes.ok) {
const d = await clRes.json() as { checklist?: { episodes?: ChecklistEpisode[] } }
setChecklistEpisodes(d.checklist?.episodes ?? [])
}
} catch { /* silent */ } } catch { /* silent */ }
setLoading(false) setLoading(false)
}, []) }, [])
@@ -825,31 +839,55 @@ function ContactsClient() {
)} )}
{/* Conversation history panel */} {/* Conversation history panel */}
{historyOpen && !isEditing && ( {historyOpen && !isEditing && (() => {
<div className="ct-history-panel"> const relatedEps = checklistEpisodes.filter(ep => {
{history.length === 0 && <p className="ct-history-empty">No conversation history.</p>} if (!ep.datePublished) return false
{history.map((item, i) => ( const epMs = new Date(ep.datePublished + 'T12:00:00').getTime()
item.kind === 'inbound' ? ( const refMs = new Date(c.latestAt).getTime()
<div key={item.id || i} className="ct-history-item ct-history-item--in"> return Math.abs(epMs - refMs) <= 30 * 24 * 60 * 60 * 1000
<div className="ct-history-meta"> })
<span className="ct-history-who">{item.name}</span> return (
<span className="ct-history-date">{fmtShort(item.date)}</span> <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> </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">
<div key={i} className="ct-history-item ct-history-item--out"> <span className="ct-history-who">You {item.toEmail}</span>
<div className="ct-history-meta"> <span className="ct-history-date">{fmtShort(item.date)}</span>
<span className="ct-history-who">You {item.toEmail}</span> </div>
<span className="ct-history-date">{fmtShort(item.date)}</span> <div className="ct-history-subject">{item.subject}</div>
<p className="ct-history-body">{item.preview}</p>
</div> </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> )
)} })()}
</div> </div>
) )
})} })}
+95
View File
@@ -247,6 +247,14 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
const [broadcastMsg, setBroadcastMsg] = useState('') const [broadcastMsg, setBroadcastMsg] = useState('')
// Audit log // Audit log
const [auditEntries, setAuditEntries] = useState<{ id: string; action: string; details: string; at: string }[]>([]) 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<Record<string, string>>({})
const [emailSettings, setEmailSettings] = useState<EmailSettings>({ signature: 'Grace and peace,\nVerse by Verse with Nate' }) const [emailSettings, setEmailSettings] = useState<EmailSettings>({ signature: 'Grace and peace,\nVerse by Verse with Nate' })
const [settingsSig, setSettingsSig] = useState('') const [settingsSig, setSettingsSig] = useState('')
const [settingsSaving, setSettingsSaving] = useState(false) const [settingsSaving, setSettingsSaving] = useState(false)
@@ -556,6 +564,51 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
} catch { /* silent */ } } 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) { async function handleAIDraft(submissionId: string) {
setDraftBusy(true); setDraftMsg('') setDraftBusy(true); setDraftMsg('')
try { try {
@@ -745,6 +798,7 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
<button type="button" className="em-btn em-btn--primary" onClick={openCompose}>Compose</button> <button type="button" className="em-btn em-btn--primary" onClick={openCompose}>Compose</button>
<button type="button" className="em-btn em-btn--ghost" onClick={() => { setShowBroadcasts(p => !p); setBroadcastMsg('') }}>📢 Broadcast</button> <button type="button" className="em-btn em-btn--ghost" onClick={() => { setShowBroadcasts(p => !p); setBroadcastMsg('') }}>📢 Broadcast</button>
<button type="button" className="em-btn em-btn--ghost" onClick={() => { setShowAuditLog(p => !p); if (!showAuditLog) loadAuditLog() }}>📋 Audit</button> <button type="button" className="em-btn em-btn--ghost" onClick={() => { setShowAuditLog(p => !p); if (!showAuditLog) loadAuditLog() }}>📋 Audit</button>
<button type="button" className="em-btn em-btn--ghost" onClick={() => { setShowWebhooks(p => !p); if (!showWebhooks) loadWebhooks() }}> Webhooks</button>
<Link to="/contacts" className="em-btn em-btn--ghost">Contacts</Link> <Link to="/contacts" className="em-btn em-btn--ghost">Contacts</Link>
<Link to="/calendar" className="em-btn em-btn--ghost">Calendar</Link> <Link to="/calendar" className="em-btn em-btn--ghost">Calendar</Link>
<Link to="/admin" className="em-btn em-btn--ghost"> Admin</Link> <Link to="/admin" className="em-btn em-btn--ghost"> Admin</Link>
@@ -1353,6 +1407,47 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
</div> </div>
)} )}
{/* Webhooks drawer */}
{showWebhooks && (
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowWebhooks(false) }}>
<div className="em-drawer em-drawer--wide">
<div className="em-drawer-header">
<h3> Outbound Webhooks</h3>
<button type="button" className="em-compose-close" onClick={() => setShowWebhooks(false)}>×</button>
</div>
<div className="em-drawer-body">
<p className="em-settings-note">Webhooks fire on <code>contact.new</code> and <code>reply.sent</code> events. Connect Zapier or any endpoint.</p>
<div className="em-webhook-list">
{webhooks.length === 0 && <p className="em-list-empty">No webhooks configured.</p>}
{webhooks.map(wh => (
<div key={wh.id} className="em-webhook-item">
<div className="em-webhook-info">
{wh.label && <span className="em-webhook-label">{wh.label}</span>}
<span className="em-webhook-url">{wh.url}</span>
<span className="em-webhook-events">{(wh.events ?? []).join(', ')}</span>
</div>
<div className="em-webhook-actions">
{wTestMsg[wh.id] && <span className="em-webhook-test-msg">{wTestMsg[wh.id]}</span>}
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => testWebhook(wh.id)}>Test</button>
<button type="button" className="em-btn em-btn--danger em-btn--sm" onClick={() => deleteWebhook(wh.id)}>Delete</button>
</div>
</div>
))}
</div>
<form className="em-webhook-add-form" onSubmit={handleAddWebhook}>
<h4 className="em-webhook-add-title">Add Webhook</h4>
<label className="em-compose-label">Label (optional)<input className="em-compose-input" type="text" placeholder="My Zapier hook" value={wNewLabel} onChange={e => setWNewLabel(e.target.value)} /></label>
<label className="em-compose-label">URL *<input className="em-compose-input" type="url" required placeholder="https://hooks.zapier.com/…" value={wNewUrl} onChange={e => setWNewUrl(e.target.value)} /></label>
{wAddMsg && <p className={`em-status-msg${wAddMsg.startsWith('Webhook added') ? ' em-status-msg--ok' : ''}`}>{wAddMsg}</p>}
<div className="em-drawer-actions">
<button type="submit" className="em-btn em-btn--primary" disabled={wAddBusy || !wNewUrl}>{wAddBusy ? 'Adding…' : 'Add Webhook'}</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Footer toolbar */} {/* Footer toolbar */}
<div className="em-footer-toolbar"> <div className="em-footer-toolbar">
{config && !config.canSendReplies && ( {config && !config.canSendReplies && (