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:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.26",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+20
@@ -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; }
|
||||
|
||||
+44
-1
@@ -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>
|
||||
|
||||
+41
-3
@@ -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<ContactSubmission[]>([])
|
||||
const [replyHistory, setReplyHistory] = useState<ReplyHistoryItem[]>([])
|
||||
const [checklistEpisodes, setChecklistEpisodes] = useState<ChecklistEpisode[]>([])
|
||||
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,9 +839,16 @@ function ContactsClient() {
|
||||
)}
|
||||
|
||||
{/* Conversation history panel */}
|
||||
{historyOpen && !isEditing && (
|
||||
{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 (
|
||||
<div className="ct-history-panel">
|
||||
{history.length === 0 && <p className="ct-history-empty">No conversation history.</p>}
|
||||
{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">
|
||||
@@ -848,10 +869,27 @@ function ContactsClient() {
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
{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>
|
||||
|
||||
@@ -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<Record<string, string>>({})
|
||||
const [emailSettings, setEmailSettings] = useState<EmailSettings>({ 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 }) {
|
||||
<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={() => { 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="/calendar" className="em-btn em-btn--ghost">Calendar</Link>
|
||||
<Link to="/admin" className="em-btn em-btn--ghost">← Admin</Link>
|
||||
@@ -1353,6 +1407,47 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
||||
</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 */}
|
||||
<div className="em-footer-toolbar">
|
||||
{config && !config.canSendReplies && (
|
||||
|
||||
Reference in New Issue
Block a user