This commit is contained in:
nmemmert
2026-06-02 14:02:00 -04:00
parent b18f4e88fa
commit 5b448aef0f
12 changed files with 1466 additions and 184 deletions
+404 -49
View File
@@ -635,6 +635,8 @@ let studyUsers = []
let studyUsersWritePromise = Promise.resolve()
const studyNotesCache = new Map() // userId -> { [sectionId]: string }
const studyNotesWriteQueues = new Map() // userId -> Promise
let studyReminders = { users: {}, updatedAt: new Date().toISOString() }
let studyRemindersWritePromise = Promise.resolve()
const studyProgressCache = new Map() // userId -> { byStudy: Record<string, { completedSectionIds: string[] }> }
const studyProgressWriteQueues = new Map() // userId -> Promise
let studyCommunityPosts = []
@@ -2244,6 +2246,28 @@ function normalizeStudyUsername(value) {
return value.trim().toLowerCase()
}
function getStudyAvatarUrl(subject) {
let customAvatar = ''
let username = ''
if (subject && typeof subject === 'object') {
customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : ''
username = normalizeStudyUsername(subject.username)
} else if (typeof subject === 'string') {
username = normalizeStudyUsername(subject)
}
if (customAvatar) return customAvatar
if (!username) return ''
const hash = createHash('md5').update(username).digest('hex')
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96`
}
function findStudyUserById(userId) {
if (typeof userId !== 'string' || !userId.trim()) return undefined
return studyUsers.find(user => user.id === userId)
}
function normalizeStudySlug(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim().toLowerCase()
@@ -2437,6 +2461,7 @@ function sanitizeStudyUsers(value) {
: []
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))
@@ -2454,13 +2479,11 @@ function sanitizeStudyUsers(value) {
passwordHash,
displayName,
subscribeNewsletter,
pendingEmailChange: pendingEmailChange?.newEmail && pendingEmailChange?.tokenHash && pendingEmailChange.expiresAt > Date.now()
? pendingEmailChange
: null,
studyRemindersEnabled,
enrolledStudySlugs,
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(),
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
pendingEmailChange,
})
}
@@ -2599,8 +2622,21 @@ function sanitizeStudyProgress(value) {
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,
}
}
}
@@ -2636,6 +2672,130 @@ function queueUserProgressWrite(userId) {
studyProgressWriteQueues.set(userId, next)
}
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
}
async function loadStudyRemindersFromDisk() {
try {
const raw = await readFile(STUDY_REMINDERS_FILE, 'utf8')
studyReminders = sanitizeStudyReminders(JSON.parse(raw))
} catch {
studyReminders = { users: {}, updatedAt: new Date().toISOString() }
}
}
function queueStudyRemindersWrite() {
studyRemindersWritePromise = studyRemindersWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(STUDY_REMINDERS_FILE, JSON.stringify(studyReminders, null, 2), 'utf8')
})
.catch(err => {
console.error('[study-reminders] failed to write reminders:', err)
})
}
async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) {
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 bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">A new study lesson is available in ${escapeHtml(studyTitle)}.</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) is now unlocked.</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>`
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_REMINDER_SUBJECT ?? `New lesson available: ${sectionTitle}`,
text:
`Hi ${namePart},\n\n` +
`A new lesson is available in ${studyTitle}.\n` +
`${sectionTitle} (${sectionReference}) is now unlocked.\n\n` +
`Open it here: ${sectionUrl}\n\n` +
`Grace and peace,\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'New Lesson Available',
eyebrow: 'Study Reminder',
bodyHtml,
ctaLabel: 'Open the Lesson',
ctaUrl: sectionUrl,
footerHtml,
}),
})
if (error) console.error('[study-reminder] send error:', error)
} catch (err) {
console.error('[study-reminder] send exception:', err)
}
}
async function scheduleStudyReminders() {
if (!cachedSiteContent) return
const now = new Date()
const userIds = studyUsers.filter(user => user.studyRemindersEnabled === true).map(user => user.id)
if (userIds.length === 0) return
for (const user of studyUsers) {
if (user.studyRemindersEnabled !== true) continue
const email = user.username
const displayName = user.displayName || email
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (enrolledStudySlugs.length === 0) continue
const userSent = studyReminders.users[user.id] ?? {}
for (const studySlug of enrolledStudySlugs) {
const study = Array.isArray(cachedSiteContent.studies)
? cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined
if (!study) continue
for (const section of study.sections ?? []) {
const sectionId = section.id
const releaseDate = getSectionReleaseDate(section)
if (!releaseDate) continue
if (releaseDate > now) continue
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
if (sentForStudy.includes(sectionId)) continue
const hoursSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60)
if (hoursSinceRelease > 24) continue
const sectionUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/${study.slug}/${section.id}`)
await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl)
userSent[studySlug] = [...sentForStudy, sectionId]
studyReminders.users[user.id] = userSent
}
}
}
studyReminders.updatedAt = new Date().toISOString()
queueStudyRemindersWrite()
}
async function migrateStudyNotesIfNeeded() {
try {
const raw = await readFile(STUDY_NOTES_FILE, 'utf8')
@@ -2779,6 +2939,8 @@ app.get('/api/study-auth/status', (req, res) => {
username: user?.username ?? '',
displayName: user?.displayName ?? '',
subscribeNewsletter: user?.subscribeNewsletter !== false,
studyRemindersEnabled: user?.studyRemindersEnabled === true,
avatarUrl: user ? getStudyAvatarUrl(user) : '',
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
})
})
@@ -2811,6 +2973,7 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
passwordHash: hashStudyPassword(password),
displayName,
subscribeNewsletter: subscribe,
studyRemindersEnabled: false,
pendingEmailChange: null,
enrolledStudySlugs: [],
createdAt: now,
@@ -2838,6 +3001,8 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
username: user.username,
displayName: user.displayName,
subscribeNewsletter: user.subscribeNewsletter,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs,
})
})
@@ -2872,6 +3037,8 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
username: user.username,
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
})
})
@@ -3041,6 +3208,56 @@ app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (r
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
})
app.get('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
return
}
const progress = await loadUserProgress(user.id)
const quizAnswers = progress.byStudy[studySlug]?.quizAnswers?.[sectionId] ?? []
res.json({ studySlug, sectionId, answers: quizAnswers })
})
app.post('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
return
}
const rawAnswers = req.body?.answers
const answers = Array.isArray(rawAnswers)
? rawAnswers.map(answer => typeof answer === 'string' ? answer.trim() : '').filter(Boolean)
: []
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
studyProgress.quizAnswers = studyProgress.quizAnswers || {}
studyProgress.quizAnswers[sectionId] = answers
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, sectionId, answers })
})
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
@@ -3084,6 +3301,23 @@ app.get('/api/study-community', requireStudyAuth, async (req, res) => {
.filter(post => post.studySlug === studySlug)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 50)
.map(post => {
const author = findStudyUserById(post.authorUserId)
const enrichedPost = {
...post,
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
replies: Array.isArray(post.replies)
? post.replies.map(reply => {
const replyAuthor = findStudyUserById(reply.authorUserId)
return {
...reply,
authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || ''),
}
})
: [],
}
return enrichedPost
})
res.json({
studySlug,
@@ -3115,6 +3349,7 @@ app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
sectionId,
authorUserId: user.id,
authorName,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: now,
replies: [],
@@ -3152,6 +3387,7 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (
id: randomUUID(),
authorUserId: user.id,
authorName: user.displayName?.trim() || user.username,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: new Date().toISOString(),
}
@@ -3167,86 +3403,151 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
const progress = await loadUserProgress(user.id)
// Build a lookup of sectionId -> { title, reference, studyTitle } from cached content
// Build a lookup of sectionId -> { title, reference, studyTitle, description } 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,
}
const studies = content && Array.isArray(content.studies) && content.studies.length > 0
? content.studies
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', description: '', sections: content?.colossiansStudySections ?? [] }]
for (const study of studies) {
for (const section of (study.sections ?? [])) {
sectionMeta[`${study.slug}--${section.id}`] = {
studyTitle: study.title,
studyDescription: typeof study.description === 'string' ? study.description : '',
title: section.title,
reference: section.reference,
studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [],
}
}
}
// Group notes by study
const byStudy = {}
const studyEntries = {}
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 sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].noteText = noteText.trim()
}
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
const quizAnswersBySection = studyProgress.quizAnswers || {}
for (const [sectionId, answers] of Object.entries(quizAnswersBySection)) {
if (!Array.isArray(answers) || answers.length === 0) continue
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].quizAnswers = answers.filter(answer => typeof answer === 'string' && answer.trim()).map(answer => answer.trim())
}
}
const studySlugs = Array.from(new Set([
...Object.keys(studyEntries),
...Object.values(studies).map(study => study.slug),
]))
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 },
}),
new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }),
new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }),
new Paragraph({ text: `Student: ${user.displayName || user.username}`, spacing: { after: 120 } }),
new Paragraph({ 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
for (const studySlug of studySlugs) {
const study = studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
const studyTitle = study?.title || studySlug
const studyDescription = typeof study?.description === 'string' ? study.description : ''
const sectionIds = studyEntries[studySlug] ? Object.keys(studyEntries[studySlug]) : []
if (sectionIds.length === 0) continue
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 ?? ''
if (studyDescription) {
docChildren.push(
new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
new Paragraph({ text: studyDescription, spacing: { after: 240 } }),
)
if (reference) {
}
const noteCount = sectionIds.filter(sectionId => studyEntries[studySlug][sectionId].noteText).length
const quizCount = sectionIds.filter(sectionId => Array.isArray(studyEntries[studySlug][sectionId].quizAnswers) && studyEntries[studySlug][sectionId].quizAnswers.length > 0).length
docChildren.push(
new Paragraph({ text: `Notes: ${noteCount} | Quiz sections: ${quizCount}`, italics: true, spacing: { after: 240 } }),
)
const orderedSectionIds = study?.sections?.map(section => section.id).filter(id => sectionIds.includes(id)) ?? sectionIds
for (const sectionId of orderedSectionIds) {
const entry = studyEntries[studySlug][sectionId]
if (!entry) continue
const meta = sectionMeta[`${studySlug}--${sectionId}`] || { title: sectionId, reference: '' }
docChildren.push(
new Paragraph({ text: meta.title, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
)
if (meta.reference) {
docChildren.push(
new Paragraph({
children: [new TextRun({ text: reference, italics: true, color: '555555' })],
spacing: { after: 120 },
}),
new Paragraph({ children: [new TextRun({ text: meta.reference, italics: true, color: '555555' })], spacing: { after: 120 } }),
)
}
for (const line of noteText.split('\n')) {
if (entry.noteText) {
docChildren.push(
new Paragraph({ text: line.trim(), spacing: { after: 80 } }),
new Paragraph({ text: 'Notes', heading: HeadingLevel.HEADING_3, spacing: { before: 120 } }),
)
for (const line of entry.noteText.split('\n')) {
docChildren.push(new Paragraph({ text: line.trim(), spacing: { after: 80 } }))
}
}
if (Array.isArray(meta.studyQuestions) && meta.studyQuestions.length > 0) {
docChildren.push(
new Paragraph({ text: 'Quiz Questions', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }),
)
meta.studyQuestions.forEach((question, index) => {
docChildren.push(
new Paragraph({
children: [
new TextRun({ text: `${index + 1}. `, bold: true }),
new TextRun({ text: question }),
],
spacing: { after: 80 },
}),
)
})
}
if (Array.isArray(entry.quizAnswers) && entry.quizAnswers.length > 0) {
docChildren.push(
new Paragraph({ text: 'Quiz Answers', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }),
)
entry.quizAnswers.forEach((answer, index) => {
docChildren.push(
new Paragraph({
children: [
new TextRun({ text: `Answer ${index + 1}: `, bold: true }),
new TextRun({ text: answer }),
],
spacing: { after: 80 },
}),
)
})
}
}
}
if (docChildren.length <= 2) {
docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } }))
if (docChildren.length <= 4) {
docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } }))
}
const doc = new Document({
creator: 'Verse by Verse with Nate',
title: 'My Study Notes',
title: 'My Study Export',
sections: [{ children: docChildren }],
})
const buffer = await Packer.toBuffer(doc)
const filename = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx`
const filename = `my-study-export-${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)
@@ -3304,6 +3605,8 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
username: user.username,
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user),
},
stats: {
noteCount: Object.keys(notes).length,
@@ -3317,16 +3620,59 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
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) : ''
const avatarUrl = typeof req.body?.avatarUrl === 'string' ? req.body.avatarUrl.trim() : ''
if (avatarUrl && !/^https?:\/\//i.test(avatarUrl) && !avatarUrl.startsWith('/uploads/') && !avatarUrl.startsWith('data:image/')) {
res.status(400).json({ message: 'Avatar must be a valid uploaded image, data URI, or https URL.' })
return
}
user.displayName = displayName
user.avatarUrl = avatarUrl
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName })
res.json({ ok: true, displayName: user.displayName, avatarUrl: user.avatarUrl || getStudyAvatarUrl(user) })
})
app.post('/api/study-account/avatar-upload', requireStudyAuth, async (req, res) => {
try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const ext = inferImageExtensionFromDataUrl(dataUrl)
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
return
}
const base64 = dataUrl.split(',')[1] ?? ''
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > (4 * 1024 * 1024)) {
res.status(400).json({ message: 'Upload must be between 1 byte and 4MB.' })
return
}
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName || 'avatar'}-${Date.now()}${ext}`
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
const metadata = await readUploadsMetadata()
metadata[finalName] = []
await writeUploadsMetadata(metadata)
res.json({ ok: true, url: `/uploads/${finalName}` })
} catch (err) {
console.error('[study-account-avatar-upload] upload error:', err)
res.status(500).json({ message: 'Avatar upload failed.' })
}
})
app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
const user = req.studyUser
const subscribeNewsletter = req.body?.subscribeNewsletter === true
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
user.subscribeNewsletter = subscribeNewsletter
user.studyRemindersEnabled = studyRemindersEnabled
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
@@ -3334,7 +3680,7 @@ app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
}
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter })
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true })
})
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
@@ -5150,6 +5496,7 @@ Promise.all([
loadDraftQuestionsFromDisk(),
loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(),
loadStudyRemindersFromDisk(),
migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(),
loadPodcastChecklistFromDisk(),
@@ -5172,6 +5519,14 @@ Promise.all([
}
}, 60 * 60 * 1000)
// Send study reminder emails for newly released lessons every hour
setInterval(() => {
scheduleStudyReminders().catch(err => {
console.error('[study-reminders] failed to schedule reminders:', err)
})
}, 60 * 60 * 1000)
void scheduleStudyReminders()
app.listen(PORT, () => {
logResendEmailAlignmentWarnings()
console.log(`Portfolio app listening on http://localhost:${PORT}`)