diff --git a/server.js b/server.js index 09518b2..9c53d6b 100644 --- a/server.js +++ b/server.js @@ -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 } +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, } }) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index d037931..1698486 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -79,6 +79,10 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy