Files
Siteforge/server/data.js
T
nmemmert ce235ba9ef Add /contacts and /calendar pages; email signature settings; v1.1.12
- /contacts: standalone page with hybrid contact list (submissions + manual entry), inline edit, search, archive
- /calendar: monthly release scheduling calendar reading/writing podcast checklist episode dates
- /email settings: editable signature panel; signature persisted server-side and injected into outgoing emails
- Move contacts out of /admin panel (now links to /contacts route)
- Partial PATCH for contact submissions (name, notes, archived independently)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 10:12:52 -04:00

1426 lines
54 KiB
JavaScript

import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import { sanitizeSiteContent } from './helpers.js'
import {
DATA_DIR,
DATA_FILE,
DRAFT_DATA_FILE,
HIT_STATS_FILE,
VISITOR_STATS_FILE,
CONTACT_SUBMISSIONS_FILE,
QUESTIONS_FILE,
DRAFT_QUESTIONS_FILE,
STUDY_USERS_FILE,
STUDY_COMMUNITY_FILE,
STUDY_NOTES_DIR,
STUDY_NOTES_FILE,
STUDY_PROGRESS_DIR,
STUDY_REMINDERS_FILE,
STUDY_COMMENTS_FILE,
STUDY_CERTIFICATES_FILE,
EPISODE_SCRIPTS_FILE,
QR_CODES_FILE,
REPLY_TEMPLATES_FILE,
REPLY_HISTORY_FILE,
PODCAST_CHECKLIST_FILE,
BACKUP_DIR,
UPLOADS_DIR,
UPLOADS_META_FILE,
DOWNLOAD_COUNTS_FILE,
EPISODE_PLAYS_FILE,
ANALYTICS_EVENTS_FILE,
EMAIL_SETTINGS_FILE,
EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES,
MAX_QUESTIONS,
MAX_CONTACT_SUBMISSIONS,
MAX_RECENT_VISITS,
BACKUP_RETENTION_DAYS,
MAX_STUDY_USERS,
MAX_STUDY_ENROLLMENTS_PER_USER,
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'
// ── Helpers ────────────────────────────────────────────────────────────────
export async function loadSiteContentFile(filePath) {
const raw = await readFile(filePath, 'utf8')
const parsed = JSON.parse(raw)
const safeSiteContent = sanitizeSiteContent(parsed?.siteContent)
return {
...parsed,
siteContent: safeSiteContent,
}
}
export async function checkDataDirWritable() {
try {
await mkdir(DATA_DIR, { recursive: true })
const marker = path.join(DATA_DIR, `.write-test-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`)
await writeFile(marker, 'ok', 'utf8')
await unlink(marker)
return { ok: true, error: null }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Unknown write test error' }
}
}
export async function getStorageStatus() {
const writable = await checkDataDirWritable()
const files = {}
for (const [key, filePath] of Object.entries({
adminContent: DATA_FILE,
adminContentDraft: DRAFT_DATA_FILE,
studyUsers: STUDY_USERS_FILE,
})) {
try {
const fileStat = await stat(filePath)
files[key] = {
path: filePath,
exists: true,
sizeBytes: fileStat.size,
mtime: fileStat.mtime.toISOString(),
}
} catch {
files[key] = {
path: filePath,
exists: false,
sizeBytes: 0,
mtime: null,
}
}
}
return {
dataDir: DATA_DIR,
writable,
files,
}
}
export async function refreshContentCaches() {
try {
const published = await loadSiteContentFile(DATA_FILE)
state.cachedSiteContent = published.siteContent
if (typeof published?.updatedAt === 'string') {
state.publishState.publishedAt = published.updatedAt
}
} catch {
state.cachedSiteContent = null
}
try {
const draft = await loadSiteContentFile(DRAFT_DATA_FILE)
state.cachedDraftSiteContent = draft.siteContent
if (typeof draft?.updatedAt === 'string') {
state.publishState.draftUpdatedAt = draft.updatedAt
}
} catch {
state.cachedDraftSiteContent = null
}
}
// ── Hit stats ──────────────────────────────────────────────────────────────
export function queueHitStatsWrite() {
state.hitStatsWritePromise = state.hitStatsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
HIT_STATS_FILE,
JSON.stringify({
...state.hitStats,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
state.lastHitStatsWrite = { ok: true, at: new Date().toISOString(), error: null }
})
.catch(err => {
console.error('[stats] failed to write hit stats:', err)
state.lastHitStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) }
})
}
export function loadHitStatsFromDisk() {
return readFile(HIT_STATS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.hitStats = {
totalHits: Number(parsed?.totalHits) || 0,
realHits: Number(parsed?.realHits) || 0,
botHits: Number(parsed?.botHits) || 0,
firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null,
lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null,
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
botReasons: parsed?.botReasons && typeof parsed.botReasons === 'object' ? parsed.botReasons : {},
}
})
.catch(() => {
state.hitStats = { ...EMPTY_HIT_STATS }
})
}
// ── Visitor stats ──────────────────────────────────────────────────────────
export function queueVisitorStatsWrite() {
state.visitorStatsWritePromise = state.visitorStatsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
VISITOR_STATS_FILE,
JSON.stringify({
...state.visitorStats,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
state.lastVisitorStatsWrite = { ok: true, at: new Date().toISOString(), error: null }
})
.catch(err => {
console.error('[visitor-stats] failed to write visitor stats:', err)
state.lastVisitorStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) }
})
}
export function loadVisitorStatsFromDisk() {
return readFile(VISITOR_STATS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
const loadedVisitors = parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {}
let ipHashIndex = parsed?.ipHashIndex && typeof parsed.ipHashIndex === 'object' ? parsed.ipHashIndex : {}
if (Object.keys(ipHashIndex).length === 0 && Object.keys(loadedVisitors).length > 0) {
for (const [vid, visitor] of Object.entries(loadedVisitors)) {
if (visitor?.ipHash && typeof visitor.ipHash === 'string') {
ipHashIndex[visitor.ipHash] = vid
}
}
}
state.visitorStats = {
totalVisits: Number(parsed?.totalVisits) || 0,
uniqueVisitors: Number(parsed?.uniqueVisitors) || 0,
returningVisits: Number(parsed?.returningVisits) || 0,
firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null,
lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null,
visitors: loadedVisitors,
ipHashIndex,
recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {},
}
})
.catch(() => {
state.visitorStats = { ...EMPTY_VISITOR_STATS }
})
}
// ── Contact submissions ────────────────────────────────────────────────────
export function queueContactSubmissionsWrite() {
state.contactSubmissionsWritePromise = state.contactSubmissionsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
CONTACT_SUBMISSIONS_FILE,
JSON.stringify({
submissions: state.contactSubmissions,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[contact] failed to write submissions:', err)
})
}
export function loadContactSubmissionsFromDisk() {
return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.submissions)
})
.catch(() => {
state.contactSubmissions = []
})
}
// ── Questions ──────────────────────────────────────────────────────────────
export function queueQuestionsWrite() {
state.questionsWritePromise = state.questionsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
QUESTIONS_FILE,
JSON.stringify({
questions: state.questions,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[questions] failed to write questions:', err)
})
}
export function queueDraftQuestionsWrite() {
if (state.draftQuestions === null) return
state.draftQuestionsWritePromise = state.draftQuestionsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DRAFT_QUESTIONS_FILE,
JSON.stringify({ questions: state.draftQuestions, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
state.publishState.draftUpdatedAt = new Date().toISOString()
})
.catch(err => {
console.error('[draft-questions] failed to write draft questions:', err)
})
}
export function loadQuestionsFromDisk() {
return readFile(QUESTIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
state.questions = parsed.slice(0, MAX_QUESTIONS)
} else if (Array.isArray(parsed?.questions)) {
state.questions = parsed.questions.slice(0, MAX_QUESTIONS)
} else {
state.questions = []
}
})
.catch(() => {
state.questions = []
})
}
export async function loadDraftQuestionsFromDisk() {
return readFile(DRAFT_QUESTIONS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
state.draftQuestions = parsed.slice(0, MAX_QUESTIONS)
} else if (Array.isArray(parsed?.questions)) {
state.draftQuestions = parsed.questions.slice(0, MAX_QUESTIONS)
} else {
state.draftQuestions = null
}
if (typeof parsed?.updatedAt === 'string') {
state.publishState.draftUpdatedAt = parsed.updatedAt
}
})
.catch(() => {
state.draftQuestions = null
})
}
// ── Reply templates / history ──────────────────────────────────────────────
export function queueReplyTemplatesWrite() {
state.replyTemplatesWritePromise = state.replyTemplatesWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
REPLY_TEMPLATES_FILE,
JSON.stringify({ templates: state.replyTemplates, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[reply-templates] failed to write templates:', err)
})
}
export function queueReplyHistoryWrite() {
state.replyHistoryWritePromise = state.replyHistoryWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
REPLY_HISTORY_FILE,
JSON.stringify({ items: state.replyHistory, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[reply-history] failed to write history:', err)
})
}
export function loadReplyTemplatesFromDisk() {
return readFile(REPLY_TEMPLATES_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.replyTemplates = sanitizeReplyTemplates(parsed?.templates)
})
.catch(() => {
state.replyTemplates = [...DEFAULT_REPLY_TEMPLATES]
})
}
export function loadReplyHistoryFromDisk() {
return readFile(REPLY_HISTORY_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.replyHistory = sanitizeReplyHistory(parsed?.items)
})
.catch(() => {
state.replyHistory = []
})
}
// ── Email settings ────────────────────────────────────────────────────────
export function queueEmailSettingsWrite() {
state.emailSettingsWritePromise = state.emailSettingsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
EMAIL_SETTINGS_FILE,
JSON.stringify({ settings: state.emailSettings, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[email-settings] failed to write settings:', err)
})
}
export function loadEmailSettingsFromDisk() {
return readFile(EMAIL_SETTINGS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (parsed?.settings && typeof parsed.settings === 'object') {
state.emailSettings = { ...state.emailSettings, ...parsed.settings }
}
})
.catch(() => {
// Keep defaults from state initializer
})
}
// ── 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() {
state.studyUsersWritePromise = state.studyUsersWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
STUDY_USERS_FILE,
JSON.stringify({ users: state.studyUsers, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[study-users] failed to write users:', err)
})
}
export function loadStudyUsersFromDisk() {
return readFile(STUDY_USERS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
const source = Array.isArray(parsed) ? parsed : parsed?.users
state.studyUsers = sanitizeStudyUsers(source)
})
.catch(() => {
state.studyUsers = []
})
}
// ── Study community ────────────────────────────────────────────────────────
export function queueStudyCommunityWrite() {
state.studyCommunityWritePromise = state.studyCommunityWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
STUDY_COMMUNITY_FILE,
JSON.stringify({ posts: state.studyCommunityPosts, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[study-community] failed to write discussion posts:', err)
})
}
export async function loadStudyCommunityFromDisk() {
return readFile(STUDY_COMMUNITY_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.studyCommunityPosts = sanitizeStudyCommunityPosts(parsed?.posts ?? parsed)
})
.catch(() => {
state.studyCommunityPosts = []
})
}
// ── Study notes (per-user files) ───────────────────────────────────────────
export function getUserNotesFilePath(userId) {
return path.join(STUDY_NOTES_DIR, `${userId}.json`)
}
export async function loadUserNotes(userId) {
if (state.studyNotesCache.has(userId)) return state.studyNotesCache.get(userId)
try {
const raw = await readFile(getUserNotesFilePath(userId), 'utf8')
const notes = sanitizeUserNotes(JSON.parse(raw))
state.studyNotesCache.set(userId, notes)
return notes
} catch {
const notes = {}
state.studyNotesCache.set(userId, notes)
return notes
}
}
export function queueUserNotesWrite(userId) {
const prev = state.studyNotesWriteQueues.get(userId) ?? Promise.resolve()
const next = prev
.then(async () => {
const notes = state.studyNotesCache.get(userId) ?? {}
await mkdir(STUDY_NOTES_DIR, { recursive: true })
await writeFile(getUserNotesFilePath(userId), JSON.stringify(notes, null, 2), 'utf8')
})
.catch(err => {
console.error(`[study-notes] failed to write notes for user ${userId}:`, err)
})
state.studyNotesWriteQueues.set(userId, next)
}
export async function migrateStudyNotesIfNeeded() {
try {
const raw = await readFile(STUDY_NOTES_FILE, 'utf8')
const parsed = JSON.parse(raw)
const notesByUser = parsed?.notesByUser ?? {}
const userIds = Object.keys(notesByUser)
if (userIds.length === 0) return
await mkdir(STUDY_NOTES_DIR, { recursive: true })
let migrated = 0
for (const [userId, notes] of Object.entries(notesByUser)) {
const sanitized = sanitizeUserNotes(notes)
if (Object.keys(sanitized).length === 0) continue
const filePath = getUserNotesFilePath(userId)
try { await readFile(filePath, 'utf8'); continue } catch { /* doesn't exist yet */ }
await writeFile(filePath, JSON.stringify(sanitized, null, 2), 'utf8')
migrated += 1
}
if (migrated > 0) console.log(`[study-notes] migrated ${migrated} users to per-user files`)
} catch { /* no legacy file — nothing to migrate */ }
}
// ── Study progress ─────────────────────────────────────────────────────────
export function getUserProgressFilePath(userId) {
return path.join(STUDY_PROGRESS_DIR, `${userId}.json`)
}
export async function loadUserProgress(userId) {
if (state.studyProgressCache.has(userId)) return state.studyProgressCache.get(userId)
try {
const raw = await readFile(getUserProgressFilePath(userId), 'utf8')
const progress = sanitizeStudyProgress(JSON.parse(raw))
state.studyProgressCache.set(userId, progress)
return progress
} catch {
const progress = { byStudy: {}, updatedAt: new Date().toISOString() }
state.studyProgressCache.set(userId, progress)
return progress
}
}
export function queueUserProgressWrite(userId) {
const prev = state.studyProgressWriteQueues.get(userId) ?? Promise.resolve()
const next = prev
.then(async () => {
const progress = state.studyProgressCache.get(userId) ?? { byStudy: {}, updatedAt: new Date().toISOString() }
await mkdir(STUDY_PROGRESS_DIR, { recursive: true })
await writeFile(getUserProgressFilePath(userId), JSON.stringify(progress, null, 2), 'utf8')
})
.catch(err => {
console.error(`[study-progress] failed to write progress for user ${userId}:`, err)
})
state.studyProgressWriteQueues.set(userId, next)
}
// ── Study reminders ────────────────────────────────────────────────────────
export function queueStudyRemindersWrite() {
state.studyRemindersWritePromise = state.studyRemindersWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(STUDY_REMINDERS_FILE, JSON.stringify(state.studyReminders, null, 2), 'utf8')
})
.catch(err => {
console.error('[study-reminders] failed to write reminders:', err)
})
}
export async function loadStudyRemindersFromDisk() {
try {
const raw = await readFile(STUDY_REMINDERS_FILE, 'utf8')
state.studyReminders = sanitizeStudyReminders(JSON.parse(raw))
} catch {
state.studyReminders = { users: {}, updatedAt: new Date().toISOString() }
}
}
// ── Study section comments ─────────────────────────────────────────────────
export function queueStudyCommentsWrite() {
state.studyCommentsWritePromise = state.studyCommentsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(STUDY_COMMENTS_FILE, JSON.stringify(state.studyComments, null, 2), 'utf8')
})
.catch(err => {
console.error('[study-comments] failed to write:', err)
})
}
export async function loadStudyCommentsFromDisk() {
try {
const raw = await readFile(STUDY_COMMENTS_FILE, 'utf8')
const parsed = JSON.parse(raw)
state.studyComments = Array.isArray(parsed) ? parsed.slice(0, MAX_STUDY_COMMENTS) : []
} catch {
state.studyComments = []
}
}
// ── Study certificates ─────────────────────────────────────────────────────
export function queueStudyCertificatesWrite() {
state.studyCertificatesWritePromise = state.studyCertificatesWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(STUDY_CERTIFICATES_FILE, JSON.stringify(state.studyCertificates, null, 2), 'utf8')
})
.catch(err => {
console.error('[study-certificates] failed to write:', err)
})
}
export async function loadStudyCertificatesFromDisk() {
try {
const raw = await readFile(STUDY_CERTIFICATES_FILE, 'utf8')
const parsed = JSON.parse(raw)
state.studyCertificates = Array.isArray(parsed) ? parsed : []
} catch {
state.studyCertificates = []
}
}
// ── Episode scripts ───────────────────────────────────────────────────────
export function queueEpisodeScriptsWrite() {
state.episodeScriptsWritePromise = state.episodeScriptsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(EPISODE_SCRIPTS_FILE, JSON.stringify(state.episodeScripts, null, 2), 'utf8')
})
.catch(err => {
console.error('[episode-scripts] failed to write:', err)
})
}
export async function loadEpisodeScriptsFromDisk() {
try {
const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8')
const parsed = JSON.parse(raw)
state.episodeScripts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
} catch {
state.episodeScripts = {}
}
}
// ── Download counts ────────────────────────────────────────────────────────
export function queueDownloadCountsWrite() {
state.downloadCountsWritePromise = state.downloadCountsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DOWNLOAD_COUNTS_FILE, JSON.stringify(state.downloadCounts, null, 2), 'utf8')
})
.catch(err => {
console.error('[download-counts] failed to write:', err)
})
}
export function loadDownloadCountsFromDisk() {
return readFile(DOWNLOAD_COUNTS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.downloadCounts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
})
.catch(() => {
state.downloadCounts = {}
})
}
export function incrementDownloadCount(resourceKey) {
state.downloadCounts[resourceKey] = (state.downloadCounts[resourceKey] ?? 0) + 1
queueDownloadCountsWrite()
}
// ── Episode play counts ────────────────────────────────────────────────────
export function queueEpisodePlaysWrite() {
state.episodePlaysWritePromise = state.episodePlaysWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(EPISODE_PLAYS_FILE, JSON.stringify(state.episodePlays, null, 2), 'utf8')
})
.catch(err => {
console.error('[episode-plays] failed to write:', err)
})
}
export function loadEpisodePlaysFromDisk() {
return readFile(EPISODE_PLAYS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.episodePlays = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
})
.catch(() => {
state.episodePlays = {}
})
}
export function recordEpisodePlay(title) {
const today = new Date().toISOString().slice(0, 10)
if (!state.episodePlays[title]) {
state.episodePlays[title] = { total: 0, byDay: {} }
}
state.episodePlays[title].total += 1
state.episodePlays[title].byDay[today] = (state.episodePlays[title].byDay[today] ?? 0) + 1
queueEpisodePlaysWrite()
}
// ── Analytics events ───────────────────────────────────────────────────────
const EMPTY_ANALYTICS_EVENTS = {
scrollDepth: {},
timeOnPage: {},
outboundClicks: {},
utmSources: {},
searchQueries: {},
notFound: {},
audioEvents: {},
}
export function queueAnalyticsEventsWrite() {
state.analyticsEventsWritePromise = state.analyticsEventsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(ANALYTICS_EVENTS_FILE, JSON.stringify(state.analyticsEvents, null, 2), 'utf8')
})
.catch(err => {
console.error('[analytics-events] failed to write:', err)
})
}
export function loadAnalyticsEventsFromDisk() {
return readFile(ANALYTICS_EVENTS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.analyticsEvents = {
scrollDepth: parsed?.scrollDepth ?? {},
timeOnPage: parsed?.timeOnPage ?? {},
outboundClicks: parsed?.outboundClicks ?? {},
utmSources: parsed?.utmSources ?? {},
searchQueries: parsed?.searchQueries ?? {},
notFound: parsed?.notFound ?? {},
audioEvents: parsed?.audioEvents ?? {},
}
})
.catch(() => {
state.analyticsEvents = { ...EMPTY_ANALYTICS_EVENTS }
})
}
export function recordAnalyticsEvent(type, data) {
const ev = state.analyticsEvents
if (type === 'scroll_depth') {
const path = data.path ?? '/'
if (!ev.scrollDepth[path]) ev.scrollDepth[path] = { 25: 0, 50: 0, 75: 0, 90: 0 }
const mark = String(data.depth)
ev.scrollDepth[path][mark] = (ev.scrollDepth[path][mark] ?? 0) + 1
} else if (type === 'time_on_page') {
const path = data.path ?? '/'
const seconds = Number(data.seconds) || 0
if (!ev.timeOnPage[path]) ev.timeOnPage[path] = { totalSeconds: 0, count: 0 }
ev.timeOnPage[path].totalSeconds += seconds
ev.timeOnPage[path].count += 1
} else if (type === 'outbound_click') {
const url = typeof data.url === 'string' ? data.url.slice(0, 500) : ''
if (url) ev.outboundClicks[url] = (ev.outboundClicks[url] ?? 0) + 1
} else if (type === 'utm') {
const source = typeof data.utm_source === 'string' ? data.utm_source.slice(0, 100) : 'unknown'
ev.utmSources[source] = (ev.utmSources[source] ?? 0) + 1
} else if (type === 'search_query') {
const q = typeof data.query === 'string' ? data.query.trim().slice(0, 200) : ''
if (q) ev.searchQueries[q] = (ev.searchQueries[q] ?? 0) + 1
} else if (type === 'not_found') {
const path = typeof data.path === 'string' ? data.path.slice(0, 300) : '/'
ev.notFound[path] = (ev.notFound[path] ?? 0) + 1
} else if (type === 'audio_pause') {
const title = typeof data.title === 'string' ? data.title.trim().slice(0, 200) : ''
if (title) {
if (!ev.audioEvents[title]) ev.audioEvents[title] = { pauses: 0, completions: 0, totalListenSeconds: 0 }
ev.audioEvents[title].pauses += 1
}
} else if (type === 'audio_completion') {
const title = typeof data.title === 'string' ? data.title.trim().slice(0, 200) : ''
if (title) {
if (!ev.audioEvents[title]) ev.audioEvents[title] = { pauses: 0, completions: 0, totalListenSeconds: 0 }
ev.audioEvents[title].completions += 1
}
} else if (type === 'audio_listen_time') {
const title = typeof data.title === 'string' ? data.title.trim().slice(0, 200) : ''
const seconds = Number(data.seconds) || 0
if (title && seconds > 0) {
if (!ev.audioEvents[title]) ev.audioEvents[title] = { pauses: 0, completions: 0, totalListenSeconds: 0 }
ev.audioEvents[title].totalListenSeconds += seconds
}
}
queueAnalyticsEventsWrite()
}
// ── Uploads ────────────────────────────────────────────────────────────────
export async function readUploadsMetadata() {
try {
const raw = await readFile(UPLOADS_META_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return {}
}
}
export async function writeUploadsMetadata(metadata) {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8')
}
export async function listUploadedAssets() {
await mkdir(UPLOADS_DIR, { recursive: true })
const files = await readdir(UPLOADS_DIR)
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort()
const metadata = await readUploadsMetadata()
const withStats = await Promise.all(imageFiles.map(async filename => {
const info = await stat(path.join(UPLOADS_DIR, filename))
return {
filename,
url: `/uploads/${filename}`,
sizeBytes: info.size,
updatedAt: info.mtime.toISOString(),
tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [],
}
}))
return withStats
}
// ── Backups ────────────────────────────────────────────────────────────────
export async function createBackupSnapshot(reason = 'scheduled') {
try {
await mkdir(BACKUP_DIR, { recursive: true })
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupPath = path.join(BACKUP_DIR, `snapshot-${stamp}-${reason}.json`)
const payload = {
createdAt: new Date().toISOString(),
reason,
adminContent: null,
draftContent: null,
podcastChecklist: state.podcastChecklist,
publishState: state.publishState,
hitStats: state.hitStats,
visitorStats: state.visitorStats,
contactSubmissions: state.contactSubmissions,
studyCommunityPosts: state.studyCommunityPosts,
replyTemplates: state.replyTemplates,
replyHistory: state.replyHistory,
}
try {
const contentRaw = await readFile(DATA_FILE, 'utf8')
payload.adminContent = JSON.parse(contentRaw)
} catch {
payload.adminContent = null
}
try {
const draftRaw = await readFile(DRAFT_DATA_FILE, 'utf8')
payload.draftContent = JSON.parse(draftRaw)
} catch {
payload.draftContent = null
}
await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8')
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort()
const maxFiles = BACKUP_RETENTION_DAYS
if (files.length > maxFiles) {
const toDelete = files.slice(0, files.length - maxFiles)
await Promise.all(toDelete.map(name => unlink(path.join(BACKUP_DIR, name)).catch(() => {})))
}
state.lastBackupStatus = { ok: true, at: new Date().toISOString(), error: null, file: path.basename(backupPath) }
} catch (err) {
state.lastBackupStatus = { ok: false, at: new Date().toISOString(), error: String(err), file: null }
console.error('[backup] failed to create snapshot:', err)
}
}
export async function listBackupFiles() {
await mkdir(BACKUP_DIR, { recursive: true })
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort().reverse()
return files
}
export async function readBackupPreview(filename) {
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
throw new Error('Invalid backup filename')
}
const fullPath = path.join(BACKUP_DIR, filename)
const [fileInfo, raw] = await Promise.all([
stat(fullPath),
readFile(fullPath, 'utf8'),
])
const parsed = JSON.parse(raw)
return {
filename,
sizeBytes: fileInfo.size,
createdAt: typeof parsed?.createdAt === 'string' ? parsed.createdAt : null,
reason: typeof parsed?.reason === 'string' ? parsed.reason : 'unknown',
adminUpdatedAt: typeof parsed?.adminContent?.updatedAt === 'string' ? parsed.adminContent.updatedAt : null,
totalHits: Number(parsed?.hitStats?.totalHits) || 0,
totalVisits: Number(parsed?.visitorStats?.totalVisits) || 0,
}
}
export async function listBackupPreviews() {
const files = await listBackupFiles()
const previews = await Promise.all(files.map(async filename => {
try {
return await readBackupPreview(filename)
} catch {
return {
filename,
sizeBytes: 0,
createdAt: null,
reason: 'unknown',
adminUpdatedAt: null,
totalHits: 0,
totalVisits: 0,
}
}
}))
return previews
}
export async function restoreFromBackup(filename) {
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
throw new Error('Invalid backup filename')
}
const fullPath = path.join(BACKUP_DIR, filename)
const raw = await readFile(fullPath, 'utf8')
const parsed = JSON.parse(raw)
await createBackupSnapshot('pre-restore')
if (parsed?.adminContent && typeof parsed.adminContent === 'object') {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8')
}
if (parsed?.draftContent && typeof parsed.draftContent === 'object') {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DRAFT_DATA_FILE, JSON.stringify(parsed.draftContent, null, 2), 'utf8')
}
if (parsed?.publishState && typeof parsed.publishState === 'object') {
state.publishState = {
draftUpdatedAt: typeof parsed.publishState.draftUpdatedAt === 'string' ? parsed.publishState.draftUpdatedAt : null,
publishedAt: typeof parsed.publishState.publishedAt === 'string' ? parsed.publishState.publishedAt : null,
}
}
state.hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
state.visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
state.podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
queueHitStatsWrite()
queueVisitorStatsWrite()
queueContactSubmissionsWrite()
queueReplyTemplatesWrite()
queueReplyHistoryWrite()
queuePodcastChecklistWrite()
await Promise.all([
state.hitStatsWritePromise,
state.visitorStatsWritePromise,
state.contactSubmissionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
])
await refreshContentCaches()
await createBackupSnapshot('post-restore')
}
// ── Sanitize helpers used by load functions ────────────────────────────────
// (These are defined here rather than study-helpers to avoid circular imports)
export function normalizeMessageType(value) {
if (value === 'question' || value === 'testimony' || value === 'topic') return value
return 'general'
}
function createEmailDeliveryState(status = 'pending') {
return {
status,
lastEventAt: null,
lastEventType: null,
resendEmailId: null,
error: null,
}
}
function normalizeEmailDeliveryState(value, fallbackStatus = 'pending') {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return createEmailDeliveryState(fallbackStatus)
}
return {
status: typeof value.status === 'string' && value.status.trim() ? value.status.trim().slice(0, 40) : fallbackStatus,
lastEventAt: typeof value.lastEventAt === 'string' ? value.lastEventAt : null,
lastEventType: typeof value.lastEventType === 'string' ? value.lastEventType.trim().slice(0, 120) : null,
resendEmailId: typeof value.resendEmailId === 'string' && value.resendEmailId.trim() ? value.resendEmailId.trim().slice(0, 200) : null,
error: typeof value.error === 'string' && value.error.trim() ? value.error.trim().slice(0, 600) : null,
}
}
export function normalizeContactEmailStatus(value, subscribe) {
const base = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
return {
welcome: normalizeEmailDeliveryState(base.welcome, subscribe === true ? 'pending' : 'not-requested'),
adminNotification: normalizeEmailDeliveryState(base.adminNotification, 'pending'),
adminReply: normalizeEmailDeliveryState(base.adminReply, 'idle'),
}
}
function sanitizeLoadedContactSubmissions(value) {
if (!Array.isArray(value)) return []
return value
.slice(0, MAX_CONTACT_SUBMISSIONS)
.filter(entry => entry && typeof entry === 'object')
.map(entry => ({
id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : randomUUID(),
submittedAt: typeof entry.submittedAt === 'string' ? entry.submittedAt : new Date().toISOString(),
name: typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : '',
email: typeof entry.email === 'string' ? entry.email.trim().slice(0, 320) : '',
message: typeof entry.message === 'string' ? entry.message.trim().slice(0, 3000) : '',
messageType: normalizeMessageType(entry.messageType),
subscribe: entry.subscribe === true,
archived: entry.archived === true,
emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true),
source: entry.source === 'inbound-email' || entry.source === 'download' ? entry.source : 'contact-form',
htmlBody: typeof entry.htmlBody === 'string' && entry.htmlBody.trim() ? entry.htmlBody : null,
inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '',
messageId: typeof entry.messageId === 'string' ? entry.messageId : '',
}))
}
function sanitizeLoadedHitStats(value) {
return {
totalHits: Number(value?.totalHits) || 0,
realHits: Number(value?.realHits) || 0,
botHits: Number(value?.botHits) || 0,
firstHitAt: typeof value?.firstHitAt === 'string' ? value.firstHitAt : null,
lastHitAt: typeof value?.lastHitAt === 'string' ? value.lastHitAt : null,
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {},
byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {},
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {},
byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {},
botReasons: value?.botReasons && typeof value.botReasons === 'object' ? value.botReasons : {},
}
}
function sanitizeLoadedVisitorStats(value) {
const loadedVisitors = value?.visitors && typeof value.visitors === 'object' ? value.visitors : {}
let ipHashIndex = value?.ipHashIndex && typeof value.ipHashIndex === 'object' ? value.ipHashIndex : {}
if (Object.keys(ipHashIndex).length === 0 && Object.keys(loadedVisitors).length > 0) {
for (const [vid, visitor] of Object.entries(loadedVisitors)) {
if (visitor?.ipHash && typeof visitor.ipHash === 'string') {
ipHashIndex[visitor.ipHash] = vid
}
}
}
return {
totalVisits: Number(value?.totalVisits) || 0,
uniqueVisitors: Number(value?.uniqueVisitors) || 0,
returningVisits: Number(value?.returningVisits) || 0,
firstVisitAt: typeof value?.firstVisitAt === 'string' ? value.firstVisitAt : null,
lastVisitAt: typeof value?.lastVisitAt === 'string' ? value.lastVisitAt : null,
visitors: loadedVisitors,
ipHashIndex,
recentVisits: Array.isArray(value?.recentVisits) ? value.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
geoCacheByIp: value?.geoCacheByIp && typeof value.geoCacheByIp === 'object' ? value.geoCacheByIp : {},
}
}
export function sanitizeReplyTemplates(value) {
if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES]
const out = value
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 80) : '',
subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
message: typeof item.message === 'string' ? item.message.trim().slice(0, 6000) : '',
}))
.filter(item => item.label && item.subject && item.message)
return out.length > 0 ? out : [...DEFAULT_REPLY_TEMPLATES]
}
export function sanitizeReplyHistory(value) {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object')
.slice(0, 500)
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
submissionId: typeof item.submissionId === 'string' ? item.submissionId : '',
toEmail: typeof item.toEmail === 'string' ? item.toEmail.trim().slice(0, 320) : '',
toName: typeof item.toName === 'string' ? item.toName.trim().slice(0, 200) : '',
fromEmail: typeof item.fromEmail === 'string' ? item.fromEmail.trim().slice(0, 320) : 'hello@versebyversewithnate.us',
subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
preview: typeof item.preview === 'string' ? item.preview.trim().slice(0, 500) : '',
sentAt: typeof item.sentAt === 'string' ? item.sentAt : new Date().toISOString(),
}))
}
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
}
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState })
}
if (episodes.length === 0) {
return fallback
}
return { tasks, episodes }
}
function normalizeStudyUsername(value) {
if (typeof value !== 'string') return ''
return value.trim().toLowerCase()
}
function isValidStudyUsername(value) {
return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254
}
function normalizeStudySlug(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim().toLowerCase()
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
}
export function sanitizeStudyUsers(value) {
if (!Array.isArray(value)) return []
const out = []
const seen = new Set()
for (const item of value) {
const username = normalizeStudyUsername(item?.username)
const passwordHash = typeof item?.passwordHash === 'string' ? item.passwordHash.trim() : ''
if (!isValidStudyUsername(username) || !passwordHash || seen.has(username)) continue
const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs)
? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
: []
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
const subscribeNewsletter = item?.subscribeNewsletter !== false
const studyRemindersEnabled = item?.studyRemindersEnabled === true
const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange)
? {
newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail))
? normalizeStudyUsername(item.pendingEmailChange.newEmail)
: '',
tokenHash: typeof item.pendingEmailChange.tokenHash === 'string' ? item.pendingEmailChange.tokenHash.trim() : '',
expiresAt: typeof item.pendingEmailChange.expiresAt === 'number' ? item.pendingEmailChange.expiresAt : 0,
requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null,
}
: null
seen.add(username)
out.push({
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
username,
passwordHash,
displayName,
subscribeNewsletter,
studyRemindersEnabled,
enrolledStudySlugs,
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null,
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null,
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
pendingEmailChange,
twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null,
totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null,
totpVerified: item?.totpVerified === true,
totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null,
totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [],
totpSecretPending: typeof item?.totpSecretPending === 'string' ? item.totpSecretPending : undefined,
})
}
return out.slice(0, MAX_STUDY_USERS)
}
export function sanitizeUserNotes(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const out = {}
let count = 0
for (const [sectionId, note] of Object.entries(value)) {
if (count >= MAX_STUDY_NOTES_PER_USER) break
if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue
if (typeof note !== 'string') continue
const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
if (!trimmed) continue
out[sectionId] = trimmed
count += 1
}
return out
}
export function sanitizeStudyProgress(value) {
const defaultResult = { byStudy: {}, updatedAt: new Date().toISOString() }
if (!value || typeof value !== 'object') return defaultResult
const progress = { byStudy: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() }
if (value.byStudy && typeof value.byStudy === 'object') {
for (const [studySlug, studyData] of Object.entries(value.byStudy)) {
if (typeof studySlug !== 'string' || !studySlug.trim()) continue
const completedSectionIds = Array.isArray(studyData?.completedSectionIds)
? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
: []
const quizAnswers = studyData?.quizAnswers && typeof studyData?.quizAnswers === 'object' && !Array.isArray(studyData.quizAnswers)
? Object.fromEntries(
Object.entries(studyData.quizAnswers)
.filter(([sectionId]) => typeof sectionId === 'string' && sectionId.trim())
.map(([sectionId, answers]) => [
sectionId.trim(),
Array.isArray(answers)
? answers.filter(answer => typeof answer === 'string').map(answer => answer.trim())
: [],
])
)
: {}
progress.byStudy[studySlug.trim().toLowerCase()] = {
completedSectionIds: Array.from(new Set(completedSectionIds)),
quizAnswers,
}
}
}
return progress
}
export function sanitizeStudyReminders(value) {
const defaultResult = { users: {}, updatedAt: new Date().toISOString() }
if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultResult
const reminders = { users: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() }
if (value.users && typeof value.users === 'object') {
for (const [userId, userData] of Object.entries(value.users)) {
if (typeof userId !== 'string' || !userId.trim()) continue
const studies = typeof userData === 'object' && userData && !Array.isArray(userData) ? userData : {}
const normalizedStudies = {}
for (const [studySlug, sectionIds] of Object.entries(studies)) {
if (typeof studySlug !== 'string' || !studySlug.trim()) continue
const ids = Array.isArray(sectionIds)
? sectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
: []
if (ids.length > 0) normalizedStudies[studySlug.trim().toLowerCase()] = Array.from(new Set(ids))
}
reminders.users[userId.trim()] = normalizedStudies
}
}
return reminders
}
function sanitizeStudyCommunityReply(reply) {
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return null
const message = typeof reply.message === 'string' ? reply.message.trim().slice(0, 3000) : ''
if (!message) return null
return {
id: typeof reply.id === 'string' && reply.id.trim() ? reply.id.trim() : randomUUID(),
authorUserId: typeof reply.authorUserId === 'string' && reply.authorUserId.trim() ? reply.authorUserId.trim() : '',
authorName: typeof reply.authorName === 'string' ? reply.authorName.trim().slice(0, 120) : '',
message,
createdAt: typeof reply.createdAt === 'string' ? reply.createdAt : new Date().toISOString(),
}
}
export function sanitizeStudyCommunityPosts(value) {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && !Array.isArray(item))
.map(item => {
const studySlug = normalizeStudySlug(item.studySlug)
const sectionId = typeof item.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(item.sectionId) ? item.sectionId.trim() : ''
const message = typeof item.message === 'string' ? item.message.trim().slice(0, 3000) : ''
const replies = Array.isArray(item.replies)
? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50)
: []
if (!studySlug || !message) return null
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
studySlug,
sectionId,
authorUserId: typeof item.authorUserId === 'string' && item.authorUserId.trim() ? item.authorUserId.trim() : '',
authorName: typeof item.authorName === 'string' ? item.authorName.trim().slice(0, 120) : '',
message,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
replies,
}
})
.filter(Boolean)
}
// ── QR Codes ───────────────────────────────────────────────────────────────
export function queueQrCodesWrite() {
state.qrCodesWritePromise = state.qrCodesWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(QR_CODES_FILE, JSON.stringify({ codes: state.qrCodes, scans: state.qrScans }, null, 2), 'utf8')
})
.catch(err => {
console.error('[qr-codes] failed to write:', err)
})
}
export function loadQrCodesFromDisk() {
return readFile(QR_CODES_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.qrCodes = Array.isArray(parsed?.codes) ? parsed.codes : []
state.qrScans = Array.isArray(parsed?.scans) ? parsed.scans : []
})
.catch(() => {
state.qrCodes = []
state.qrScans = []
})
}