Publish Container / build (push) Successful in 14m56s
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>
484 lines
19 KiB
JavaScript
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) })
|
|
})
|
|
}
|