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:
1 parent
99b19faede
commit
13d0eb6651
12 files changed
+3
-534
No files matched your search
@@ -141,7 +141,6 @@ siteforge/
|
|||||||
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
|
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
|
||||||
| `GET /api/admin/qr-codes` | QR code management |
|
| `GET /api/admin/qr-codes` | QR code management |
|
||||||
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
|
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
|
||||||
| `GET /api/admin-podcast-checklist` | Podcast checklist state |
|
|
||||||
| `GET /api/admin-subscribers` | Subscriber list |
|
| `GET /api/admin-subscribers` | Subscriber list |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.14.2",
|
"version": "2.14.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.14.2",
|
"version": "2.14.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1135.0",
|
"@aws-sdk/client-s3": "^3.1135.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.14.2",
|
"version": "2.14.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ import {
|
|||||||
loadDownloadCountsFromDisk,
|
loadDownloadCountsFromDisk,
|
||||||
loadQrCodesFromDisk,
|
loadQrCodesFromDisk,
|
||||||
loadEpisodePlaysFromDisk,
|
loadEpisodePlaysFromDisk,
|
||||||
loadPodcastChecklistFromDisk,
|
|
||||||
loadAnalyticsEventsFromDisk,
|
loadAnalyticsEventsFromDisk,
|
||||||
loadEmailSettingsFromDisk,
|
loadEmailSettingsFromDisk,
|
||||||
loadCalendarEventsFromDisk,
|
|
||||||
loadAuditLogFromDisk,
|
loadAuditLogFromDisk,
|
||||||
loadWebhooksFromDisk,
|
loadWebhooksFromDisk,
|
||||||
loadStudySessionsFromDisk,
|
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 registerStudyCertificate } from './server/routes/study-certificate.js'
|
||||||
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
||||||
import { register as registerContact } from './server/routes/contact.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 registerInboundEmail } from './server/routes/inbound-email.js'
|
||||||
import { register as registerQuestions } from './server/routes/questions.js'
|
import { register as registerQuestions } from './server/routes/questions.js'
|
||||||
import { register as registerAnalytics } from './server/routes/analytics.js'
|
import { register as registerAnalytics } from './server/routes/analytics.js'
|
||||||
@@ -119,7 +115,6 @@ registerStudyComments(app)
|
|||||||
registerStudyCertificate(app)
|
registerStudyCertificate(app)
|
||||||
registerEpisodeScripts(app)
|
registerEpisodeScripts(app)
|
||||||
registerContact(app)
|
registerContact(app)
|
||||||
registerCalendarEvents(app)
|
|
||||||
registerInboundEmail(app)
|
registerInboundEmail(app)
|
||||||
registerQuestions(app)
|
registerQuestions(app)
|
||||||
registerAnalytics(app)
|
registerAnalytics(app)
|
||||||
@@ -185,10 +180,8 @@ Promise.all([
|
|||||||
loadDownloadCountsFromDisk(),
|
loadDownloadCountsFromDisk(),
|
||||||
loadQrCodesFromDisk(),
|
loadQrCodesFromDisk(),
|
||||||
loadEpisodePlaysFromDisk(),
|
loadEpisodePlaysFromDisk(),
|
||||||
loadPodcastChecklistFromDisk(),
|
|
||||||
loadAnalyticsEventsFromDisk(),
|
loadAnalyticsEventsFromDisk(),
|
||||||
loadEmailSettingsFromDisk(),
|
loadEmailSettingsFromDisk(),
|
||||||
loadCalendarEventsFromDisk(),
|
|
||||||
loadAuditLogFromDisk(),
|
loadAuditLogFromDisk(),
|
||||||
loadWebhooksFromDisk(),
|
loadWebhooksFromDisk(),
|
||||||
loadStudySessionsFromDisk(),
|
loadStudySessionsFromDisk(),
|
||||||
@@ -209,7 +202,6 @@ Promise.all([
|
|||||||
createBackupSnapshot('scheduled').catch(() => {})
|
createBackupSnapshot('scheduled').catch(() => {})
|
||||||
}, BACKUP_INTERVAL_MS)
|
}, BACKUP_INTERVAL_MS)
|
||||||
|
|
||||||
startReminderScheduler()
|
|
||||||
startNuggetsSeafileWatcher()
|
startNuggetsSeafileWatcher()
|
||||||
|
|
||||||
// Purge expired study sessions every hour
|
// Purge expired study sessions every hour
|
||||||
@@ -281,7 +273,6 @@ Promise.all([
|
|||||||
state.draftQuestionsWritePromise,
|
state.draftQuestionsWritePromise,
|
||||||
state.replyTemplatesWritePromise,
|
state.replyTemplatesWritePromise,
|
||||||
state.replyHistoryWritePromise,
|
state.replyHistoryWritePromise,
|
||||||
state.podcastChecklistWritePromise,
|
|
||||||
state.studyUsersWritePromise,
|
state.studyUsersWritePromise,
|
||||||
state.studyCommunityWritePromise,
|
state.studyCommunityWritePromise,
|
||||||
state.studyRemindersWritePromise,
|
state.studyRemindersWritePromise,
|
||||||
@@ -293,7 +284,6 @@ Promise.all([
|
|||||||
state.analyticsEventsWritePromise,
|
state.analyticsEventsWritePromise,
|
||||||
state.qrCodesWritePromise,
|
state.qrCodesWritePromise,
|
||||||
state.emailSettingsWritePromise,
|
state.emailSettingsWritePromise,
|
||||||
state.calendarEventsWritePromise,
|
|
||||||
state.auditLogWritePromise,
|
state.auditLogWritePromise,
|
||||||
state.webhooksWritePromise,
|
state.webhooksWritePromise,
|
||||||
state.studySessionsWritePromise,
|
state.studySessionsWritePromise,
|
||||||
|
|||||||
@@ -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 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_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 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 BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||||||
export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||||
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
|
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 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 SEAFILE_POLL_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes
|
||||||
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 AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.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 WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||||
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.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 = {
|
export const EMPTY_HIT_STATS = {
|
||||||
totalHits: 0,
|
totalHits: 0,
|
||||||
realHits: 0,
|
realHits: 0,
|
||||||
@@ -203,29 +189,3 @@ export const EMPTY_VISITOR_STATS = {
|
|||||||
geoCacheByIp: {},
|
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
@@ -26,7 +26,6 @@ import {
|
|||||||
QR_CODES_FILE,
|
QR_CODES_FILE,
|
||||||
REPLY_TEMPLATES_FILE,
|
REPLY_TEMPLATES_FILE,
|
||||||
REPLY_HISTORY_FILE,
|
REPLY_HISTORY_FILE,
|
||||||
PODCAST_CHECKLIST_FILE,
|
|
||||||
BACKUP_DIR,
|
BACKUP_DIR,
|
||||||
UPLOADS_DIR,
|
UPLOADS_DIR,
|
||||||
UPLOADS_META_FILE,
|
UPLOADS_META_FILE,
|
||||||
@@ -34,7 +33,6 @@ import {
|
|||||||
EPISODE_PLAYS_FILE,
|
EPISODE_PLAYS_FILE,
|
||||||
ANALYTICS_EVENTS_FILE,
|
ANALYTICS_EVENTS_FILE,
|
||||||
EMAIL_SETTINGS_FILE,
|
EMAIL_SETTINGS_FILE,
|
||||||
CALENDAR_EVENTS_FILE,
|
|
||||||
AUDIT_LOG_FILE,
|
AUDIT_LOG_FILE,
|
||||||
WEBHOOKS_FILE,
|
WEBHOOKS_FILE,
|
||||||
STUDY_SESSIONS_FILE,
|
STUDY_SESSIONS_FILE,
|
||||||
@@ -51,8 +49,6 @@ import {
|
|||||||
MAX_STUDY_NOTES_PER_USER,
|
MAX_STUDY_NOTES_PER_USER,
|
||||||
MAX_STUDY_NOTE_LENGTH,
|
MAX_STUDY_NOTE_LENGTH,
|
||||||
MAX_STUDY_COMMENTS,
|
MAX_STUDY_COMMENTS,
|
||||||
DEFAULT_PODCAST_CHECKLIST_TASKS,
|
|
||||||
buildDefaultPodcastChecklist,
|
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
import { state } from './state.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 ─────────────────────────────────────────────────────────────
|
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function appendAuditEntry(action, details) {
|
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 ────────────────────────────────────────────────────────────
|
// ── Study users ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function queueStudyUsersWrite() {
|
export function queueStudyUsersWrite() {
|
||||||
@@ -1074,7 +1014,6 @@ export async function createBackupSnapshot(reason = 'scheduled') {
|
|||||||
reason,
|
reason,
|
||||||
adminContent: null,
|
adminContent: null,
|
||||||
draftContent: null,
|
draftContent: null,
|
||||||
podcastChecklist: state.podcastChecklist,
|
|
||||||
publishState: state.publishState,
|
publishState: state.publishState,
|
||||||
hitStats: state.hitStats,
|
hitStats: state.hitStats,
|
||||||
visitorStats: state.visitorStats,
|
visitorStats: state.visitorStats,
|
||||||
@@ -1200,7 +1139,6 @@ export async function restoreFromBackup(filename) {
|
|||||||
state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
|
state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
|
||||||
state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
|
state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
|
||||||
state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
|
state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
|
||||||
state.podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
|
|
||||||
|
|
||||||
queueHitStatsWrite()
|
queueHitStatsWrite()
|
||||||
queueVisitorStatsWrite()
|
queueVisitorStatsWrite()
|
||||||
@@ -1209,7 +1147,6 @@ export async function restoreFromBackup(filename) {
|
|||||||
queueContactSubmissionsWrite()
|
queueContactSubmissionsWrite()
|
||||||
queueReplyTemplatesWrite()
|
queueReplyTemplatesWrite()
|
||||||
queueReplyHistoryWrite()
|
queueReplyHistoryWrite()
|
||||||
queuePodcastChecklistWrite()
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
state.hitStatsWritePromise,
|
state.hitStatsWritePromise,
|
||||||
@@ -1219,7 +1156,6 @@ export async function restoreFromBackup(filename) {
|
|||||||
state.contactSubmissionsWritePromise,
|
state.contactSubmissionsWritePromise,
|
||||||
state.replyTemplatesWritePromise,
|
state.replyTemplatesWritePromise,
|
||||||
state.replyHistoryWritePromise,
|
state.replyHistoryWritePromise,
|
||||||
state.podcastChecklistWritePromise,
|
|
||||||
])
|
])
|
||||||
await refreshContentCaches()
|
await refreshContentCaches()
|
||||||
await createBackupSnapshot('post-restore')
|
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) {
|
function normalizeStudyUsername(value) {
|
||||||
if (typeof value !== 'string') return ''
|
if (typeof value !== 'string') return ''
|
||||||
return value.trim().toLowerCase()
|
return value.trim().toLowerCase()
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
loadDownloadCountsFromDisk,
|
loadDownloadCountsFromDisk,
|
||||||
loadQrCodesFromDisk,
|
loadQrCodesFromDisk,
|
||||||
loadEpisodePlaysFromDisk,
|
loadEpisodePlaysFromDisk,
|
||||||
loadPodcastChecklistFromDisk,
|
|
||||||
loadAnalyticsEventsFromDisk,
|
loadAnalyticsEventsFromDisk,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
refreshContentCaches,
|
refreshContentCaches,
|
||||||
@@ -105,7 +104,6 @@ async function flushPendingWrites() {
|
|||||||
state.draftQuestionsWritePromise,
|
state.draftQuestionsWritePromise,
|
||||||
state.replyTemplatesWritePromise,
|
state.replyTemplatesWritePromise,
|
||||||
state.replyHistoryWritePromise,
|
state.replyHistoryWritePromise,
|
||||||
state.podcastChecklistWritePromise,
|
|
||||||
state.studyUsersWritePromise,
|
state.studyUsersWritePromise,
|
||||||
state.studyCommunityWritePromise,
|
state.studyCommunityWritePromise,
|
||||||
state.studyRemindersWritePromise,
|
state.studyRemindersWritePromise,
|
||||||
@@ -148,7 +146,6 @@ async function reloadStateFromDisk() {
|
|||||||
loadDownloadCountsFromDisk(),
|
loadDownloadCountsFromDisk(),
|
||||||
loadQrCodesFromDisk(),
|
loadQrCodesFromDisk(),
|
||||||
loadEpisodePlaysFromDisk(),
|
loadEpisodePlaysFromDisk(),
|
||||||
loadPodcastChecklistFromDisk(),
|
|
||||||
loadAnalyticsEventsFromDisk(),
|
loadAnalyticsEventsFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -26,13 +26,11 @@ import {
|
|||||||
queueContactSubmissionsWrite,
|
queueContactSubmissionsWrite,
|
||||||
queueReplyTemplatesWrite,
|
queueReplyTemplatesWrite,
|
||||||
queueReplyHistoryWrite,
|
queueReplyHistoryWrite,
|
||||||
queuePodcastChecklistWrite,
|
|
||||||
queueWebhooksWrite,
|
queueWebhooksWrite,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
listBackupPreviews,
|
listBackupPreviews,
|
||||||
readBackupPreview,
|
readBackupPreview,
|
||||||
restoreFromBackup,
|
restoreFromBackup,
|
||||||
sanitizePodcastChecklist,
|
|
||||||
} from '../data.js'
|
} from '../data.js'
|
||||||
import {
|
import {
|
||||||
pruneStatsByDays,
|
pruneStatsByDays,
|
||||||
@@ -278,21 +276,6 @@ export function register(app) {
|
|||||||
res.json(status)
|
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) => {
|
app.get('/api/site-config', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
const parsed = await loadSiteContentFile(DATA_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() {
|
async function buildNuggets() {
|
||||||
const { entries } = await loadHistory()
|
const { entries } = await loadHistory()
|
||||||
const weekAgo = Date.now() - 7 * DAY_MS
|
const weekAgo = Date.now() - 7 * DAY_MS
|
||||||
@@ -243,7 +225,6 @@ export function register(app) {
|
|||||||
reengagement: buildReengagement(),
|
reengagement: buildReengagement(),
|
||||||
studyProgress,
|
studyProgress,
|
||||||
latestEpisode,
|
latestEpisode,
|
||||||
upcoming: buildUpcomingEvents(),
|
|
||||||
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest },
|
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest },
|
||||||
health,
|
health,
|
||||||
lastBroadcast,
|
lastBroadcast,
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
EMPTY_VISITOR_STATS,
|
EMPTY_VISITOR_STATS,
|
||||||
DEFAULT_PUBLISH_STATE,
|
DEFAULT_PUBLISH_STATE,
|
||||||
DEFAULT_REPLY_TEMPLATES,
|
DEFAULT_REPLY_TEMPLATES,
|
||||||
buildDefaultPodcastChecklist,
|
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
@@ -36,8 +35,6 @@ export const state = {
|
|||||||
replyHistory: [],
|
replyHistory: [],
|
||||||
replyHistoryWritePromise: Promise.resolve(),
|
replyHistoryWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
podcastChecklist: buildDefaultPodcastChecklist(),
|
|
||||||
podcastChecklistWritePromise: Promise.resolve(),
|
|
||||||
|
|
||||||
studyUsers: [],
|
studyUsers: [],
|
||||||
studyUsersWritePromise: Promise.resolve(),
|
studyUsersWritePromise: Promise.resolve(),
|
||||||
@@ -100,8 +97,6 @@ export const state = {
|
|||||||
newsletterIssues: [],
|
newsletterIssues: [],
|
||||||
newsletterIssuesWritePromise: Promise.resolve(),
|
newsletterIssuesWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
calendarEvents: [],
|
|
||||||
calendarEventsWritePromise: Promise.resolve(),
|
|
||||||
|
|
||||||
auditLog: [],
|
auditLog: [],
|
||||||
auditLogWritePromise: Promise.resolve(),
|
auditLogWritePromise: Promise.resolve(),
|
||||||
|
|||||||
Reference in new issue
Block a user