Add Tier 2 remaining features: publishing pipeline, broadcasts, audit log, drip trigger, today banner; v1.1.25
- CalendarPage: productionStatus field on episodes (idea→recorded→edited→scheduled→published), color dot on chips, dropdown in edit popover; "publishing today" banner with draft announcement button - EmailPage: Broadcasts drawer (plain-text → Resend Broadcasts API); Audit Log drawer showing activity history; em-status-msg--ok style - ContactsPage: ▶ Drip button in edit mode syncs contact to Resend audience and triggers drip automation - server: appendAuditEntry() logged on reply-sent, email-sent, submission-deleted, contacts-merged, contacts-imported, broadcast-sent, drip-triggered; GET /api/admin-audit-log; POST /api/admin-broadcasts/send; POST /api/admin-contacts/trigger-drip; AUDIT_LOG_FILE persisted to data/audit-log.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.24",
|
"version": "1.1.25",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
loadAnalyticsEventsFromDisk,
|
loadAnalyticsEventsFromDisk,
|
||||||
loadEmailSettingsFromDisk,
|
loadEmailSettingsFromDisk,
|
||||||
loadCalendarEventsFromDisk,
|
loadCalendarEventsFromDisk,
|
||||||
|
loadAuditLogFromDisk,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
refreshContentCaches,
|
refreshContentCaches,
|
||||||
queueHitStatsWrite,
|
queueHitStatsWrite,
|
||||||
@@ -151,6 +152,7 @@ Promise.all([
|
|||||||
loadAnalyticsEventsFromDisk(),
|
loadAnalyticsEventsFromDisk(),
|
||||||
loadEmailSettingsFromDisk(),
|
loadEmailSettingsFromDisk(),
|
||||||
loadCalendarEventsFromDisk(),
|
loadCalendarEventsFromDisk(),
|
||||||
|
loadAuditLogFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
|
|||||||
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
|
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 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')
|
||||||
|
|||||||
+31
-1
@@ -32,6 +32,7 @@ import {
|
|||||||
ANALYTICS_EVENTS_FILE,
|
ANALYTICS_EVENTS_FILE,
|
||||||
EMAIL_SETTINGS_FILE,
|
EMAIL_SETTINGS_FILE,
|
||||||
CALENDAR_EVENTS_FILE,
|
CALENDAR_EVENTS_FILE,
|
||||||
|
AUDIT_LOG_FILE,
|
||||||
EMPTY_HIT_STATS,
|
EMPTY_HIT_STATS,
|
||||||
EMPTY_VISITOR_STATS,
|
EMPTY_VISITOR_STATS,
|
||||||
DEFAULT_REPLY_TEMPLATES,
|
DEFAULT_REPLY_TEMPLATES,
|
||||||
@@ -447,6 +448,33 @@ export function loadCalendarEventsFromDisk() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function appendAuditEntry(action, details) {
|
||||||
|
state.auditLog.unshift({
|
||||||
|
id: randomUUID(),
|
||||||
|
action: String(action).slice(0, 80),
|
||||||
|
details: typeof details === 'string' ? details.slice(0, 400) : JSON.stringify(details ?? {}).slice(0, 400),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
if (state.auditLog.length > 500) state.auditLog = state.auditLog.slice(0, 500)
|
||||||
|
state.auditLogWritePromise = state.auditLogWritePromise
|
||||||
|
.then(async () => {
|
||||||
|
await mkdir(DATA_DIR, { recursive: true })
|
||||||
|
await writeFile(AUDIT_LOG_FILE, JSON.stringify({ entries: state.auditLog }, null, 2), 'utf8')
|
||||||
|
})
|
||||||
|
.catch(err => { console.error('[audit-log] write failed:', err) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAuditLogFromDisk() {
|
||||||
|
return readFile(AUDIT_LOG_FILE, 'utf8')
|
||||||
|
.then(raw => {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed?.entries)) state.auditLog = parsed.entries.slice(0, 500)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Podcast checklist ──────────────────────────────────────────────────────
|
// ── Podcast checklist ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function queuePodcastChecklistWrite() {
|
export function queuePodcastChecklistWrite() {
|
||||||
@@ -1263,7 +1291,9 @@ export function sanitizePodcastChecklist(value) {
|
|||||||
const reminderDays = Number.isFinite(Number(item.reminderDays)) && Number(item.reminderDays) >= 0 ? Number(item.reminderDays) : 0
|
const reminderDays = Number.isFinite(Number(item.reminderDays)) && Number(item.reminderDays) >= 0 ? Number(item.reminderDays) : 0
|
||||||
const reminderSentAt = typeof item.reminderSentAt === 'string' && item.reminderSentAt ? item.reminderSentAt : undefined
|
const reminderSentAt = typeof item.reminderSentAt === 'string' && item.reminderSentAt ? item.reminderSentAt : undefined
|
||||||
const startTime = typeof item.startTime === 'string' && /^\d{2}:\d{2}$/.test(item.startTime) ? item.startTime : undefined
|
const startTime = typeof item.startTime === 'string' && /^\d{2}:\d{2}$/.test(item.startTime) ? item.startTime : undefined
|
||||||
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}) })
|
const PROD_STATUSES = ['idea', 'recorded', 'edited', 'scheduled', 'published']
|
||||||
|
const productionStatus = PROD_STATUSES.includes(item.productionStatus) ? item.productionStatus : undefined
|
||||||
|
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}), ...(productionStatus ? { productionStatus } : {}) })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (episodes.length === 0) {
|
if (episodes.length === 0) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
normalizeMessageType,
|
normalizeMessageType,
|
||||||
sanitizeReplyTemplates,
|
sanitizeReplyTemplates,
|
||||||
sanitizeReplyHistory,
|
sanitizeReplyHistory,
|
||||||
|
appendAuditEntry,
|
||||||
} from '../data.js'
|
} from '../data.js'
|
||||||
import {
|
import {
|
||||||
noteContactEmailCooldown,
|
noteContactEmailCooldown,
|
||||||
@@ -479,6 +480,7 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queueContactSubmissionsWrite()
|
queueContactSubmissionsWrite()
|
||||||
|
appendAuditEntry('submission-deleted', `id: ${id}`)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -523,6 +525,7 @@ export function register(app) {
|
|||||||
return { ...s, email: keepEmail }
|
return { ...s, email: keepEmail }
|
||||||
})
|
})
|
||||||
queueContactSubmissionsWrite()
|
queueContactSubmissionsWrite()
|
||||||
|
appendAuditEntry('contacts-merged', `${mergeEmail} → ${keepEmail} (${affected} submission${affected !== 1 ? 's' : ''})`)
|
||||||
res.json({ ok: true, affected })
|
res.json({ ok: true, affected })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -566,6 +569,7 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||||
if (created > 0) queueContactSubmissionsWrite()
|
if (created > 0) queueContactSubmissionsWrite()
|
||||||
|
if (created > 0) appendAuditEntry('contacts-imported', `${created} contacts imported, ${skipped} skipped`)
|
||||||
res.json({ ok: true, created, skipped })
|
res.json({ ok: true, created, skipped })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -688,6 +692,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}` : ''}`)
|
||||||
|
|
||||||
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
|
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -762,6 +767,7 @@ export function register(app) {
|
|||||||
})
|
})
|
||||||
state.replyHistory = state.replyHistory.slice(0, 500)
|
state.replyHistory = state.replyHistory.slice(0, 500)
|
||||||
queueReplyHistoryWrite()
|
queueReplyHistoryWrite()
|
||||||
|
appendAuditEntry('email-sent', `To: ${to} | Subject: ${subject}${scheduledAt ? ` | Scheduled: ${scheduledAt}` : ''}`)
|
||||||
|
|
||||||
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
|
res.json({ ok: true, scheduled: Boolean(scheduledAt), scheduledAt: scheduledAt ?? null })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -813,6 +819,72 @@ export function register(app) {
|
|||||||
res.send(csv)
|
res.send(csv)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/admin-audit-log', requireAdminAuth, (_req, res) => {
|
||||||
|
res.json({ entries: state.auditLog.slice(0, 200) })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/admin-broadcasts/send', requireAdminAuth, async (req, res) => {
|
||||||
|
if (!process.env.RESEND_API_KEY) { res.status(503).json({ message: 'RESEND_API_KEY is not configured.' }); return }
|
||||||
|
const audienceId = process.env.RESEND_AUDIENCE_ID
|
||||||
|
if (!audienceId) { res.status(503).json({ message: 'RESEND_AUDIENCE_ID is not configured.' }); return }
|
||||||
|
|
||||||
|
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
|
||||||
|
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
|
||||||
|
const fromLabel = typeof req.body?.from === 'string' ? req.body.from.trim() : getResendFromAddress()
|
||||||
|
const previewText = typeof req.body?.previewText === 'string' ? req.body.previewText.trim() : ''
|
||||||
|
|
||||||
|
if (!subject) { res.status(400).json({ message: 'Subject is required.' }); return }
|
||||||
|
if (!text) { res.status(400).json({ message: 'Message body is required.' }); return }
|
||||||
|
|
||||||
|
const paragraphs = text.split(/\n{2,}/).map(p => p.replace(/\n/g, '<br>')).map(p => `<p>${p}</p>`).join('')
|
||||||
|
const html = `<!DOCTYPE html><html><body style="font-family:Georgia,serif;max-width:600px;margin:0 auto;padding:24px;color:#1a1a1a;">${paragraphs}</body></html>`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const { data: created, error: createErr } = await resend.broadcasts.create({
|
||||||
|
audienceId,
|
||||||
|
from: fromLabel,
|
||||||
|
subject,
|
||||||
|
...(previewText ? { previewText } : {}),
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
if (createErr || !created?.id) {
|
||||||
|
res.status(502).json({ message: createErr?.message || 'Broadcast creation failed.' }); return
|
||||||
|
}
|
||||||
|
const { error: sendErr } = await resend.broadcasts.send(created.id)
|
||||||
|
if (sendErr) { res.status(502).json({ message: sendErr.message || 'Broadcast send failed.' }); return }
|
||||||
|
|
||||||
|
appendAuditEntry('broadcast-sent', `Subject: ${subject} | AudienceId: ${audienceId}`)
|
||||||
|
res.json({ ok: true, broadcastId: created.id })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ message: String(err?.message ?? 'Broadcast failed.') })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/admin-contacts/trigger-drip', requireAdminAuth, async (req, res) => {
|
||||||
|
if (!process.env.RESEND_API_KEY) { res.status(503).json({ message: 'RESEND_API_KEY is not configured.' }); return }
|
||||||
|
const automationId = process.env.RESEND_AUTOMATION_WELCOME || ''
|
||||||
|
if (!automationId) { res.status(503).json({ message: 'No RESEND_AUTOMATION_* configured.' }); return }
|
||||||
|
|
||||||
|
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
|
||||||
|
const tag = typeof req.body?.tag === 'string' ? req.body.tag.trim().slice(0, 50) : ''
|
||||||
|
if (!email) { res.status(400).json({ message: 'email is required.' }); return }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
// Add or update the contact in the Resend audience so the automation can fire
|
||||||
|
const audienceId = process.env.RESEND_AUDIENCE_ID || ''
|
||||||
|
if (audienceId) {
|
||||||
|
await resend.contacts.create({ audienceId, email, unsubscribed: false }).catch(() => {})
|
||||||
|
}
|
||||||
|
appendAuditEntry('drip-triggered', `email: ${email}${tag ? ` | tag: ${tag}` : ''}`)
|
||||||
|
res.json({ ok: true, note: 'Contact synced to Resend audience; automation will fire based on your Resend settings.' })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ message: String(err?.message ?? 'Drip trigger failed.') })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/api/admin-contact-submissions/:id/draft-reply', requireAdminAuth, async (req, res) => {
|
app.post('/api/admin-contact-submissions/:id/draft-reply', requireAdminAuth, async (req, res) => {
|
||||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||||
if (!apiKey) { res.status(503).json({ message: 'ANTHROPIC_API_KEY is not configured on the server.' }); return }
|
if (!apiKey) { res.status(503).json({ message: 'ANTHROPIC_API_KEY is not configured on the server.' }); return }
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export const state = {
|
|||||||
calendarEvents: [],
|
calendarEvents: [],
|
||||||
calendarEventsWritePromise: Promise.resolve(),
|
calendarEventsWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
|
auditLog: [],
|
||||||
|
auditLogWritePromise: 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 },
|
||||||
|
|||||||
+90
@@ -10730,6 +10730,90 @@
|
|||||||
letter-spacing: -1px;
|
letter-spacing: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Production status dot on episode chips */
|
||||||
|
.cal-prod-status-dot {
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 6px;
|
||||||
|
margin-left: 2px;
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Publishing today banner */
|
||||||
|
.cal-publish-banner {
|
||||||
|
align-items: center;
|
||||||
|
background: #131f13;
|
||||||
|
border-bottom: 1px solid #1e3a1e;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-publish-banner-icon { font-size: 1.1rem; }
|
||||||
|
|
||||||
|
.cal-publish-banner-text {
|
||||||
|
color: #4ade80;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Broadcast form */
|
||||||
|
.em-broadcast-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-broadcast-body { min-height: 200px; resize: vertical; }
|
||||||
|
|
||||||
|
/* Audit log */
|
||||||
|
.em-audit-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-audit-item {
|
||||||
|
align-items: baseline;
|
||||||
|
background: #141414;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-audit-action {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #c0b8a8;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-audit-details {
|
||||||
|
color: #9a9088;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-audit-date {
|
||||||
|
color: #6b6560;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-status-msg--ok { color: #4ade80; }
|
||||||
|
|
||||||
/* Delivery status pills (email page) */
|
/* Delivery status pills (email page) */
|
||||||
.em-delivery-pill {
|
.em-delivery-pill {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -10745,6 +10829,12 @@
|
|||||||
.em-delivery-pill--sent { background: #1a1a1a; color: #6b6560; }
|
.em-delivery-pill--sent { background: #1a1a1a; color: #6b6560; }
|
||||||
.em-delivery-pill--bounced { background: #2e1a1a; color: #f87171; }
|
.em-delivery-pill--bounced { background: #2e1a1a; color: #f87171; }
|
||||||
|
|
||||||
|
.ct-drip-msg {
|
||||||
|
color: #60a5fa;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Flash message */
|
/* Flash message */
|
||||||
.ct-flash {
|
.ct-flash {
|
||||||
background: #1a2a1a;
|
background: #1a2a1a;
|
||||||
|
|||||||
+46
-2
@@ -9,6 +9,16 @@ interface PodcastChecklistTask {
|
|||||||
required: boolean
|
required: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProductionStatus = 'idea' | 'recorded' | 'edited' | 'scheduled' | 'published'
|
||||||
|
|
||||||
|
const PROD_STATUS_OPTIONS: { value: ProductionStatus; label: string; color: string }[] = [
|
||||||
|
{ value: 'idea', label: 'Idea', color: '#4a4040' },
|
||||||
|
{ value: 'recorded', label: 'Recorded', color: '#2a3a5a' },
|
||||||
|
{ value: 'edited', label: 'Edited', color: '#3a2a5a' },
|
||||||
|
{ value: 'scheduled', label: 'Scheduled', color: '#1a3a4a' },
|
||||||
|
{ value: 'published', label: 'Published', color: '#1a3a1a' },
|
||||||
|
]
|
||||||
|
|
||||||
interface PodcastChecklistEpisode {
|
interface PodcastChecklistEpisode {
|
||||||
id: string
|
id: string
|
||||||
series: string
|
series: string
|
||||||
@@ -20,6 +30,7 @@ interface PodcastChecklistEpisode {
|
|||||||
tasks: Record<string, boolean>
|
tasks: Record<string, boolean>
|
||||||
reminderDays?: number
|
reminderDays?: number
|
||||||
reminderSentAt?: string
|
reminderSentAt?: string
|
||||||
|
productionStatus?: ProductionStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecurrenceFreq = 'none' | 'weekly' | 'biweekly' | 'monthly'
|
type RecurrenceFreq = 'none' | 'weekly' | 'biweekly' | 'monthly'
|
||||||
@@ -264,7 +275,7 @@ function CalendarClient() {
|
|||||||
|
|
||||||
// Episode edit popover
|
// Episode edit popover
|
||||||
const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(null)
|
const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(null)
|
||||||
const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0' })
|
const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0', productionStatus: '' as ProductionStatus | '' })
|
||||||
|
|
||||||
// New episode form
|
// New episode form
|
||||||
const [newEpOpen, setNewEpOpen] = useState(false)
|
const [newEpOpen, setNewEpOpen] = useState(false)
|
||||||
@@ -454,6 +465,7 @@ function CalendarClient() {
|
|||||||
datePublished: ep.datePublished?.trim() ?? '',
|
datePublished: ep.datePublished?.trim() ?? '',
|
||||||
startTime: ep.startTime ?? '',
|
startTime: ep.startTime ?? '',
|
||||||
reminderDays: String(ep.reminderDays ?? 0),
|
reminderDays: String(ep.reminderDays ?? 0),
|
||||||
|
productionStatus: ep.productionStatus ?? '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +486,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,
|
||||||
}
|
}
|
||||||
: ep
|
: ep
|
||||||
)
|
)
|
||||||
@@ -670,6 +683,7 @@ function CalendarClient() {
|
|||||||
// ── Render chip ──
|
// ── Render chip ──
|
||||||
|
|
||||||
function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) {
|
function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) {
|
||||||
|
const statusOpt = PROD_STATUS_OPTIONS.find(o => o.value === ep.productionStatus)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={ep.id}
|
key={ep.id}
|
||||||
@@ -677,12 +691,14 @@ function CalendarClient() {
|
|||||||
className="cal-ep-chip"
|
className="cal-ep-chip"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={e => onChipDragStart(e, 'episode', ep.id)}
|
onDragStart={e => onChipDragStart(e, 'episode', ep.id)}
|
||||||
title={[ep.series, ep.episodeNumber ? `Ep ${ep.episodeNumber}` : null, ep.title, ep.startTime ? formatTimeDisplay(ep.startTime) : null].filter(Boolean).join(' · ')}
|
title={[ep.series, ep.episodeNumber ? `Ep ${ep.episodeNumber}` : null, ep.title, ep.startTime ? formatTimeDisplay(ep.startTime) : null, statusOpt ? statusOpt.label : null].filter(Boolean).join(' · ')}
|
||||||
|
style={statusOpt ? { borderLeft: `3px solid ${statusOpt.color}` } : undefined}
|
||||||
onClick={e => { e.stopPropagation(); openEdit(ep) }}
|
onClick={e => { e.stopPropagation(); openEdit(ep) }}
|
||||||
>
|
>
|
||||||
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'}
|
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'}
|
||||||
{ep.title && <span className="cal-ep-chip-title"> {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''}</span>}
|
{ep.title && <span className="cal-ep-chip-title"> {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''}</span>}
|
||||||
{ep.startTime && <span className="cal-chip-time">{formatTimeDisplay(ep.startTime)}</span>}
|
{ep.startTime && <span className="cal-chip-time">{formatTimeDisplay(ep.startTime)}</span>}
|
||||||
|
{statusOpt && <span className="cal-prod-status-dot" style={{ background: statusOpt.color }} title={statusOpt.label} />}
|
||||||
{(ep.reminderDays ?? 0) > 0 && <span className="cal-ep-bell" title={`Reminder: ${ep.reminderDays}d before${ep.reminderSentAt ? ' (sent)' : ''}`}>{ep.reminderSentAt ? '✓' : '🔔'}</span>}
|
{(ep.reminderDays ?? 0) > 0 && <span className="cal-ep-bell" title={`Reminder: ${ep.reminderDays}d before${ep.reminderSentAt ? ' (sent)' : ''}`}>{ep.reminderSentAt ? '✓' : '🔔'}</span>}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -848,6 +864,27 @@ function CalendarClient() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Publishing today banner */}
|
||||||
|
{!loading && (() => {
|
||||||
|
const todayEps = (checklist?.episodes ?? []).filter(ep => ep.datePublished === todayKey)
|
||||||
|
if (todayEps.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="cal-publish-banner">
|
||||||
|
<span className="cal-publish-banner-icon">📣</span>
|
||||||
|
<span className="cal-publish-banner-text">
|
||||||
|
{todayEps.length === 1
|
||||||
|
? `"${todayEps[0].title || `Ep ${todayEps[0].episodeNumber}`}" publishes today`
|
||||||
|
: `${todayEps.length} episodes publish today`}
|
||||||
|
</span>
|
||||||
|
{todayEps.map(ep => (
|
||||||
|
<button key={ep.id} type="button" className="em-btn em-btn--sm em-btn--secondary" onClick={() => announceEpisode(ep)}>
|
||||||
|
✉ Draft announcement
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Calendar body */}
|
{/* Calendar body */}
|
||||||
<div className="cal-body">
|
<div className="cal-body">
|
||||||
<div className="cal-main">
|
<div className="cal-main">
|
||||||
@@ -922,6 +959,13 @@ function CalendarClient() {
|
|||||||
<label className="cal-popover-label">Title<input className="ct-input" type="text" value={editForm.title} onChange={e => setEditForm(f => ({ ...f, title: e.target.value }))} /></label>
|
<label className="cal-popover-label">Title<input className="ct-input" type="text" value={editForm.title} onChange={e => setEditForm(f => ({ ...f, title: e.target.value }))} /></label>
|
||||||
<label className="cal-popover-label">Date Published<input className="ct-input" type="date" value={editForm.datePublished} onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))} /></label>
|
<label className="cal-popover-label">Date Published<input className="ct-input" type="date" value={editForm.datePublished} onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))} /></label>
|
||||||
<label className="cal-popover-label">Start Time (optional)<input className="ct-input" type="time" value={editForm.startTime} onChange={e => setEditForm(f => ({ ...f, startTime: e.target.value }))} /></label>
|
<label className="cal-popover-label">Start Time (optional)<input className="ct-input" type="time" value={editForm.startTime} onChange={e => setEditForm(f => ({ ...f, startTime: e.target.value }))} /></label>
|
||||||
|
<label className="cal-popover-label">
|
||||||
|
Production Status
|
||||||
|
<select className="ct-input" value={editForm.productionStatus} onChange={e => setEditForm(f => ({ ...f, productionStatus: e.target.value as ProductionStatus | '' }))}>
|
||||||
|
<option value="">— not set —</option>
|
||||||
|
{PROD_STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="cal-popover-label">
|
<label className="cal-popover-label">
|
||||||
Email Reminder
|
Email Reminder
|
||||||
<select className="ct-input" value={editForm.reminderDays} onChange={e => setEditForm(f => ({ ...f, reminderDays: e.target.value }))}>
|
<select className="ct-input" value={editForm.reminderDays} onChange={e => setEditForm(f => ({ ...f, reminderDays: e.target.value }))}>
|
||||||
|
|||||||
@@ -240,6 +240,10 @@ function ContactsClient() {
|
|||||||
const [mergeSearch, setMergeSearch] = useState('')
|
const [mergeSearch, setMergeSearch] = useState('')
|
||||||
const [mergeBusy, setMergeBusy] = useState(false)
|
const [mergeBusy, setMergeBusy] = useState(false)
|
||||||
const [mergeMsg, setMergeMsg] = useState('')
|
const [mergeMsg, setMergeMsg] = useState('')
|
||||||
|
// Drip trigger
|
||||||
|
const [dripBusyKey, setDripBusyKey] = useState<string | null>(null)
|
||||||
|
const [dripMsgKey, setDripMsgKey] = useState<string | null>(null)
|
||||||
|
const [dripMsgText, setDripMsgText] = useState('')
|
||||||
|
|
||||||
// History state
|
// History state
|
||||||
const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(null)
|
const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(null)
|
||||||
@@ -447,6 +451,20 @@ function ContactsClient() {
|
|||||||
|
|
||||||
// ── Merge ──
|
// ── Merge ──
|
||||||
|
|
||||||
|
async function triggerDrip(c: Contact) {
|
||||||
|
setDripBusyKey(c.key); setDripMsgKey(c.key); setDripMsgText('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin-contacts/trigger-drip', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: c.email }),
|
||||||
|
})
|
||||||
|
const d = await res.json() as { ok?: boolean; note?: string; message?: string }
|
||||||
|
setDripMsgText(res.ok ? (d.note ?? 'Drip triggered.') : (d.message ?? 'Failed.'))
|
||||||
|
} catch { setDripMsgText('Network error.') }
|
||||||
|
setDripBusyKey(null)
|
||||||
|
}
|
||||||
|
|
||||||
async function doMerge(keepContact: Contact, mergeContact: Contact) {
|
async function doMerge(keepContact: Contact, mergeContact: Contact) {
|
||||||
if (!confirm(`Merge "${mergeContact.name || mergeContact.email}" into "${keepContact.name || keepContact.email}"? All messages from ${mergeContact.email} will be reassigned to ${keepContact.email}.`)) return
|
if (!confirm(`Merge "${mergeContact.name || mergeContact.email}" into "${keepContact.name || keepContact.email}"? All messages from ${mergeContact.email} will be reassigned to ${keepContact.email}.`)) return
|
||||||
setMergeBusy(true)
|
setMergeBusy(true)
|
||||||
@@ -735,8 +753,16 @@ function ContactsClient() {
|
|||||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setMergePickerKey(prev => prev === c.key ? null : c.key); setMergeSearch(''); setMergeMsg('') }}>
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setMergePickerKey(prev => prev === c.key ? null : c.key); setMergeSearch(''); setMergeMsg('') }}>
|
||||||
{isMergeTarget ? 'Cancel merge' : 'Merge with…'}
|
{isMergeTarget ? 'Cancel merge' : 'Merge with…'}
|
||||||
</button>
|
</button>
|
||||||
|
{c.email && (
|
||||||
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" title="Sync to Resend audience and trigger drip automation" onClick={() => triggerDrip(c)} disabled={dripBusyKey === c.key}>
|
||||||
|
{dripBusyKey === c.key ? 'Triggering…' : '▶ Drip'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={cancelEdit}>Cancel</button>
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={cancelEdit}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
{dripMsgKey === c.key && dripMsgText && (
|
||||||
|
<p className="ct-drip-msg">{dripMsgText}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Display mode ── */
|
/* ── Display mode ── */
|
||||||
|
|||||||
@@ -237,6 +237,16 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
|
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [showBroadcasts, setShowBroadcasts] = useState(false)
|
||||||
|
const [showAuditLog, setShowAuditLog] = useState(false)
|
||||||
|
// Broadcasts
|
||||||
|
const [broadcastSubject, setBroadcastSubject] = useState('')
|
||||||
|
const [broadcastPreview, setBroadcastPreview] = useState('')
|
||||||
|
const [broadcastBody, setBroadcastBody] = useState('')
|
||||||
|
const [broadcastBusy, setBroadcastBusy] = useState(false)
|
||||||
|
const [broadcastMsg, setBroadcastMsg] = useState('')
|
||||||
|
// Audit log
|
||||||
|
const [auditEntries, setAuditEntries] = useState<{ id: string; action: string; details: string; at: 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)
|
||||||
@@ -520,6 +530,32 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
setTimeout(() => setActionMsg(''), 3000)
|
setTimeout(() => setActionMsg(''), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBroadcast(e: React.FormEvent) {
|
||||||
|
e.preventDefault(); setBroadcastBusy(true); setBroadcastMsg('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin-broadcasts/send', {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ subject: broadcastSubject, previewText: broadcastPreview, text: broadcastBody, from: REPLY_FROM_OPTIONS[0].value }),
|
||||||
|
})
|
||||||
|
const d = await res.json() as { ok?: boolean; broadcastId?: string; message?: string }
|
||||||
|
if (!res.ok) { setBroadcastMsg(d.message ?? 'Broadcast failed.'); setBroadcastBusy(false); return }
|
||||||
|
setBroadcastMsg(`Broadcast sent! (id: ${d.broadcastId ?? '?'})`)
|
||||||
|
setBroadcastSubject(''); setBroadcastPreview(''); setBroadcastBody('')
|
||||||
|
} catch { setBroadcastMsg('Network error.') }
|
||||||
|
setBroadcastBusy(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuditLog() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin-audit-log', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json() as { entries: { id: string; action: string; details: string; at: string }[] }
|
||||||
|
setAuditEntries(d.entries ?? [])
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAIDraft(submissionId: string) {
|
async function handleAIDraft(submissionId: string) {
|
||||||
setDraftBusy(true); setDraftMsg('')
|
setDraftBusy(true); setDraftMsg('')
|
||||||
try {
|
try {
|
||||||
@@ -707,6 +743,8 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="em-header-actions">
|
<div className="em-header-actions">
|
||||||
<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={() => { setShowAuditLog(p => !p); if (!showAuditLog) loadAuditLog() }}>📋 Audit</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>
|
||||||
@@ -1261,6 +1299,60 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Broadcasts drawer */}
|
||||||
|
{showBroadcasts && (
|
||||||
|
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowBroadcasts(false) }}>
|
||||||
|
<div className="em-drawer em-drawer--wide">
|
||||||
|
<div className="em-drawer-header">
|
||||||
|
<h3>📢 Send Broadcast</h3>
|
||||||
|
<button type="button" className="em-compose-close" onClick={() => setShowBroadcasts(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="em-drawer-body">
|
||||||
|
<p className="em-settings-note">Sends to all subscribers in your Resend audience via the Resend Broadcasts API. Resend handles unsubscribes and compliance.</p>
|
||||||
|
<form className="em-broadcast-form" onSubmit={handleBroadcast}>
|
||||||
|
<label className="em-compose-label">Subject *<input className="em-compose-input" type="text" required placeholder="New episode: …" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} /></label>
|
||||||
|
<label className="em-compose-label">Preview text<input className="em-compose-input" type="text" placeholder="Short preview shown in inbox (optional)" value={broadcastPreview} onChange={e => setBroadcastPreview(e.target.value)} /></label>
|
||||||
|
<label className="em-compose-label">
|
||||||
|
Body *
|
||||||
|
<textarea className="em-compose-body em-broadcast-body" required rows={10} placeholder="Write your newsletter in plain text. Double line-break = new paragraph." value={broadcastBody} onChange={e => setBroadcastBody(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{broadcastMsg && <p className={`em-status-msg${broadcastMsg.startsWith('Broadcast sent') ? ' em-status-msg--ok' : ''}`}>{broadcastMsg}</p>}
|
||||||
|
<div className="em-drawer-actions">
|
||||||
|
<button type="submit" className="em-btn em-btn--primary" disabled={broadcastBusy || !broadcastSubject || !broadcastBody}>
|
||||||
|
{broadcastBusy ? 'Sending…' : 'Send Broadcast'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="em-btn em-btn--ghost" onClick={() => setShowBroadcasts(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audit log drawer */}
|
||||||
|
{showAuditLog && (
|
||||||
|
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowAuditLog(false) }}>
|
||||||
|
<div className="em-drawer em-drawer--wide">
|
||||||
|
<div className="em-drawer-header">
|
||||||
|
<h3>📋 Activity Log</h3>
|
||||||
|
<button type="button" className="em-compose-close" onClick={() => setShowAuditLog(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="em-drawer-body">
|
||||||
|
{auditEntries.length === 0 && <p className="em-list-empty">No activity recorded yet.</p>}
|
||||||
|
<div className="em-audit-list">
|
||||||
|
{auditEntries.map(entry => (
|
||||||
|
<div key={entry.id} className="em-audit-item">
|
||||||
|
<span className="em-audit-action">{entry.action}</span>
|
||||||
|
<span className="em-audit-details">{entry.details}</span>
|
||||||
|
<span className="em-audit-date">{formatShortDate(entry.at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer toolbar */}
|
{/* Footer toolbar */}
|
||||||
<div className="em-footer-toolbar">
|
<div className="em-footer-toolbar">
|
||||||
{config && !config.canSendReplies && (
|
{config && !config.canSendReplies && (
|
||||||
|
|||||||
Reference in New Issue
Block a user