Remove podcast checklist backend, calendar events and reminder emails; v2.14.3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 08:11:09 -04:00
1 parent 99b19faede
commit 13d0eb6651
12 files changed
+3 -534

No files matched your search

-1
View File
@@ -141,7 +141,6 @@ siteforge/
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
| `GET /api/admin/qr-codes` | QR code management |
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
| `GET /api/admin-podcast-checklist` | Podcast checklist state |
| `GET /api/admin-subscribers` | Subscriber list |
---
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "siteforge",
"version": "2.14.2",
"version": "2.14.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "siteforge",
"version": "2.14.2",
"version": "2.14.3",
"dependencies": {
"@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.14.2",
"version": "2.14.3",
"type": "module",
"scripts": {
"dev": "vite",
-10
View File
@@ -21,10 +21,8 @@ import {
loadDownloadCountsFromDisk,
loadQrCodesFromDisk,
loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk,
loadEmailSettingsFromDisk,
loadCalendarEventsFromDisk,
loadAuditLogFromDisk,
loadWebhooksFromDisk,
loadStudySessionsFromDisk,
@@ -60,8 +58,6 @@ import { register as registerStudyComments } from './server/routes/study-comment
import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
import { register as registerContact } from './server/routes/contact.js'
import { register as registerCalendarEvents } from './server/routes/calendar-events.js'
import { startReminderScheduler } from './server/reminder.js'
import { register as registerInboundEmail } from './server/routes/inbound-email.js'
import { register as registerQuestions } from './server/routes/questions.js'
import { register as registerAnalytics } from './server/routes/analytics.js'
@@ -119,7 +115,6 @@ registerStudyComments(app)
registerStudyCertificate(app)
registerEpisodeScripts(app)
registerContact(app)
registerCalendarEvents(app)
registerInboundEmail(app)
registerQuestions(app)
registerAnalytics(app)
@@ -185,10 +180,8 @@ Promise.all([
loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(),
loadEmailSettingsFromDisk(),
loadCalendarEventsFromDisk(),
loadAuditLogFromDisk(),
loadWebhooksFromDisk(),
loadStudySessionsFromDisk(),
@@ -209,7 +202,6 @@ Promise.all([
createBackupSnapshot('scheduled').catch(() => {})
}, BACKUP_INTERVAL_MS)
startReminderScheduler()
startNuggetsSeafileWatcher()
// Purge expired study sessions every hour
@@ -281,7 +273,6 @@ Promise.all([
state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
state.studyUsersWritePromise,
state.studyCommunityWritePromise,
state.studyRemindersWritePromise,
@@ -293,7 +284,6 @@ Promise.all([
state.analyticsEventsWritePromise,
state.qrCodesWritePromise,
state.emailSettingsWritePromise,
state.calendarEventsWritePromise,
state.auditLogWritePromise,
state.webhooksWritePromise,
state.studySessionsWritePromise,
-40
View File
@@ -47,7 +47,6 @@ export const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress')
export const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
export const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
export const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
export const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
export const BACKUP_DIR = path.join(DATA_DIR, 'backups')
export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
@@ -70,7 +69,6 @@ export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?
export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
export const SEAFILE_POLL_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes
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 STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
@@ -164,18 +162,6 @@ export const DEFAULT_REPLY_TEMPLATES = [
},
]
export const DEFAULT_PODCAST_CHECKLIST_TASKS = [
{ id: 'verify_script', label: 'Verify Script', phase: 'pre' },
{ id: 'read_script', label: 'Read Script', phase: 'pre' },
{ id: 'record', label: 'Record', phase: 'pre' },
{ id: 'mix', label: 'Mix', phase: 'pre' },
{ id: 'edit', label: 'Edit', phase: 'pre' },
{ id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' },
{ id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' },
{ id: 'update_website', label: 'Update Website', phase: 'post' },
{ id: 'send_email', label: 'Send Email', phase: 'post' },
]
export const EMPTY_HIT_STATS = {
totalHits: 0,
realHits: 0,
@@ -203,29 +189,3 @@ export const EMPTY_VISITOR_STATS = {
geoCacheByIp: {},
}
// ── Podcast checklist helpers (no state dependency) ────────────────────────
function buildChecklistEpisode(series, number) {
const tasks = {}
for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) {
tasks[task.id] = false
}
return {
id: `${series.toLowerCase()}-${number}`,
series,
episodeNumber: number,
title: '',
datePublished: '',
expanded: false,
tasks,
}
}
export function buildDefaultPodcastChecklist() {
const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number))
const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1))
return {
tasks: DEFAULT_PODCAST_CHECKLIST_TASKS,
episodes: [...titusEpisodes, ...colossiansEpisodes],
}
}
-133
View File
@@ -26,7 +26,6 @@ import {
QR_CODES_FILE,
REPLY_TEMPLATES_FILE,
REPLY_HISTORY_FILE,
PODCAST_CHECKLIST_FILE,
BACKUP_DIR,
UPLOADS_DIR,
UPLOADS_META_FILE,
@@ -34,7 +33,6 @@ import {
EPISODE_PLAYS_FILE,
ANALYTICS_EVENTS_FILE,
EMAIL_SETTINGS_FILE,
CALENDAR_EVENTS_FILE,
AUDIT_LOG_FILE,
WEBHOOKS_FILE,
STUDY_SESSIONS_FILE,
@@ -51,8 +49,6 @@ import {
MAX_STUDY_NOTES_PER_USER,
MAX_STUDY_NOTE_LENGTH,
MAX_STUDY_COMMENTS,
DEFAULT_PODCAST_CHECKLIST_TASKS,
buildDefaultPodcastChecklist,
} from './config.js'
import { state } from './state.js'
@@ -424,36 +420,6 @@ export function loadEmailSettingsFromDisk() {
})
}
// ── Calendar events ────────────────────────────────────────────────────────
export function queueCalendarEventsWrite() {
state.calendarEventsWritePromise = state.calendarEventsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
CALENDAR_EVENTS_FILE,
JSON.stringify({ events: state.calendarEvents, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[calendar-events] failed to write:', err)
})
}
export function loadCalendarEventsFromDisk() {
return readFile(CALENDAR_EVENTS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed?.events)) {
state.calendarEvents = parsed.events
}
})
.catch(() => {
// No file yet — start with empty array
})
}
// ── Audit log ─────────────────────────────────────────────────────────────
export function appendAuditEntry(action, details) {
@@ -552,32 +518,6 @@ export async function fireWebhooks(eventType, payload) {
}
}
// ── Podcast checklist ──────────────────────────────────────────────────────
export function queuePodcastChecklistWrite() {
const updatedAt = new Date().toISOString()
state.podcastChecklistWritePromise = state.podcastChecklistWritePromise.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
PODCAST_CHECKLIST_FILE,
JSON.stringify({ checklist: state.podcastChecklist, updatedAt }, null, 2),
'utf8',
)
})
return state.podcastChecklistWritePromise
}
export function loadPodcastChecklistFromDisk() {
return readFile(PODCAST_CHECKLIST_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.podcastChecklist = sanitizePodcastChecklist(parsed?.checklist)
})
.catch(() => {
state.podcastChecklist = buildDefaultPodcastChecklist()
})
}
// ── Study users ────────────────────────────────────────────────────────────
export function queueStudyUsersWrite() {
@@ -1074,7 +1014,6 @@ export async function createBackupSnapshot(reason = 'scheduled') {
reason,
adminContent: null,
draftContent: null,
podcastChecklist: state.podcastChecklist,
publishState: state.publishState,
hitStats: state.hitStats,
visitorStats: state.visitorStats,
@@ -1200,7 +1139,6 @@ export async function restoreFromBackup(filename) {
state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
state.podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
queueHitStatsWrite()
queueVisitorStatsWrite()
@@ -1209,7 +1147,6 @@ export async function restoreFromBackup(filename) {
queueContactSubmissionsWrite()
queueReplyTemplatesWrite()
queueReplyHistoryWrite()
queuePodcastChecklistWrite()
await Promise.all([
state.hitStatsWritePromise,
@@ -1219,7 +1156,6 @@ export async function restoreFromBackup(filename) {
state.contactSubmissionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
])
await refreshContentCaches()
await createBackupSnapshot('post-restore')
@@ -1393,75 +1329,6 @@ export function sanitizeReplyHistory(value) {
}))
}
function sanitizeChecklistTask(task) {
const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : ''
if (!label) return null
const phase = task?.phase === 'post' ? 'post' : 'pre'
const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID()
return { id, label, phase }
}
export function sanitizePodcastChecklist(value) {
const fallback = buildDefaultPodcastChecklist()
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks
const seenTaskIds = new Set()
const tasks = []
for (const item of taskInput) {
const safeTask = sanitizeChecklistTask(item)
if (!safeTask) continue
if (seenTaskIds.has(safeTask.id)) continue
seenTaskIds.add(safeTask.id)
tasks.push(safeTask)
}
if (tasks.length === 0) {
for (const task of fallback.tasks) {
tasks.push({ ...task })
seenTaskIds.add(task.id)
}
}
const taskIds = tasks.map(task => task.id)
const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes
const episodes = []
for (const item of episodesInput) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID()
const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : ''
const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : ''
const rawEpisodeNumber = Number(item.episodeNumber)
const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0
? Math.round(rawEpisodeNumber)
: null
const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim())
? item.datePublished.trim()
: ''
const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks)
? item.tasks
: {}
const taskState = {}
for (const taskId of taskIds) {
taskState[taskId] = sourceTasks[taskId] === true
}
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 startTime = typeof item.startTime === 'string' && /^\d{2}:\d{2}$/.test(item.startTime) ? item.startTime : undefined
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) {
return fallback
}
return { tasks, episodes }
}
function normalizeStudyUsername(value) {
if (typeof value !== 'string') return ''
return value.trim().toLowerCase()
-111
View File
@@ -1,111 +0,0 @@
import { Resend } from 'resend'
import { state } from './state.js'
import { queuePodcastChecklistWrite, queueCalendarEventsWrite } from './data.js'
import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js'
function toDateKey(date) {
return date.toISOString().slice(0, 10)
}
export function startReminderScheduler() {
checkReminders()
setInterval(checkReminders, 60 * 60 * 1000)
}
async function checkReminders() {
const todayKey = toDateKey(new Date())
// Episodes
const episodes = state.podcastChecklist?.episodes
if (Array.isArray(episodes)) {
let changed = false
for (const ep of episodes) {
if (!ep.datePublished || !(ep.reminderDays > 0) || ep.reminderSentAt) continue
const publish = new Date(ep.datePublished + 'T12:00:00Z')
if (isNaN(publish.getTime())) continue
const reminderDate = new Date(publish)
reminderDate.setUTCDate(reminderDate.getUTCDate() - ep.reminderDays)
if (todayKey === toDateKey(reminderDate)) {
const sent = await sendReminderEmail({
label: ep.episodeNumber ? `Episode ${ep.episodeNumber}` : 'Episode',
title: ep.title || 'Untitled',
series: ep.series,
date: ep.datePublished,
reminderDays: ep.reminderDays,
type: 'episode',
})
if (sent) { ep.reminderSentAt = new Date().toISOString(); changed = true }
}
}
if (changed) queuePodcastChecklistWrite()
}
// Calendar events
const events = state.calendarEvents
if (Array.isArray(events)) {
let changed = false
for (const ev of events) {
if (!ev.date || !(ev.reminderDays > 0) || ev.reminderSentAt) continue
const publish = new Date(ev.date + 'T12:00:00Z')
if (isNaN(publish.getTime())) continue
const reminderDate = new Date(publish)
reminderDate.setUTCDate(reminderDate.getUTCDate() - ev.reminderDays)
if (todayKey === toDateKey(reminderDate)) {
const sent = await sendReminderEmail({
label: ev.type.charAt(0).toUpperCase() + ev.type.slice(1),
title: ev.title,
series: null,
date: ev.date,
reminderDays: ev.reminderDays,
type: ev.type,
})
if (sent) { ev.reminderSentAt = new Date().toISOString(); changed = true }
}
}
if (changed) queueCalendarEventsWrite()
}
}
const TYPE_ICONS = { episode: '📅', general: '📌', recording: '🎙️', social: '📱', task: '✅' }
async function sendReminderEmail({ label, title, series, date, reminderDays, type }) {
if (!process.env.RESEND_API_KEY) return false
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO
const eventDate = new Date(date + 'T12:00:00Z')
const msLeft = eventDate.getTime() - Date.now()
const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)))
const seriesStr = series ? ` (${series})` : ''
const daysText = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'in 1 day' : `in ${daysLeft} days`
const icon = TYPE_ICONS[type] ?? '📅'
const subject = `Reminder: ${label}: ${title} — ${daysText}`
const html = `
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
<h2 style="color:#c8860a;margin-bottom:4px">${icon} Calendar Reminder</h2>
<p style="font-size:1.1rem;margin-bottom:16px">
<strong>${label}: ${title}</strong>${seriesStr}<br>
<span style="color:#555">Scheduled for <strong>${date}</strong> — ${daysText}</span>
</p>
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
<p style="color:#777;font-size:0.85rem">
This reminder was set ${reminderDays} day${reminderDays === 1 ? '' : 's'} before the date.
To change or remove it, open the Calendar in your admin panel.
</p>
</div>
`
const { error } = await resend.emails.send({ from, to, subject, html })
if (error) { console.error('[reminder] send error:', error); return false }
console.log(`[reminder] sent for "${title}" (${date})`)
return true
} catch (err) {
console.error('[reminder] send exception:', err)
return false
}
}
-3
View File
@@ -24,7 +24,6 @@ import {
loadDownloadCountsFromDisk,
loadQrCodesFromDisk,
loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk,
createBackupSnapshot,
refreshContentCaches,
@@ -105,7 +104,6 @@ async function flushPendingWrites() {
state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
state.studyUsersWritePromise,
state.studyCommunityWritePromise,
state.studyRemindersWritePromise,
@@ -148,7 +146,6 @@ async function reloadStateFromDisk() {
loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(),
refreshContentCaches(),
])
-17
View File
@@ -26,13 +26,11 @@ import {
queueContactSubmissionsWrite,
queueReplyTemplatesWrite,
queueReplyHistoryWrite,
queuePodcastChecklistWrite,
queueWebhooksWrite,
createBackupSnapshot,
listBackupPreviews,
readBackupPreview,
restoreFromBackup,
sanitizePodcastChecklist,
} from '../data.js'
import {
pruneStatsByDays,
@@ -278,21 +276,6 @@ export function register(app) {
res.json(status)
})
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
res.json({ checklist: state.podcastChecklist })
})
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
try {
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
state.podcastChecklist = safeChecklist
await queuePodcastChecklistWrite()
res.json({ ok: true, checklist: safeChecklist })
} catch {
res.status(500).json({ message: 'Failed to save podcast checklist.' })
}
})
app.get('/api/site-config', async (_req, res) => {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
-19
View File
@@ -146,24 +146,6 @@ async function buildLatestEpisode() {
}
}
function buildUpcomingEvents() {
const today = dayKey(0)
const events = (state.calendarEvents ?? [])
.filter(ev => ev.date && ev.date >= today && !ev.completed)
.map(ev => ({ id: ev.id, kind: 'event', type: ev.type, title: ev.title, date: ev.date, startTime: ev.startTime ?? null }))
const episodes = (state.podcastChecklist?.episodes ?? [])
.filter(ep => ep.datePublished && ep.datePublished >= today)
.map(ep => ({
id: `ep-${ep.episodeNumber ?? ep.title}`,
kind: 'episode',
type: 'episode',
title: `${ep.episodeNumber ? `Ep. ${ep.episodeNumber}: ` : ''}${ep.title || 'Untitled'}`,
date: ep.datePublished,
startTime: null,
}))
return [...events, ...episodes].sort((a, b) => a.date.localeCompare(b.date)).slice(0, 3)
}
async function buildNuggets() {
const { entries } = await loadHistory()
const weekAgo = Date.now() - 7 * DAY_MS
@@ -243,7 +225,6 @@ export function register(app) {
reengagement: buildReengagement(),
studyProgress,
latestEpisode,
upcoming: buildUpcomingEvents(),
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest },
health,
lastBroadcast,
-192
View File
@@ -1,192 +0,0 @@
import { randomUUID } from 'node:crypto'
import { state } from '../state.js'
import { queueCalendarEventsWrite } from '../data.js'
import { requireAdminAuth } from '../auth.js'
const VALID_TYPES = ['general', 'recording', 'social', 'task']
const VALID_FREQS = ['none', 'weekly', 'biweekly', 'monthly']
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const TIME_RE = /^\d{2}:\d{2}$/
function sanitizeRecurrence(raw) {
if (!raw || typeof raw !== 'object') return null
const freq = VALID_FREQS.includes(raw.freq) ? raw.freq : 'none'
if (freq === 'none') return null
const until = typeof raw.until === 'string' && DATE_RE.test(raw.until) ? raw.until : null
return { freq, until }
}
function sanitize(ev) {
const recurrence = sanitizeRecurrence(ev.recurrence)
return {
id: ev.id,
type: VALID_TYPES.includes(ev.type) ? ev.type : 'general',
title: typeof ev.title === 'string' ? ev.title.slice(0, 300) : '',
date: typeof ev.date === 'string' && DATE_RE.test(ev.date) ? ev.date : '',
startTime: typeof ev.startTime === 'string' && TIME_RE.test(ev.startTime) ? ev.startTime : undefined,
notes: typeof ev.notes === 'string' ? ev.notes.slice(0, 2000) : '',
completed: Boolean(ev.completed),
reminderDays: Number.isFinite(ev.reminderDays) && ev.reminderDays > 0 ? ev.reminderDays : 0,
reminderSentAt: typeof ev.reminderSentAt === 'string' ? ev.reminderSentAt : undefined,
recurrence: recurrence ?? undefined,
createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(),
}
}
// ── iCal helpers ─────────────────────────────────────────────────────────────
function escapeIcs(s) {
return String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n')
}
function foldIcsLine(line) {
const chars = [...line]
if (chars.length <= 75) return line
const parts = []
let current = ''
for (const ch of chars) {
if ((current + ch).length > 75) { parts.push(current); current = ' ' + ch }
else current += ch
}
if (current) parts.push(current)
return parts.join('\r\n')
}
function toIcsDatetime(dateStr, timeStr) {
const d = dateStr.replace(/-/g, '')
if (timeStr && TIME_RE.test(timeStr)) {
const t = timeStr.replace(':', '') + '00'
return `DTSTART:${d}T${t}`
}
return `DTSTART;VALUE=DATE:${d}`
}
function toIcsTimestamp(iso) {
try {
const d = new Date(iso)
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')
} catch { return '' }
}
function rruleFor(recurrence) {
if (!recurrence || recurrence.freq === 'none') return null
const freqMap = { weekly: 'WEEKLY', biweekly: 'WEEKLY', monthly: 'MONTHLY' }
const freq = freqMap[recurrence.freq]
if (!freq) return null
let rule = `RRULE:FREQ=${freq}`
if (recurrence.freq === 'biweekly') rule += ';INTERVAL=2'
if (recurrence.until) rule += `;UNTIL=${recurrence.until.replace(/-/g, '')}T235959Z`
return rule
}
function generateIcal() {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Siteforge//CalendarExport//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Verse by Verse Calendar',
]
for (const ep of (state.podcastChecklist?.episodes ?? [])) {
if (!ep.datePublished) continue
const summary = [ep.series, ep.episodeNumber != null ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ')
const dtstart = ep.startTime ? `DTSTART:${ep.datePublished.replace(/-/g, '')}T${ep.startTime.replace(':', '')}00` : `DTSTART;VALUE=DATE:${ep.datePublished.replace(/-/g, '')}`
lines.push('BEGIN:VEVENT')
lines.push(`UID:ep-${ep.id}@siteforge`)
lines.push(dtstart)
lines.push(`SUMMARY:${escapeIcs(summary || 'Episode')}`)
if (ep.title) lines.push(`DESCRIPTION:${escapeIcs(ep.title)}`)
lines.push('END:VEVENT')
}
for (const ev of (state.calendarEvents ?? [])) {
if (!ev.date) continue
lines.push('BEGIN:VEVENT')
lines.push(`UID:ev-${ev.id}@siteforge`)
lines.push(toIcsDatetime(ev.date, ev.startTime))
lines.push(`SUMMARY:${escapeIcs(ev.title || '(Event)')}`)
if (ev.notes) lines.push(`DESCRIPTION:${escapeIcs(ev.notes)}`)
const rrule = rruleFor(ev.recurrence)
if (rrule) lines.push(rrule)
if (ev.createdAt) lines.push(`CREATED:${toIcsTimestamp(ev.createdAt)}`)
lines.push('END:VEVENT')
}
lines.push('END:VCALENDAR')
return lines.map(foldIcsLine).join('\r\n') + '\r\n'
}
// ── Routes ───────────────────────────────────────────────────────────────────
export function register(app) {
app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => {
res.json({ events: state.calendarEvents })
})
app.get('/api/admin-calendar.ics', requireAdminAuth, (_req, res) => {
const ical = generateIcal()
res.setHeader('Content-Type', 'text/calendar; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="siteforge-calendar.ics"')
res.send(ical)
})
app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => {
const { type, title, date, startTime, notes, reminderDays, recurrence } = req.body ?? {}
if (!title?.trim()) { res.status(400).json({ message: 'Title is required.' }); return }
if (!date || !DATE_RE.test(date)) { res.status(400).json({ message: 'Valid date is required.' }); return }
if (type && !VALID_TYPES.includes(type)) { res.status(400).json({ message: 'Invalid type.' }); return }
const ev = sanitize({
id: randomUUID(),
type: type ?? 'general',
title: String(title).trim(),
date,
startTime: startTime ?? undefined,
notes: notes ?? '',
completed: false,
reminderDays: reminderDays ?? 0,
recurrence: recurrence ?? null,
createdAt: new Date().toISOString(),
})
state.calendarEvents.unshift(ev)
queueCalendarEventsWrite()
res.json({ ok: true, event: ev })
})
app.patch('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
let found = false
state.calendarEvents = state.calendarEvents.map(ev => {
if (ev.id !== id) return ev
found = true
const patch = {}
if (typeof req.body?.title === 'string') patch.title = req.body.title.trim().slice(0, 300)
if (typeof req.body?.date === 'string' && DATE_RE.test(req.body.date)) patch.date = req.body.date
if (typeof req.body?.startTime === 'string') patch.startTime = TIME_RE.test(req.body.startTime) ? req.body.startTime : undefined
if (req.body?.startTime === null) patch.startTime = undefined
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
if (typeof req.body?.completed === 'boolean') patch.completed = req.body.completed
if (VALID_TYPES.includes(req.body?.type)) patch.type = req.body.type
if (Number.isFinite(req.body?.reminderDays)) patch.reminderDays = req.body.reminderDays
if ('recurrence' in (req.body ?? {})) patch.recurrence = sanitizeRecurrence(req.body.recurrence) ?? undefined
const dateChanged = patch.date && patch.date !== ev.date
const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays
if (dateChanged || reminderChanged) patch.reminderSentAt = undefined
return { ...ev, ...patch }
})
if (!found) { res.status(404).json({ message: 'Event not found.' }); return }
queueCalendarEventsWrite()
res.json({ ok: true })
})
app.delete('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
const before = state.calendarEvents.length
state.calendarEvents = state.calendarEvents.filter(ev => ev.id !== req.params.id)
if (state.calendarEvents.length === before) { res.status(404).json({ message: 'Event not found.' }); return }
queueCalendarEventsWrite()
res.json({ ok: true })
})
}
-5
View File
@@ -3,7 +3,6 @@ import {
EMPTY_VISITOR_STATS,
DEFAULT_PUBLISH_STATE,
DEFAULT_REPLY_TEMPLATES,
buildDefaultPodcastChecklist,
} from './config.js'
export const state = {
@@ -36,8 +35,6 @@ export const state = {
replyHistory: [],
replyHistoryWritePromise: Promise.resolve(),
podcastChecklist: buildDefaultPodcastChecklist(),
podcastChecklistWritePromise: Promise.resolve(),
studyUsers: [],
studyUsersWritePromise: Promise.resolve(),
@@ -100,8 +97,6 @@ export const state = {
newsletterIssues: [],
newsletterIssuesWritePromise: Promise.resolve(),
calendarEvents: [],
calendarEventsWritePromise: Promise.resolve(),
auditLog: [],
auditLogWritePromise: Promise.resolve(),