Add study enrollment manager, profile settings, and email verification flow
This commit is contained in:
@@ -6,6 +6,7 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Resend } from 'resend'
|
||||
import qrcode from 'qrcode'
|
||||
import { Document, Packer, Paragraph, HeadingLevel, TextRun, AlignmentType } from 'docx'
|
||||
import {
|
||||
sanitizeSiteContent,
|
||||
escapeHtml,
|
||||
@@ -54,7 +55,8 @@ const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
|
||||
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
|
||||
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
|
||||
const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
|
||||
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json')
|
||||
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration
|
||||
const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes')
|
||||
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
||||
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
||||
const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
|
||||
@@ -547,8 +549,10 @@ const titusDownloadTokens = new Map()
|
||||
|
||||
const MAX_QUESTIONS = 1000
|
||||
const MAX_STUDY_USERS = 5000
|
||||
const MAX_STUDY_ENROLLMENTS_PER_USER = 100
|
||||
const MAX_STUDY_NOTES_PER_USER = 500
|
||||
const MAX_STUDY_NOTE_LENGTH = 12000
|
||||
const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const STUDY_SESSION_COOKIE = 'vbn_study_session'
|
||||
const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
let visitorStats = { ...EMPTY_VISITOR_STATS }
|
||||
@@ -559,8 +563,8 @@ let questions = []
|
||||
let questionsWritePromise = Promise.resolve()
|
||||
let studyUsers = []
|
||||
let studyUsersWritePromise = Promise.resolve()
|
||||
let studyNotesByUser = {}
|
||||
let studyNotesWritePromise = Promise.resolve()
|
||||
const studyNotesCache = new Map() // userId -> { [sectionId]: string }
|
||||
const studyNotesWriteQueues = new Map() // userId -> Promise
|
||||
let downloadCounts = {}
|
||||
let downloadCountsWritePromise = Promise.resolve()
|
||||
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
||||
@@ -1784,14 +1788,139 @@ function normalizeStudyUsername(value) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeStudySlug(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
|
||||
}
|
||||
|
||||
function isValidStudyUsername(value) {
|
||||
return /^[a-z0-9._-]{3,40}$/.test(value)
|
||||
return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254
|
||||
}
|
||||
|
||||
function getStudyCatalog() {
|
||||
const fallback = [
|
||||
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
|
||||
]
|
||||
const content = cachedSiteContent
|
||||
if (!content || typeof content !== 'object') return fallback
|
||||
|
||||
if (Array.isArray(content.studies) && content.studies.length > 0) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const study of content.studies) {
|
||||
const slug = normalizeStudySlug(study?.slug)
|
||||
if (!slug || seen.has(slug)) continue
|
||||
seen.add(slug)
|
||||
out.push({
|
||||
slug,
|
||||
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
|
||||
status: study?.status === 'planned' ? 'planned' : 'active',
|
||||
})
|
||||
}
|
||||
if (out.length > 0) return out
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function isEnrollableStudySlug(studySlug) {
|
||||
const normalized = normalizeStudySlug(studySlug)
|
||||
if (!normalized) return false
|
||||
return getStudyCatalog().some(study => study.slug === normalized && study.status !== 'planned')
|
||||
}
|
||||
|
||||
function getStudyTitleBySlug(studySlug) {
|
||||
const normalized = normalizeStudySlug(studySlug)
|
||||
if (!normalized) return ''
|
||||
const study = getStudyCatalog().find(item => item.slug === normalized)
|
||||
return study?.title ?? ''
|
||||
}
|
||||
|
||||
function isStudyUserEnrolled(user, studySlug) {
|
||||
const normalized = normalizeStudySlug(studySlug)
|
||||
if (!normalized || !user) return false
|
||||
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
|
||||
}
|
||||
|
||||
function hashStudyPassword(password) {
|
||||
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
|
||||
}
|
||||
|
||||
function hashEmailChangeToken(token) {
|
||||
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
|
||||
}
|
||||
|
||||
function getCanonicalBaseUrl() {
|
||||
const configured = cachedSiteContent?.seo?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl
|
||||
if (typeof configured !== 'string' || !configured.trim()) return DEFAULT_SEO.canonicalUrl
|
||||
return configured.trim()
|
||||
}
|
||||
|
||||
async function sendStudyWelcomeEmail(email, displayName) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const studiesUrl = buildAbsoluteUrl(baseUrl, '/study')
|
||||
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||||
to: [email],
|
||||
subject: process.env.RESEND_WELCOME_SUBJECT ?? 'Welcome to Verse by Verse with Nate',
|
||||
text:
|
||||
`Welcome, ${namePart}.\n\n` +
|
||||
`Your student account is ready.\n\n` +
|
||||
`Open studies: ${studiesUrl}\n` +
|
||||
`Manage account: ${accountUrl}\n\n` +
|
||||
`Grace and peace,\nVerse by Verse with Nate`,
|
||||
html:
|
||||
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||||
`<p>Welcome, <strong>${escapeHtml(namePart)}</strong>.</p>` +
|
||||
`<p>Your student account is ready.</p>` +
|
||||
`<p><a href="${escapeHtml(studiesUrl)}">Open studies</a><br/><a href="${escapeHtml(accountUrl)}">Manage account</a></p>` +
|
||||
`<p>Grace and peace,<br/>Verse by Verse with Nate</p>` +
|
||||
`</div>`,
|
||||
})
|
||||
if (error) console.error('[study-signup] welcome email send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[study-signup] welcome email exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendStudyAccountDeletedEmail(email, displayName) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const signupUrl = buildAbsoluteUrl(baseUrl, '/study/signup')
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||||
to: [email],
|
||||
subject: 'Your study account was deleted',
|
||||
text:
|
||||
`Hi ${namePart},\n\n` +
|
||||
`This confirms your study account and saved notes were deleted.\n\n` +
|
||||
`If this was not you, please contact us immediately.\n\n` +
|
||||
`Create a new account anytime: ${signupUrl}\n\n` +
|
||||
`Verse by Verse with Nate`,
|
||||
html:
|
||||
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||||
`<p>Hi ${escapeHtml(namePart)},</p>` +
|
||||
`<p>This confirms your study account and saved notes were deleted.</p>` +
|
||||
`<p>If this was not you, please contact us immediately.</p>` +
|
||||
`<p><a href="${escapeHtml(signupUrl)}">Create a new account</a></p>` +
|
||||
`<p>Verse by Verse with Nate</p>` +
|
||||
`</div>`,
|
||||
})
|
||||
if (error) console.error('[study-account] delete email send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[study-account] delete email exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeStudyUsers(value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
@@ -1802,11 +1931,32 @@ function sanitizeStudyUsers(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 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,
|
||||
pendingEmailChange: pendingEmailChange?.newEmail && pendingEmailChange?.tokenHash && pendingEmailChange.expiresAt > Date.now()
|
||||
? pendingEmailChange
|
||||
: null,
|
||||
enrolledStudySlugs,
|
||||
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
|
||||
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(),
|
||||
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
||||
@@ -1816,32 +1966,76 @@ function sanitizeStudyUsers(value) {
|
||||
return out.slice(0, MAX_STUDY_USERS)
|
||||
}
|
||||
|
||||
function sanitizeStudyNotes(value) {
|
||||
function sanitizeUserNotes(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out = {}
|
||||
|
||||
for (const [userId, entries] of Object.entries(value)) {
|
||||
if (typeof userId !== 'string' || !userId.trim()) continue
|
||||
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) continue
|
||||
|
||||
const userNotes = {}
|
||||
let count = 0
|
||||
for (const [sectionId, note] of Object.entries(entries)) {
|
||||
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
|
||||
userNotes[sectionId] = trimmed
|
||||
count += 1
|
||||
}
|
||||
|
||||
out[userId] = userNotes
|
||||
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
|
||||
}
|
||||
|
||||
function getUserNotesFilePath(userId) {
|
||||
// userId is a UUID — safe as a filename
|
||||
return path.join(STUDY_NOTES_DIR, `${userId}.json`)
|
||||
}
|
||||
|
||||
async function loadUserNotes(userId) {
|
||||
if (studyNotesCache.has(userId)) return studyNotesCache.get(userId)
|
||||
try {
|
||||
const raw = await readFile(getUserNotesFilePath(userId), 'utf8')
|
||||
const notes = sanitizeUserNotes(JSON.parse(raw))
|
||||
studyNotesCache.set(userId, notes)
|
||||
return notes
|
||||
} catch {
|
||||
const notes = {}
|
||||
studyNotesCache.set(userId, notes)
|
||||
return notes
|
||||
}
|
||||
}
|
||||
|
||||
function queueUserNotesWrite(userId) {
|
||||
const prev = studyNotesWriteQueues.get(userId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.then(async () => {
|
||||
const notes = 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)
|
||||
})
|
||||
studyNotesWriteQueues.set(userId, next)
|
||||
}
|
||||
|
||||
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 */ }
|
||||
}
|
||||
|
||||
function queueStudyUsersWrite() {
|
||||
studyUsersWritePromise = studyUsersWritePromise
|
||||
.then(async () => {
|
||||
@@ -1857,21 +2051,6 @@ function queueStudyUsersWrite() {
|
||||
})
|
||||
}
|
||||
|
||||
function queueStudyNotesWrite() {
|
||||
studyNotesWritePromise = studyNotesWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
STUDY_NOTES_FILE,
|
||||
JSON.stringify({ notesByUser: studyNotesByUser, updatedAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[study-notes] failed to write notes:', err)
|
||||
})
|
||||
}
|
||||
|
||||
function loadStudyUsersFromDisk() {
|
||||
return readFile(STUDY_USERS_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
@@ -1884,18 +2063,6 @@ function loadStudyUsersFromDisk() {
|
||||
})
|
||||
}
|
||||
|
||||
function loadStudyNotesFromDisk() {
|
||||
return readFile(STUDY_NOTES_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
const source = parsed && typeof parsed === 'object' ? parsed.notesByUser : {}
|
||||
studyNotesByUser = sanitizeStudyNotes(source)
|
||||
})
|
||||
.catch(() => {
|
||||
studyNotesByUser = {}
|
||||
})
|
||||
}
|
||||
|
||||
function findStudyUserByUsername(username) {
|
||||
return studyUsers.find(user => user.username === normalizeStudyUsername(username))
|
||||
}
|
||||
@@ -1952,6 +2119,13 @@ function requireStudyAuth(req, res, next) {
|
||||
next()
|
||||
}
|
||||
|
||||
function getStudySlugFromNoteId(sectionId) {
|
||||
if (typeof sectionId !== 'string') return ''
|
||||
const separatorIndex = sectionId.indexOf('--')
|
||||
if (separatorIndex <= 0) return ''
|
||||
return normalizeStudySlug(sectionId.slice(0, separatorIndex))
|
||||
}
|
||||
|
||||
function normalizeLessonSectionId(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
@@ -1979,15 +2153,23 @@ const studyAuthRateLimiter = rateLimit({
|
||||
|
||||
app.get('/api/study-auth/status', (req, res) => {
|
||||
const user = getStudyUserFromRequest(req)
|
||||
res.json({ authenticated: Boolean(user), username: user?.username ?? '' })
|
||||
res.json({
|
||||
authenticated: Boolean(user),
|
||||
username: user?.username ?? '',
|
||||
displayName: user?.displayName ?? '',
|
||||
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
||||
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
||||
const username = normalizeStudyUsername(req.body?.username)
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
const subscribe = req.body?.subscribe === true
|
||||
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
|
||||
|
||||
if (!isValidStudyUsername(username)) {
|
||||
res.status(400).json({ message: 'Username must be 3-40 characters (letters, numbers, dot, underscore, dash).' })
|
||||
res.status(400).json({ message: 'Please enter a valid email address.' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1997,7 +2179,7 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
||||
}
|
||||
|
||||
if (findStudyUserByUsername(username)) {
|
||||
res.status(409).json({ message: 'That username is already in use.' })
|
||||
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2006,6 +2188,10 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash: hashStudyPassword(password),
|
||||
displayName,
|
||||
subscribeNewsletter: subscribe,
|
||||
pendingEmailChange: null,
|
||||
enrolledStudySlugs: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
@@ -2017,9 +2203,22 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
||||
}
|
||||
queueStudyUsersWrite()
|
||||
|
||||
if (subscribe) {
|
||||
addContactSubmission({ name: displayName || username, email: username, message: '', messageType: 'general', subscribe: true })
|
||||
syncContactToResend(displayName || username, username).catch(err => console.error('[study-signup] resend sync error:', err))
|
||||
}
|
||||
|
||||
sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err))
|
||||
|
||||
const sessionToken = createStudySession(user.id)
|
||||
setStudySessionCookie(res, sessionToken)
|
||||
res.json({ ok: true, username: user.username })
|
||||
res.json({
|
||||
ok: true,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
subscribeNewsletter: user.subscribeNewsletter,
|
||||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||||
@@ -2028,7 +2227,7 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||||
const user = findStudyUserByUsername(username)
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ message: 'Invalid username or password.' })
|
||||
res.status(401).json({ message: 'Invalid email or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2037,7 +2236,7 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||||
const a = Buffer.from(submittedHash, 'utf8')
|
||||
const b = Buffer.from(expectedHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(401).json({ message: 'Invalid username or password.' })
|
||||
res.status(401).json({ message: 'Invalid email or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2047,7 +2246,69 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||||
|
||||
const sessionToken = createStudySession(user.id)
|
||||
setStudySessionCookie(res, sessionToken)
|
||||
res.json({ ok: true, username: user.username })
|
||||
res.json({
|
||||
ok: true,
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? '',
|
||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/study-enrollment', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
res.json({
|
||||
enrolledStudySlugs: Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||||
availableStudies: getStudyCatalog()
|
||||
.filter(study => study.status !== 'planned')
|
||||
.map(study => ({ slug: study.slug, title: study.title })),
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
|
||||
res.status(404).json({ message: 'Study not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
if (!enrolled.includes(studySlug)) {
|
||||
user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
studySlug,
|
||||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
|
||||
res.status(404).json({ message: 'Study not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
if (enrolled.includes(studySlug)) {
|
||||
user.enrolledStudySlugs = enrolled.filter(slug => slug !== studySlug)
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
studySlug,
|
||||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/logout', (req, res) => {
|
||||
@@ -2060,54 +2321,396 @@ app.post('/api/study-auth/logout', (req, res) => {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/study-notes', requireStudyAuth, (req, res) => {
|
||||
app.get('/api/study-notes', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = studyNotesByUser[user.id] ?? {}
|
||||
const notes = await loadUserNotes(user.id)
|
||||
res.json({ notes })
|
||||
})
|
||||
|
||||
app.get('/api/study-notes/:sectionId', requireStudyAuth, (req, res) => {
|
||||
app.get('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!sectionId) {
|
||||
res.status(400).json({ message: 'Invalid section id.' })
|
||||
return
|
||||
}
|
||||
|
||||
const user = req.studyUser
|
||||
const note = studyNotesByUser[user.id]?.[sectionId] ?? ''
|
||||
res.json({ note })
|
||||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||||
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
|
||||
return
|
||||
}
|
||||
const notes = await loadUserNotes(user.id)
|
||||
res.json({ note: notes[sectionId] ?? '' })
|
||||
})
|
||||
|
||||
app.put('/api/study-notes/:sectionId', requireStudyAuth, (req, res) => {
|
||||
app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!sectionId) {
|
||||
res.status(400).json({ message: 'Invalid section id.' })
|
||||
return
|
||||
}
|
||||
|
||||
const user = req.studyUser
|
||||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||||
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
|
||||
return
|
||||
}
|
||||
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
|
||||
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
||||
|
||||
if (!studyNotesByUser[user.id]) {
|
||||
studyNotesByUser[user.id] = {}
|
||||
}
|
||||
const notes = await loadUserNotes(user.id)
|
||||
|
||||
if (!note) {
|
||||
delete studyNotesByUser[user.id][sectionId]
|
||||
delete notes[sectionId]
|
||||
} else {
|
||||
const existingCount = Object.keys(studyNotesByUser[user.id]).length
|
||||
if (!studyNotesByUser[user.id][sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) {
|
||||
const existingCount = Object.keys(notes).length
|
||||
if (!notes[sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) {
|
||||
res.status(400).json({ message: 'Notes limit reached for this account.' })
|
||||
return
|
||||
}
|
||||
studyNotesByUser[user.id][sectionId] = note
|
||||
notes[sectionId] = note
|
||||
}
|
||||
|
||||
queueStudyNotesWrite()
|
||||
studyNotesCache.set(user.id, notes)
|
||||
queueUserNotesWrite(user.id)
|
||||
res.json({ ok: true, note })
|
||||
})
|
||||
|
||||
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = await loadUserNotes(user.id)
|
||||
|
||||
// Build a lookup of sectionId -> { title, reference, studyTitle } from cached content
|
||||
const sectionMeta = {}
|
||||
const content = cachedSiteContent
|
||||
if (content) {
|
||||
const studies = Array.isArray(content.studies) && content.studies.length > 0
|
||||
? content.studies
|
||||
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', sections: content.colossiansStudySections ?? [] }]
|
||||
for (const study of studies) {
|
||||
for (const section of (study.sections ?? [])) {
|
||||
sectionMeta[`${study.slug}--${section.id}`] = {
|
||||
studyTitle: study.title,
|
||||
title: section.title,
|
||||
reference: section.reference,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group notes by study
|
||||
const byStudy = {}
|
||||
for (const [noteKey, noteText] of Object.entries(notes)) {
|
||||
if (!noteText?.trim()) continue
|
||||
const dashIndex = noteKey.indexOf('--')
|
||||
const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown'
|
||||
if (!byStudy[studySlug]) byStudy[studySlug] = []
|
||||
byStudy[studySlug].push({ noteKey, noteText })
|
||||
}
|
||||
|
||||
const docChildren = [
|
||||
new Paragraph({
|
||||
text: 'My Study Notes',
|
||||
heading: HeadingLevel.TITLE,
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true })],
|
||||
spacing: { after: 400 },
|
||||
}),
|
||||
]
|
||||
|
||||
for (const [studySlug, entries] of Object.entries(byStudy)) {
|
||||
const studyTitle = entries[0] ? (sectionMeta[entries[0].noteKey]?.studyTitle ?? studySlug) : studySlug
|
||||
docChildren.push(
|
||||
new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }),
|
||||
)
|
||||
for (const { noteKey, noteText } of entries) {
|
||||
const meta = sectionMeta[noteKey]
|
||||
const lessonTitle = meta?.title ?? noteKey
|
||||
const reference = meta?.reference ?? ''
|
||||
docChildren.push(
|
||||
new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
|
||||
)
|
||||
if (reference) {
|
||||
docChildren.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: reference, italics: true, color: '555555' })],
|
||||
spacing: { after: 120 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const line of noteText.split('\n')) {
|
||||
docChildren.push(
|
||||
new Paragraph({ text: line.trim(), spacing: { after: 80 } }),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (docChildren.length <= 2) {
|
||||
docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } }))
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
creator: 'Verse by Verse with Nate',
|
||||
title: 'My Study Notes',
|
||||
sections: [{ children: docChildren }],
|
||||
})
|
||||
|
||||
const buffer = await Packer.toBuffer(doc)
|
||||
const filename = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx`
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||||
res.send(buffer)
|
||||
})
|
||||
|
||||
app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||||
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||||
|
||||
const currentHash = hashStudyPassword(currentPassword)
|
||||
const a = Buffer.from(currentHash, 'utf8')
|
||||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 8 || newPassword.length > 200) {
|
||||
res.status(400).json({ message: 'New password must be 8–200 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
user.passwordHash = hashStudyPassword(newPassword)
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = await loadUserNotes(user.id)
|
||||
const noteEntries = Object.entries(notes)
|
||||
|
||||
const studies = getStudyCatalog().map(study => {
|
||||
const totalLessons = Array.isArray(cachedSiteContent?.studies)
|
||||
? (cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === study.slug)?.sections?.length ?? 0)
|
||||
: 0
|
||||
const noteCount = noteEntries.filter(([key, value]) => key.startsWith(`${study.slug}--`) && typeof value === 'string' && value.trim()).length
|
||||
return {
|
||||
slug: study.slug,
|
||||
title: study.title,
|
||||
status: study.status,
|
||||
enrolled: isStudyUserEnrolled(user, study.slug),
|
||||
totalLessons,
|
||||
completedLessons: noteCount,
|
||||
noteCount,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({
|
||||
profile: {
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? '',
|
||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||
},
|
||||
stats: {
|
||||
noteCount: Object.keys(notes).length,
|
||||
memberSince: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
studies,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-account/profile', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
|
||||
user.displayName = displayName
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true, displayName: user.displayName })
|
||||
})
|
||||
|
||||
app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const subscribeNewsletter = req.body?.subscribeNewsletter === true
|
||||
user.subscribeNewsletter = subscribeNewsletter
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
|
||||
if (subscribeNewsletter) {
|
||||
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
|
||||
}
|
||||
|
||||
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter })
|
||||
})
|
||||
|
||||
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const newEmail = normalizeStudyUsername(req.body?.newEmail)
|
||||
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||||
|
||||
if (!isValidStudyUsername(newEmail)) {
|
||||
res.status(400).json({ message: 'Please enter a valid email address.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (newEmail === user.username) {
|
||||
res.status(400).json({ message: 'That is already your current email.' })
|
||||
return
|
||||
}
|
||||
|
||||
const existing = findStudyUserByUsername(newEmail)
|
||||
if (existing && existing.id !== user.id) {
|
||||
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||
return
|
||||
}
|
||||
|
||||
const currentHash = hashStudyPassword(currentPassword)
|
||||
const a = Buffer.from(currentHash, 'utf8')
|
||||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||
return
|
||||
}
|
||||
|
||||
const rawToken = randomUUID()
|
||||
const tokenHash = hashEmailChangeToken(rawToken)
|
||||
const expiresAt = Date.now() + EMAIL_CHANGE_TOKEN_TTL_MS
|
||||
|
||||
user.pendingEmailChange = {
|
||||
newEmail,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
requestedAt: new Date().toISOString(),
|
||||
}
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
|
||||
if (process.env.RESEND_API_KEY) {
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const verifyUrl = buildAbsoluteUrl(baseUrl, `/study/account?verifyEmailToken=${encodeURIComponent(rawToken)}`)
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||||
to: [newEmail],
|
||||
subject: 'Confirm your new email address',
|
||||
text:
|
||||
`Use this link to confirm your new account email:\n${verifyUrl}\n\n` +
|
||||
`If you did not request this change, ignore this message.`,
|
||||
html:
|
||||
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||||
`<p>Click the link below to confirm your new account email:</p>` +
|
||||
`<p><a href="${escapeHtml(verifyUrl)}">Confirm email change</a></p>` +
|
||||
`<p>If you did not request this change, ignore this message.</p>` +
|
||||
`</div>`,
|
||||
})
|
||||
if (error) {
|
||||
console.error('[study-account] email change send error:', error)
|
||||
res.status(503).json({ message: 'Could not send verification email right now.' })
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[study-account] email change send exception:', err)
|
||||
res.status(503).json({ message: 'Could not send verification email right now.' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true, verificationSent: true })
|
||||
})
|
||||
|
||||
app.post('/api/study-account/verify-email-change', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
|
||||
const pending = user.pendingEmailChange
|
||||
|
||||
if (!token || !pending || !pending.tokenHash) {
|
||||
res.status(400).json({ message: 'No pending email change request found.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (pending.expiresAt <= Date.now()) {
|
||||
user.pendingEmailChange = null
|
||||
queueStudyUsersWrite()
|
||||
res.status(400).json({ message: 'This verification link has expired. Request a new email change.' })
|
||||
return
|
||||
}
|
||||
|
||||
const submittedHash = hashEmailChangeToken(token)
|
||||
const a = Buffer.from(submittedHash, 'utf8')
|
||||
const b = Buffer.from(pending.tokenHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(400).json({ message: 'Invalid verification token.' })
|
||||
return
|
||||
}
|
||||
|
||||
const newEmail = normalizeStudyUsername(pending.newEmail)
|
||||
if (!isValidStudyUsername(newEmail)) {
|
||||
user.pendingEmailChange = null
|
||||
queueStudyUsersWrite()
|
||||
res.status(400).json({ message: 'Pending email address is invalid.' })
|
||||
return
|
||||
}
|
||||
|
||||
const existing = findStudyUserByUsername(newEmail)
|
||||
if (existing && existing.id !== user.id) {
|
||||
user.pendingEmailChange = null
|
||||
queueStudyUsersWrite()
|
||||
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||
return
|
||||
}
|
||||
|
||||
user.username = newEmail
|
||||
user.pendingEmailChange = null
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
|
||||
if (user.subscribeNewsletter !== false) {
|
||||
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
|
||||
}
|
||||
|
||||
res.json({ ok: true, username: user.username })
|
||||
})
|
||||
|
||||
app.get('/api/study-account/stats', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = await loadUserNotes(user.id)
|
||||
res.json({
|
||||
noteCount: Object.keys(notes).length,
|
||||
memberSince: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/api/study-account', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const deletedEmail = user.username
|
||||
const deletedDisplayName = user.displayName || user.username
|
||||
|
||||
// Revoke all sessions for this user
|
||||
for (const [token, session] of studySessions) {
|
||||
if (session.userId === user.id) studySessions.delete(token)
|
||||
}
|
||||
|
||||
// Remove from users list and persist
|
||||
studyUsers = studyUsers.filter(u => u.id !== user.id)
|
||||
queueStudyUsersWrite()
|
||||
|
||||
// Delete notes file
|
||||
studyNotesCache.delete(user.id)
|
||||
try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ }
|
||||
|
||||
sendStudyAccountDeletedEmail(deletedEmail, deletedDisplayName).catch(err => {
|
||||
console.error('[study-account] delete email error:', err)
|
||||
})
|
||||
|
||||
clearStudySessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/admin-auth/status', async (req, res) => {
|
||||
res.json({
|
||||
authenticated: isValidAdminSession(req),
|
||||
@@ -2314,6 +2917,22 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
||||
.map(([reason, count]) => ({ reason, count }))
|
||||
|
||||
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
|
||||
const enrollmentCountsBySlug = {}
|
||||
for (const user of studyUsers) {
|
||||
const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
for (const studySlug of userEnrollments) {
|
||||
enrollmentCountsBySlug[studySlug] = (enrollmentCountsBySlug[studySlug] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
const enrollmentsByStudy = getStudyCatalog()
|
||||
.map(study => ({
|
||||
slug: study.slug,
|
||||
title: study.title,
|
||||
count: enrollmentCountsBySlug[study.slug] ?? 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
const enrolledUsers = studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
|
||||
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
|
||||
|
||||
res.json({
|
||||
totalHits: hitStats.totalHits,
|
||||
@@ -2354,6 +2973,12 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
||||
totalSubmissions: contactSubmissions.length,
|
||||
totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
|
||||
},
|
||||
studyEnrollment: {
|
||||
totalUsers: studyUsers.length,
|
||||
enrolledUsers,
|
||||
totalEnrollments,
|
||||
enrollmentsByStudy,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3614,7 +4239,7 @@ Promise.all([
|
||||
loadQuestionsFromDisk(),
|
||||
loadDraftQuestionsFromDisk(),
|
||||
loadStudyUsersFromDisk(),
|
||||
loadStudyNotesFromDisk(),
|
||||
migrateStudyNotesIfNeeded(),
|
||||
loadDownloadCountsFromDisk(),
|
||||
loadPodcastChecklistFromDisk(),
|
||||
refreshContentCaches(),
|
||||
@@ -3628,6 +4253,14 @@ Promise.all([
|
||||
createBackupSnapshot('scheduled').catch(() => {})
|
||||
}, BACKUP_INTERVAL_MS)
|
||||
|
||||
// Purge expired study sessions every hour to prevent unbounded memory growth
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [token, session] of studySessions) {
|
||||
if (session.expiresAt <= now) studySessions.delete(token)
|
||||
}
|
||||
}, 60 * 60 * 1000)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user