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
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:
1 parent
7301982bb1
commit
bb83f3e8c5
13 files changed
+221
-52
No files matched your search
+67
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user