stuff
This commit is contained in:
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user