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

+67 -1
View File
@@ -22,7 +22,7 @@ import {
DOWNLOAD_TOKEN_TTL_MS,
} from './config.js'
import { state } from './state.js'
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite } from './data.js'
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite, loadUserProgress } from './data.js'
// ── Username / slug normalizers ────────────────────────────────────────────
@@ -163,6 +163,68 @@ export function isStudyUserEnrolled(user, studySlug) {
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
}
// A student who has finished every lesson in a study keeps access to it (lessons, notes,
// certificate) even if they later leave the enrollment. Completion is stored on the user
// so it survives content edits and un-enrolling.
export function isStudyUserCompleted(user, studySlug) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized || !user) return false
return Array.isArray(user.completedStudySlugs) && user.completedStudySlugs.includes(normalized)
}
export function hasStudyAccess(user, studySlug) {
return isStudyUserEnrolled(user, studySlug) || isStudyUserCompleted(user, studySlug)
}
// Enrolled studies plus finished ones, deduplicated. Sent to the client as the list of
// studies the student can open.
export function getAccessibleStudySlugs(user) {
const enrolled = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const completed = Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : []
return [...new Set([...enrolled, ...completed])]
}
// Every lesson (released or not) is required, matching the certificate rules.
export function getRequiredSectionIds(studySlug) {
const content = state.cachedSiteContent
if (!content || !Array.isArray(content.studies)) return null
const study = content.studies.find(item => item?.slug === studySlug)
if (!study || !Array.isArray(study.sections)) return null
return study.sections.filter(item => item?.id).map(item => item.id)
}
// Marks the study completed on the user when every required lesson is done. Sticky: never
// removed, so a later new lesson doesn't revoke access. Returns true if newly completed.
export function markStudyCompletedIfDone(user, studySlug, completedSectionIds) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized || isStudyUserCompleted(user, normalized)) return false
const required = getRequiredSectionIds(normalized)
if (!required || required.length === 0) return false
if (!required.every(id => completedSectionIds.includes(id))) return false
user.completedStudySlugs = [...(Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : []), normalized]
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
return true
}
// One-off pass at startup so students who finished a study before this existed get the flag.
export async function backfillCompletedStudies() {
if (!state.cachedSiteContent) return 0
let updated = 0
for (const user of state.studyUsers) {
const progress = await loadUserProgress(user.id)
// Enrolled studies plus any with saved progress, so students who already left are caught too
const slugs = new Set([
...(Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []),
...Object.keys(progress.byStudy ?? {}),
])
for (const slug of slugs) {
if (markStudyCompletedIfDone(user, slug, progress.byStudy[slug]?.completedSectionIds ?? [])) updated++
}
}
return updated
}
export function findStudyUserById(userId) {
if (typeof userId !== 'string' || !userId.trim()) return undefined
return state.studyUsers.find(user => user.id === userId)
@@ -649,6 +711,7 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
const userSent = state.studyReminders.users[user.id] ?? {}
for (const studySlug of enrolledStudySlugs) {
if (isStudyUserCompleted(user, studySlug)) continue // finished studies don't need lesson reminders
const study = Array.isArray(state.cachedSiteContent.studies)
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined
@@ -724,7 +787,9 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
// Studies the student already finished aren't "unfinished business" for a come-back nudge.
const enrolledStudies = enrolledStudySlugs
.filter(slug => !isStudyUserCompleted(user, slug))
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
.filter(Boolean)
@@ -782,6 +847,7 @@ export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
const newlySentByStudy = {}
for (const studySlug of enrolledStudySlugs) {
if (isStudyUserCompleted(user, studySlug)) continue
const study = Array.isArray(state.cachedSiteContent.studies)
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined