Files
Siteforge/server/routes/study-data.js
T
nmemmertandClaude Sonnet 5 8496e227b3
Publish Container / build (push) Successful in 14m56s
Stop showing email-derived names in student discussions; v2.2.1
Community posts/replies fell back to the local part of the student's email
(e.g. "student.test" from student.test@versebyverse.local) whenever they
hadn't set a display name. The per-lesson discussion comments were worse —
they fell back to the FULL email address, and that endpoint has no auth
requirement, so it was publicly readable.

Adds getCommunityDisplayName() in study-helpers.js: uses the student's
display name when set, otherwise assigns a stable "Student XXXX" handle
derived from their account id — never their email. Both the community board
and lesson comments now resolve names live from the author's current
account at read time (falling back to the stored snapshot only if the
account was deleted), so this also fixes any already-posted content
without a data migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:15:54 -04:00

484 lines
19 KiB
JavaScript

import {
requireStudyAuth,
normalizeStudySlug,
normalizeLessonSectionId,
getStudySlugFromNoteId,
isStudyUserEnrolled,
isEnrollableStudySlug,
getStudyTitleBySlug,
findStudyUserById,
getStudyAvatarUrl,
getStudyCatalog,
getCommunityDisplayName,
} 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'
const noteSaveHits = new Map() // userId -> { count, windowStart }
const NOTE_RATE_WINDOW_MS = 60 * 1000
const NOTE_RATE_MAX = 30
function checkNoteSaveRateLimit(userId) {
const now = Date.now()
const entry = noteSaveHits.get(userId) ?? { count: 0, windowStart: now }
if (now - entry.windowStart > NOTE_RATE_WINDOW_MS) { entry.count = 0; entry.windowStart = now }
entry.count += 1
noteSaveHits.set(userId, entry)
return entry.count <= NOTE_RATE_MAX
}
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
if (!checkNoteSaveRateLimit(user.id)) {
res.status(429).json({ message: 'Too many note saves. Please slow down.' })
return
}
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 ?? []
const bookmarkedSectionIds = progress.byStudy[studySlug]?.bookmarkedSectionIds ?? []
// Record first visit timestamp on the user record (additive, never overwrite)
if (!user.firstVisitAt) {
user.firstVisitAt = new Date().toISOString()
queueStudyUsersWrite()
}
res.json({ studySlug, completedSectionIds, bookmarkedSectionIds })
})
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)
// Streak tracking
const todayStr = new Date().toISOString().slice(0, 10)
const lastDate = user.lastStudiedDate ?? null
if (lastDate !== todayStr) {
const yesterday = new Date(Date.now() - 864e5).toISOString().slice(0, 10)
user.currentStreak = lastDate === yesterday ? (user.currentStreak ?? 0) + 1 : 1
user.longestStreak = Math.max(user.currentStreak, user.longestStreak ?? 0)
user.lastStudiedDate = todayStr
}
// 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 })
})
// ── Bookmarks ─────────────────────────────────────────────────────────────
app.post('/api/study-progress/:studySlug/:sectionId/bookmark', 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 bookmark lessons.' }); return }
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
const bookmarkedSectionIds = Array.isArray(studyProgress.bookmarkedSectionIds) ? studyProgress.bookmarkedSectionIds : []
if (!bookmarkedSectionIds.includes(sectionId)) {
studyProgress.bookmarkedSectionIds = [...bookmarkedSectionIds, sectionId]
}
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
state.studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds })
})
app.delete('/api/study-progress/:studySlug/:sectionId/bookmark', 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: 'Not enrolled.' }); return }
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
studyProgress.bookmarkedSectionIds = (studyProgress.bookmarkedSectionIds ?? []).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, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds })
})
// ── 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)
const likedBy = Array.isArray(post.likedBy) ? post.likedBy : []
return {
...post,
authorName: author ? getCommunityDisplayName(author) : post.authorName,
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
likedByMe: likedBy.includes(user.id),
likeCount: likedBy.length,
replies: Array.isArray(post.replies)
? post.replies.map(reply => {
const replyAuthor = findStudyUserById(reply.authorUserId)
return {
...reply,
authorName: replyAuthor ? getCommunityDisplayName(replyAuthor) : reply.authorName,
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 = getCommunityDisplayName(user)
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: getCommunityDisplayName(user),
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 })
})
app.post('/api/study-community/posts/:postId/like', requireStudyAuth, (req, res) => {
const user = req.studyUser
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
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 like posts.' }); return
}
if (!Array.isArray(post.likedBy)) post.likedBy = []
const idx = post.likedBy.indexOf(user.id)
if (idx === -1) {
post.likedBy.push(user.id)
} else {
post.likedBy.splice(idx, 1)
}
queueStudyCommunityWrite()
res.json({ ok: true, likeCount: post.likedBy.length, likedByMe: post.likedBy.includes(user.id) })
})
}