Add study completion, checkpoints, lesson announcements, and lesson-thread defaults

This commit is contained in:
nmemmert
2026-06-02 12:03:29 -04:00
parent 33ac2fb5d0
commit b18f4e88fa
4 changed files with 362 additions and 8 deletions
+128 -1
View File
@@ -61,6 +61,7 @@ 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') // legacy — kept only for one-time migration
const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes')
const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress')
const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
@@ -634,6 +635,8 @@ let studyUsers = []
let studyUsersWritePromise = Promise.resolve()
const studyNotesCache = new Map() // userId -> { [sectionId]: string }
const studyNotesWriteQueues = new Map() // userId -> Promise
const studyProgressCache = new Map() // userId -> { byStudy: Record<string, { completedSectionIds: string[] }> }
const studyProgressWriteQueues = new Map() // userId -> Promise
let studyCommunityPosts = []
let studyCommunityWritePromise = Promise.resolve()
let downloadCounts = {}
@@ -2581,6 +2584,58 @@ function queueUserNotesWrite(userId) {
studyNotesWriteQueues.set(userId, next)
}
function getUserProgressFilePath(userId) {
return path.join(STUDY_PROGRESS_DIR, `${userId}.json`)
}
function sanitizeStudyProgress(value) {
const defaultResult = { byStudy: {}, updatedAt: new Date().toISOString() }
if (!value || typeof value !== 'object') return defaultResult
const progress = { byStudy: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() }
if (value.byStudy && typeof value.byStudy === 'object') {
for (const [studySlug, studyData] of Object.entries(value.byStudy)) {
if (typeof studySlug !== 'string' || !studySlug.trim()) continue
const completedSectionIds = Array.isArray(studyData?.completedSectionIds)
? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
: []
progress.byStudy[studySlug.trim().toLowerCase()] = {
completedSectionIds: Array.from(new Set(completedSectionIds)),
}
}
}
return progress
}
async function loadUserProgress(userId) {
if (studyProgressCache.has(userId)) return studyProgressCache.get(userId)
try {
const raw = await readFile(getUserProgressFilePath(userId), 'utf8')
const progress = sanitizeStudyProgress(JSON.parse(raw))
studyProgressCache.set(userId, progress)
return progress
} catch {
const progress = { byStudy: {}, updatedAt: new Date().toISOString() }
studyProgressCache.set(userId, progress)
return progress
}
}
function queueUserProgressWrite(userId) {
const prev = studyProgressWriteQueues.get(userId) ?? Promise.resolve()
const next = prev
.then(async () => {
const progress = studyProgressCache.get(userId) ?? { byStudy: {}, updatedAt: new Date().toISOString() }
await mkdir(STUDY_PROGRESS_DIR, { recursive: true })
await writeFile(getUserProgressFilePath(userId), JSON.stringify(progress, null, 2), 'utf8')
})
.catch(err => {
console.error(`[study-progress] failed to write progress for user ${userId}:`, err)
})
studyProgressWriteQueues.set(userId, next)
}
async function migrateStudyNotesIfNeeded() {
try {
const raw = await readFile(STUDY_NOTES_FILE, 'utf8')
@@ -2941,6 +2996,76 @@ app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
res.json({ ok: true, note })
})
app.get('/api/study-progress/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) {
res.status(400).json({ message: 'Study slug is required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
return
}
const progress = await loadUserProgress(user.id)
const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
res.json({ studySlug, completedSectionIds })
})
app.post('/api/study-progress/: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 update progress.' })
return
}
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
if (!studyProgress.completedSectionIds.includes(sectionId)) {
studyProgress.completedSectionIds = [...studyProgress.completedSectionIds, sectionId]
}
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
})
app.delete('/api/study-progress/: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 update progress.' })
return
}
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
studyProgress.completedSectionIds = studyProgress.completedSectionIds.filter(id => id !== sectionId)
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
})
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '')
@@ -3154,6 +3279,7 @@ app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStud
app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
const progress = await loadUserProgress(user.id)
const noteEntries = Object.entries(notes)
const studies = getStudyCatalog().map(study => {
@@ -3161,13 +3287,14 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
? (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
const completedLessons = progress.byStudy[study.slug]?.completedSectionIds?.length ?? 0
return {
slug: study.slug,
title: study.title,
status: study.status,
enrolled: isStudyUserEnrolled(user, study.slug),
totalLessons,
completedLessons: noteCount,
completedLessons,
noteCount,
}
})