Add study completion, checkpoints, lesson announcements, and lesson-thread defaults
This commit is contained in:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user