Add study enrollment manager, profile settings, and email verification flow

This commit is contained in:
nmemmert
2026-05-21 11:46:49 -04:00
parent 67f3caf40b
commit 376088a52b
8 changed files with 1702 additions and 135 deletions
+709 -76
View File
@@ -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 8200 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}`)
})