From 13d0eb6651d846503b862e397c1405917ec9cf7b Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 12 Oct 2026 08:11:09 -0400 Subject: [PATCH] Remove podcast checklist backend, calendar events and reminder emails; v2.14.3 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 - package-lock.json | 4 +- package.json | 2 +- server.js | 10 -- server/config.js | 40 ------- server/data.js | 133 --------------------- server/reminder.js | 111 ------------------ server/routes/admin-backup.js | 3 - server/routes/admin-content.js | 17 --- server/routes/admin-dashboard.js | 19 --- server/routes/calendar-events.js | 192 ------------------------------- server/state.js | 5 - 12 files changed, 3 insertions(+), 534 deletions(-) delete mode 100644 server/reminder.js delete mode 100644 server/routes/calendar-events.js diff --git a/README.md b/README.md index a90ab04..802efb0 100644 --- a/README.md +++ b/README.md @@ -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 | --- diff --git a/package-lock.json b/package-lock.json index 80062a8..eb5dded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d690205..9cfc278 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.14.2", + "version": "2.14.3", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index ead46aa..05cab4f 100644 --- a/server.js +++ b/server.js @@ -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, diff --git a/server/config.js b/server/config.js index 6a06c70..d5099d6 100644 --- a/server/config.js +++ b/server/config.js @@ -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], - } -} diff --git a/server/data.js b/server/data.js index 0505680..a17a7c9 100644 --- a/server/data.js +++ b/server/data.js @@ -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() diff --git a/server/reminder.js b/server/reminder.js deleted file mode 100644 index 373b8a4..0000000 --- a/server/reminder.js +++ /dev/null @@ -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 = ` -
-

${icon} Calendar Reminder

-

- ${label}: ${title}${seriesStr}
- Scheduled for ${date} — ${daysText} -

-
-

- This reminder was set ${reminderDays} day${reminderDays === 1 ? '' : 's'} before the date. - To change or remove it, open the Calendar in your admin panel. -

-
- ` - - 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 - } -} diff --git a/server/routes/admin-backup.js b/server/routes/admin-backup.js index 6fded54..25777ed 100644 --- a/server/routes/admin-backup.js +++ b/server/routes/admin-backup.js @@ -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(), ]) diff --git a/server/routes/admin-content.js b/server/routes/admin-content.js index bfd0f9a..614aa5f 100644 --- a/server/routes/admin-content.js +++ b/server/routes/admin-content.js @@ -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) diff --git a/server/routes/admin-dashboard.js b/server/routes/admin-dashboard.js index dd614ce..5fb087d 100644 --- a/server/routes/admin-dashboard.js +++ b/server/routes/admin-dashboard.js @@ -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, diff --git a/server/routes/calendar-events.js b/server/routes/calendar-events.js deleted file mode 100644 index 430043b..0000000 --- a/server/routes/calendar-events.js +++ /dev/null @@ -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 }) - }) -} diff --git a/server/state.js b/server/state.js index b2e0a96..560a181 100644 --- a/server/state.js +++ b/server/state.js @@ -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(),