import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, getStudySlugFromNoteId, isStudyUserEnrolled, isEnrollableStudySlug, getStudyTitleBySlug, findStudyUserById, getStudyAvatarUrl, getStudyCatalog, } from '../study-helpers.js' import { state } from '../state.js' import { queueStudyUsersWrite, queueStudyCommunityWrite, loadUserNotes, queueUserNotesWrite, loadUserProgress, queueUserProgressWrite, sanitizeStudyCommunityPosts, } from '../data.js' import { MAX_STUDY_NOTE_LENGTH, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_ENROLLMENTS_PER_USER } from '../config.js' import { randomUUID } from 'node:crypto' export function register(app) { // ── Enrollment ──────────────────────────────────────────────────────────── app.get('/api/study-enrollment', requireStudyAuth, (req, res) => { const user = req.studyUser res.json({ enrolledStudySlugs: Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [], availableStudies: getStudyCatalog() .map(study => ({ slug: study.slug, title: study.title })), }) }) app.post('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.params.studySlug) if (!studySlug || !isEnrollableStudySlug(studySlug)) { res.status(404).json({ message: 'Study not found.' }) return } const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] if (!enrolled.includes(studySlug)) { user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER) user.updatedAt = new Date().toISOString() queueStudyUsersWrite() } res.json({ ok: true, studySlug, studyTitle: getStudyTitleBySlug(studySlug) || studySlug, enrolledStudySlugs: user.enrolledStudySlugs, }) }) app.delete('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.params.studySlug) if (!studySlug || !isEnrollableStudySlug(studySlug)) { res.status(404).json({ message: 'Study not found.' }) return } const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] if (enrolled.includes(studySlug)) { user.enrolledStudySlugs = enrolled.filter(slug => slug !== studySlug) user.updatedAt = new Date().toISOString() queueStudyUsersWrite() } res.json({ ok: true, studySlug, studyTitle: getStudyTitleBySlug(studySlug) || studySlug, enrolledStudySlugs: user.enrolledStudySlugs, }) }) // ── Notes ───────────────────────────────────────────────────────────────── app.get('/api/study-notes', requireStudyAuth, async (req, res) => { const user = req.studyUser const notes = await loadUserNotes(user.id) res.json({ notes }) }) app.get('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => { const sectionId = normalizeLessonSectionId(req.params.sectionId) if (!sectionId) { res.status(400).json({ message: 'Invalid section id.' }) return } const user = req.studyUser const noteStudySlug = getStudySlugFromNoteId(sectionId) if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) { res.status(403).json({ message: 'Please enroll in this study to access notes.' }) return } const notes = await loadUserNotes(user.id) res.json({ note: notes[sectionId] ?? '' }) }) app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => { const sectionId = normalizeLessonSectionId(req.params.sectionId) if (!sectionId) { res.status(400).json({ message: 'Invalid section id.' }) return } const user = req.studyUser const noteStudySlug = getStudySlugFromNoteId(sectionId) if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) { res.status(403).json({ message: 'Please enroll in this study to save notes.' }) return } const rawNote = typeof req.body?.note === 'string' ? req.body.note : '' const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH) const notes = await loadUserNotes(user.id) if (!note) { delete notes[sectionId] } else { const existingCount = Object.keys(notes).length if (!notes[sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) { res.status(400).json({ message: 'Notes limit reached for this account.' }) return } notes[sectionId] = note } state.studyNotesCache.set(user.id, notes) queueUserNotesWrite(user.id) res.json({ ok: true, note }) }) // ── Progress ────────────────────────────────────────────────────────────── 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 ?? [] // Record first visit timestamp on the user record (additive, never overwrite) if (!user.firstVisitAt) { user.firstVisitAt = new Date().toISOString() queueStudyUsersWrite() } 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() state.studyProgressCache.set(user.id, progress) queueUserProgressWrite(user.id) // Record first completion timestamp on the user record (additive, never overwrite) if (!user.firstCompletionAt) { user.firstCompletionAt = new Date().toISOString() queueStudyUsersWrite() } 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() state.studyProgressCache.set(user.id, progress) queueUserProgressWrite(user.id) res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) }) // ── Quiz ────────────────────────────────────────────────────────────────── 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() state.studyProgressCache.set(user.id, progress) queueUserProgressWrite(user.id) res.json({ ok: true, studySlug, sectionId, answers }) }) // ── Community ───────────────────────────────────────────────────────────── app.get('/api/study-community', requireStudyAuth, async (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.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 the community.' }) return } const posts = state.studyCommunityPosts .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) return { ...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 || '') } }) : [], } }) res.json({ studySlug, posts }) }) app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.body?.studySlug) const sectionId = typeof req.body?.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(req.body.sectionId) ? req.body.sectionId.trim() : '' const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : '' if (!studySlug || !message) { res.status(400).json({ message: 'Study slug and message are required.' }) return } if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to post in the community.' }) return } const now = new Date().toISOString() const authorName = user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username) const post = { id: randomUUID(), studySlug, sectionId, authorUserId: user.id, authorName, authorAvatarUrl: getStudyAvatarUrl(user.username), message, createdAt: now, replies: [], } state.studyCommunityPosts.unshift(post) state.studyCommunityPosts = sanitizeStudyCommunityPosts(state.studyCommunityPosts).slice(0, 500) queueStudyCommunityWrite() res.json({ ok: true, post }) }) app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (req, res) => { const user = req.studyUser const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : '' const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : '' if (!postId || !message) { res.status(400).json({ message: 'Post id and message are required.' }) return } const post = state.studyCommunityPosts.find(item => item.id === postId) if (!post) { res.status(404).json({ message: 'Post not found.' }) return } if (!isStudyUserEnrolled(user, post.studySlug)) { res.status(403).json({ message: 'Please enroll in this study to reply in the community.' }) return } const reply = { id: randomUUID(), authorUserId: user.id, authorName: user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username), authorAvatarUrl: getStudyAvatarUrl(user.username), message, createdAt: new Date().toISOString(), } post.replies = Array.isArray(post.replies) ? post.replies : [] post.replies.push(reply) post.replies = sanitizeStudyCommunityPosts([post])[0]?.replies ?? [] queueStudyCommunityWrite() res.json({ ok: true, reply }) }) }