Completed studies: finishing a study keeps access after leaving; certificates always shown; no reminders for finished studies; v2.11.0
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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-09 11:42:58 -04:00
1 parent 7301982bb1
commit bb83f3e8c5
13 files changed
+221 -52

No files matched your search

+22 -17
View File
@@ -3,13 +3,15 @@ import {
normalizeStudySlug,
normalizeLessonSectionId,
getStudySlugFromNoteId,
isStudyUserEnrolled,
hasStudyAccess,
isEnrollableStudySlug,
getStudyTitleBySlug,
findStudyUserById,
getStudyAvatarUrl,
getStudyCatalog,
getCommunityDisplayName,
getAccessibleStudySlugs,
markStudyCompletedIfDone,
} from '../study-helpers.js'
import { state } from '../state.js'
import {
@@ -73,7 +75,8 @@ export function register(app) {
ok: true,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs,
enrolledStudySlugs: getAccessibleStudySlugs(user),
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
})
})
@@ -96,7 +99,8 @@ export function register(app) {
ok: true,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs,
enrolledStudySlugs: getAccessibleStudySlugs(user),
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
})
})
@@ -116,7 +120,7 @@ export function register(app) {
}
const user = req.studyUser
const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
return
}
@@ -136,7 +140,7 @@ export function register(app) {
return
}
const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
return
}
@@ -170,7 +174,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
return
}
@@ -197,7 +201,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
@@ -227,8 +231,9 @@ export function register(app) {
user.firstCompletionAt = new Date().toISOString()
}
queueStudyUsersWrite()
const justCompleted = markStudyCompletedIfDone(user, studySlug, studyProgress.completedSectionIds)
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds, studyCompleted: justCompleted || undefined })
})
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
@@ -240,7 +245,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
@@ -267,7 +272,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
return
}
@@ -286,7 +291,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
return
}
@@ -315,7 +320,7 @@ export function register(app) {
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 }
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: [] }
@@ -335,7 +340,7 @@ export function register(app) {
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 }
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: [] }
@@ -358,7 +363,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
return
}
@@ -403,7 +408,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
return
}
@@ -445,7 +450,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, post.studySlug)) {
if (!hasStudyAccess(user, post.studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to reply in the community.' })
return
}
@@ -472,7 +477,7 @@ export function register(app) {
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)) {
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 = []