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, 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 = [] }) } // ── 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), })) } function sanitizeLoadedHitStats(value) { return { totalHits: Number(value?.totalHits) || 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 : {}, byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {}, } } function sanitizeLoadedVisitorStats(value) { 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: value?.visitors && typeof value.visitors === 'object' ? value.visitors : {}, 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 = [] }) }