5e43c41b2b
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1473 lines
57 KiB
JavaScript
1473 lines
57 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,
|
|
CALENDAR_EVENTS_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
|
|
})
|
|
}
|
|
|
|
// ── 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
|
|
})
|
|
}
|
|
|
|
// ── 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,
|
|
starred: entry.starred === true,
|
|
snoozedUntil: typeof entry.snoozedUntil === 'string' && !isNaN(Date.parse(entry.snoozedUntil)) ? entry.snoozedUntil : null,
|
|
threadId: typeof entry.threadId === 'string' && entry.threadId.trim() ? entry.threadId.trim() : randomUUID(),
|
|
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 : '',
|
|
attachments: Array.isArray(entry.attachments)
|
|
? entry.attachments.filter(a => a && typeof a.filename === 'string').slice(0, 10).map(a => ({
|
|
id: typeof a.id === 'string' ? a.id : randomUUID(),
|
|
filename: String(a.filename).slice(0, 255),
|
|
contentType: typeof a.contentType === 'string' ? a.contentType : 'application/octet-stream',
|
|
size: typeof a.size === 'number' ? a.size : 0,
|
|
data: typeof a.data === 'string' ? a.data : '',
|
|
}))
|
|
: [],
|
|
}))
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}) })
|
|
}
|
|
|
|
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,
|
|
reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {},
|
|
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 = []
|
|
})
|
|
}
|