Publish Container / build (push) Successful in 15m31s
- Completion is stored on the user (completedStudySlugs, sticky) when every lesson is done; backfilled at startup - Lessons, notes, progress, quiz, community and certificate stay open for completed studies after leaving the enrollment - Dashboard/My Account list completed studies with Review Lessons and certificate access - Study page shows a completed banner - Lesson-release reminders, weekly digest and re-engagement emails skip finished studies - Fix: streak fields (current/longest/last studied) and firstCompletionAt were dropped on every restart when users were loaded - Tests for completion/access/re-engagement Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
494 lines
20 KiB
JavaScript
494 lines
20 KiB
JavaScript
import {
|
|
requireStudyAuth,
|
|
normalizeStudySlug,
|
|
normalizeLessonSectionId,
|
|
getStudySlugFromNoteId,
|
|
hasStudyAccess,
|
|
isEnrollableStudySlug,
|
|
getStudyTitleBySlug,
|
|
findStudyUserById,
|
|
getStudyAvatarUrl,
|
|
getStudyCatalog,
|
|
getCommunityDisplayName,
|
|
getAccessibleStudySlugs,
|
|
markStudyCompletedIfDone,
|
|
} 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)) {
|
|
const catalogEntry = getStudyCatalog().find(study => study.slug === studySlug)
|
|
if (catalogEntry?.status === 'planned') {
|
|
res.status(400).json({ message: 'This study is not open for enrollment yet.' })
|
|
return
|
|
}
|
|
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: getAccessibleStudySlugs(user),
|
|
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
|
|
})
|
|
})
|
|
|
|
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: getAccessibleStudySlugs(user),
|
|
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
|
|
})
|
|
})
|
|
|
|
// ── 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 && !hasStudyAccess(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 && !hasStudyAccess(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 (!hasStudyAccess(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 (!hasStudyAccess(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()
|
|
const justCompleted = markStudyCompletedIfDone(user, studySlug, studyProgress.completedSectionIds)
|
|
|
|
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds, studyCompleted: justCompleted || undefined })
|
|
})
|
|
|
|
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 (!hasStudyAccess(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 (!hasStudyAccess(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 (!hasStudyAccess(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 (!hasStudyAccess(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 (!hasStudyAccess(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 (!hasStudyAccess(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 (!hasStudyAccess(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 (!hasStudyAccess(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 (!hasStudyAccess(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) })
|
|
})
|
|
}
|