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 = ` -
- ${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. -
-