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
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.10.10",
|
"version": "2.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.10.10",
|
"version": "2.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1135.0",
|
"@aws-sdk/client-s3": "^3.1135.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.10.10",
|
"version": "2.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
scheduleStudyReminders,
|
scheduleStudyReminders,
|
||||||
scheduleReengagementEmails,
|
scheduleReengagementEmails,
|
||||||
scheduleWeeklyDigestEmails,
|
scheduleWeeklyDigestEmails,
|
||||||
|
backfillCompletedStudies,
|
||||||
} from './server/study-helpers.js'
|
} from './server/study-helpers.js'
|
||||||
import { recordVisitor } from './server/routes/analytics.js'
|
import { recordVisitor } from './server/routes/analytics.js'
|
||||||
|
|
||||||
@@ -195,6 +196,11 @@ Promise.all([
|
|||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('[stats] failed to load persisted stats:', err)
|
console.error('[stats] failed to load persisted stats:', err)
|
||||||
})
|
})
|
||||||
|
.then(() => backfillCompletedStudies())
|
||||||
|
.then(updated => { if (updated > 0) console.log(`[study] marked ${updated} finished studies as completed`) })
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[study] completed-study backfill failed:', err)
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
createBackupSnapshot('startup').catch(() => {})
|
createBackupSnapshot('startup').catch(() => {})
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -1489,6 +1489,9 @@ export function sanitizeStudyUsers(value) {
|
|||||||
const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs)
|
const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs)
|
||||||
? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
||||||
: []
|
: []
|
||||||
|
const completedStudySlugs = Array.isArray(item?.completedStudySlugs)
|
||||||
|
? Array.from(new Set(item.completedStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER * 4)
|
||||||
|
: []
|
||||||
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
|
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
|
||||||
const subscribeNewsletter = item?.subscribeNewsletter !== false
|
const subscribeNewsletter = item?.subscribeNewsletter !== false
|
||||||
const studyRemindersEnabled = item?.studyRemindersEnabled === true
|
const studyRemindersEnabled = item?.studyRemindersEnabled === true
|
||||||
@@ -1523,6 +1526,11 @@ export function sanitizeStudyUsers(value) {
|
|||||||
subscribeNewsletter,
|
subscribeNewsletter,
|
||||||
studyRemindersEnabled,
|
studyRemindersEnabled,
|
||||||
enrolledStudySlugs,
|
enrolledStudySlugs,
|
||||||
|
completedStudySlugs,
|
||||||
|
currentStreak: Number.isFinite(item?.currentStreak) && item.currentStreak > 0 ? Math.floor(item.currentStreak) : 0,
|
||||||
|
longestStreak: Number.isFinite(item?.longestStreak) && item.longestStreak > 0 ? Math.floor(item.longestStreak) : 0,
|
||||||
|
lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null,
|
||||||
|
firstCompletionAt: typeof item?.firstCompletionAt === 'string' ? item.firstCompletionAt : null,
|
||||||
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
|
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
|
||||||
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null,
|
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null,
|
||||||
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null,
|
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
findStudyUserByUsername,
|
findStudyUserByUsername,
|
||||||
getStudyAvatarUrl,
|
getStudyAvatarUrl,
|
||||||
isStudyUserEnrolled,
|
isStudyUserEnrolled,
|
||||||
|
isStudyUserCompleted,
|
||||||
normalizeStudySlug,
|
normalizeStudySlug,
|
||||||
getStudyCatalog,
|
getStudyCatalog,
|
||||||
clearStudySessionCookie,
|
clearStudySessionCookie,
|
||||||
@@ -212,6 +213,7 @@ export function register(app) {
|
|||||||
title: study.title,
|
title: study.title,
|
||||||
status: study.status,
|
status: study.status,
|
||||||
enrolled: isStudyUserEnrolled(user, study.slug),
|
enrolled: isStudyUserEnrolled(user, study.slug),
|
||||||
|
completed: isStudyUserCompleted(user, study.slug),
|
||||||
totalLessons,
|
totalLessons,
|
||||||
completedLessons,
|
completedLessons,
|
||||||
noteCount,
|
noteCount,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
storeEmailOtp,
|
storeEmailOtp,
|
||||||
verifyEmailOtp,
|
verifyEmailOtp,
|
||||||
getStudyUserFromRequest,
|
getStudyUserFromRequest,
|
||||||
|
getAccessibleStudySlugs,
|
||||||
} from '../study-helpers.js'
|
} from '../study-helpers.js'
|
||||||
import {
|
import {
|
||||||
sendEmailOtp,
|
sendEmailOtp,
|
||||||
@@ -69,7 +70,9 @@ export function register(app) {
|
|||||||
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
||||||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||||||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
||||||
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
// Studies the student can open: enrolled ones plus any they have finished
|
||||||
|
enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [],
|
||||||
|
completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [],
|
||||||
emailVerified: user?.emailVerified === true,
|
emailVerified: user?.emailVerified === true,
|
||||||
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
|
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
|
||||||
twoFaMethod: user?.twoFaMethod ?? null,
|
twoFaMethod: user?.twoFaMethod ?? null,
|
||||||
@@ -211,7 +214,8 @@ export function register(app) {
|
|||||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||||
avatarUrl: getStudyAvatarUrl(user.username),
|
avatarUrl: getStudyAvatarUrl(user.username),
|
||||||
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
|
enrolledStudySlugs: getAccessibleStudySlugs(user),
|
||||||
|
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import rateLimit from 'express-rate-limit'
|
import rateLimit from 'express-rate-limit'
|
||||||
import { requireAdminAuth } from '../auth.js'
|
import { requireAdminAuth } from '../auth.js'
|
||||||
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
|
import { requireStudyAuth, normalizeStudySlug, hasStudyAccess, getStudyTitleBySlug, getRequiredSectionIds, markStudyCompletedIfDone } from '../study-helpers.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
|
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
|
||||||
import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
|
import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
|
||||||
@@ -15,20 +15,6 @@ const publicCertRateLimiter = rateLimit({
|
|||||||
message: { message: 'Too many certificate lookups. Please wait a moment.' },
|
message: { message: 'Too many certificate lookups. Please wait a moment.' },
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the list of required section IDs for a study. Every lesson is required, so a
|
|
||||||
* certificate is only available once the whole study has been released and completed.
|
|
||||||
*/
|
|
||||||
function getRequiredSectionIds(studySlug) {
|
|
||||||
const content = state.cachedSiteContent
|
|
||||||
if (!content || !Array.isArray(content.studies)) return null
|
|
||||||
const study = content.studies.find(s => s?.slug === studySlug)
|
|
||||||
if (!study || !Array.isArray(study.sections)) return null
|
|
||||||
return study.sections
|
|
||||||
.filter(s => s?.id)
|
|
||||||
.map(s => s.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
// GET — check eligibility and return existing certificate token if one exists
|
// GET — check eligibility and return existing certificate token if one exists
|
||||||
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
|
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
|
||||||
@@ -36,7 +22,7 @@ export function register(app) {
|
|||||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||||
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
|
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Not enrolled in this study.' }); return
|
res.status(403).json({ message: 'Not enrolled in this study.' }); return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +35,8 @@ export function register(app) {
|
|||||||
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
||||||
const eligible = requiredIds.every(id => completedIds.includes(id))
|
const eligible = requiredIds.every(id => completedIds.includes(id))
|
||||||
|
|
||||||
|
if (eligible) markStudyCompletedIfDone(user, studySlug, completedIds)
|
||||||
|
|
||||||
const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
|
const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -67,7 +55,7 @@ export function register(app) {
|
|||||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||||
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
|
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Not enrolled in this study.' }); return
|
res.status(403).json({ message: 'Not enrolled in this study.' }); return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { requireAdminAuth } from '../auth.js'
|
import { requireAdminAuth } from '../auth.js'
|
||||||
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled, findStudyUserById, getCommunityDisplayName } from '../study-helpers.js'
|
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, hasStudyAccess, findStudyUserById, getCommunityDisplayName } from '../study-helpers.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import { queueStudyCommentsWrite } from '../data.js'
|
import { queueStudyCommentsWrite } from '../data.js'
|
||||||
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
|
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
|
||||||
@@ -40,7 +40,7 @@ export function register(app) {
|
|||||||
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
|
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
|
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-17
@@ -3,13 +3,15 @@ import {
|
|||||||
normalizeStudySlug,
|
normalizeStudySlug,
|
||||||
normalizeLessonSectionId,
|
normalizeLessonSectionId,
|
||||||
getStudySlugFromNoteId,
|
getStudySlugFromNoteId,
|
||||||
isStudyUserEnrolled,
|
hasStudyAccess,
|
||||||
isEnrollableStudySlug,
|
isEnrollableStudySlug,
|
||||||
getStudyTitleBySlug,
|
getStudyTitleBySlug,
|
||||||
findStudyUserById,
|
findStudyUserById,
|
||||||
getStudyAvatarUrl,
|
getStudyAvatarUrl,
|
||||||
getStudyCatalog,
|
getStudyCatalog,
|
||||||
getCommunityDisplayName,
|
getCommunityDisplayName,
|
||||||
|
getAccessibleStudySlugs,
|
||||||
|
markStudyCompletedIfDone,
|
||||||
} from '../study-helpers.js'
|
} from '../study-helpers.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import {
|
import {
|
||||||
@@ -73,7 +75,8 @@ export function register(app) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
studySlug,
|
studySlug,
|
||||||
studyTitle: getStudyTitleBySlug(studySlug) || 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,
|
ok: true,
|
||||||
studySlug,
|
studySlug,
|
||||||
studyTitle: getStudyTitleBySlug(studySlug) || 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 user = req.studyUser
|
||||||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
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.' })
|
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -136,7 +140,7 @@ export function register(app) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
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.' })
|
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -170,7 +174,7 @@ export function register(app) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
|
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -197,7 +201,7 @@ export function register(app) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -227,8 +231,9 @@ export function register(app) {
|
|||||||
user.firstCompletionAt = new Date().toISOString()
|
user.firstCompletionAt = new Date().toISOString()
|
||||||
}
|
}
|
||||||
queueStudyUsersWrite()
|
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) => {
|
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||||||
@@ -240,7 +245,7 @@ export function register(app) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -267,7 +272,7 @@ export function register(app) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
|
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -286,7 +291,7 @@ export function register(app) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
|
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -315,7 +320,7 @@ export function register(app) {
|
|||||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||||
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
|
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 progress = await loadUserProgress(user.id)
|
||||||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
|
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
|
||||||
@@ -335,7 +340,7 @@ export function register(app) {
|
|||||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||||
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
|
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 progress = await loadUserProgress(user.id)
|
||||||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
|
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
|
||||||
@@ -358,7 +363,7 @@ export function register(app) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
|
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -403,7 +408,7 @@ export function register(app) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
if (!hasStudyAccess(user, studySlug)) {
|
||||||
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
|
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -445,7 +450,7 @@ export function register(app) {
|
|||||||
return
|
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.' })
|
res.status(403).json({ message: 'Please enroll in this study to reply in the community.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -472,7 +477,7 @@ export function register(app) {
|
|||||||
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
|
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
|
||||||
const post = state.studyCommunityPosts.find(item => item.id === postId)
|
const post = state.studyCommunityPosts.find(item => item.id === postId)
|
||||||
if (!post) { res.status(404).json({ message: 'Post not found.' }); return }
|
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
|
res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return
|
||||||
}
|
}
|
||||||
if (!Array.isArray(post.likedBy)) post.likedBy = []
|
if (!Array.isArray(post.likedBy)) post.likedBy = []
|
||||||
|
|||||||
+67
-1
@@ -22,7 +22,7 @@ import {
|
|||||||
DOWNLOAD_TOKEN_TTL_MS,
|
DOWNLOAD_TOKEN_TTL_MS,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
import { state } from './state.js'
|
import { state } from './state.js'
|
||||||
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite } from './data.js'
|
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite, loadUserProgress } from './data.js'
|
||||||
|
|
||||||
// ── Username / slug normalizers ────────────────────────────────────────────
|
// ── Username / slug normalizers ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -163,6 +163,68 @@ export function isStudyUserEnrolled(user, studySlug) {
|
|||||||
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
|
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) {
|
export function findStudyUserById(userId) {
|
||||||
if (typeof userId !== 'string' || !userId.trim()) return undefined
|
if (typeof userId !== 'string' || !userId.trim()) return undefined
|
||||||
return state.studyUsers.find(user => user.id === userId)
|
return state.studyUsers.find(user => user.id === userId)
|
||||||
@@ -649,6 +711,7 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
|||||||
|
|
||||||
const userSent = state.studyReminders.users[user.id] ?? {}
|
const userSent = state.studyReminders.users[user.id] ?? {}
|
||||||
for (const studySlug of enrolledStudySlugs) {
|
for (const studySlug of enrolledStudySlugs) {
|
||||||
|
if (isStudyUserCompleted(user, studySlug)) continue // finished studies don't need lesson reminders
|
||||||
const study = Array.isArray(state.cachedSiteContent.studies)
|
const study = Array.isArray(state.cachedSiteContent.studies)
|
||||||
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -724,7 +787,9 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
|
|||||||
|
|
||||||
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||||
const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
|
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
|
const enrolledStudies = enrolledStudySlugs
|
||||||
|
.filter(slug => !isStudyUserCompleted(user, slug))
|
||||||
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
|
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
@@ -782,6 +847,7 @@ export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
|
|||||||
const newlySentByStudy = {}
|
const newlySentByStudy = {}
|
||||||
|
|
||||||
for (const studySlug of enrolledStudySlugs) {
|
for (const studySlug of enrolledStudySlugs) {
|
||||||
|
if (isStudyUserCompleted(user, studySlug)) continue
|
||||||
const study = Array.isArray(state.cachedSiteContent.studies)
|
const study = Array.isArray(state.cachedSiteContent.studies)
|
||||||
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
+13
@@ -4541,6 +4541,19 @@
|
|||||||
.account-cert-study .study-cert-heading { display: none; }
|
.account-cert-study .study-cert-heading { display: none; }
|
||||||
.account-cert-study .study-cert-canvas { width: 100%; height: auto; max-width: 100%; }
|
.account-cert-study .study-cert-canvas { width: 100%; height: auto; max-width: 100%; }
|
||||||
|
|
||||||
|
.account-chip--inline { margin-left: 0.5rem; vertical-align: middle; }
|
||||||
|
|
||||||
|
.study-completed-banner {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid rgba(111, 207, 151, 0.4);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(111, 207, 151, 0.08);
|
||||||
|
color: #b9e2c9;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Study hub (guest landing) ── */
|
/* ── Study hub (guest landing) ── */
|
||||||
.study-hub-count {
|
.study-hub-count {
|
||||||
margin: 1.25rem 0 0;
|
margin: 1.25rem 0 0;
|
||||||
|
|||||||
+26
-10
@@ -67,6 +67,7 @@ type StudyAccountOverview = {
|
|||||||
title: string
|
title: string
|
||||||
status: 'active' | 'planned' | 'complete'
|
status: 'active' | 'planned' | 'complete'
|
||||||
enrolled: boolean
|
enrolled: boolean
|
||||||
|
completed?: boolean
|
||||||
totalLessons: number
|
totalLessons: number
|
||||||
completedLessons: number
|
completedLessons: number
|
||||||
noteCount: number
|
noteCount: number
|
||||||
@@ -703,10 +704,14 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const enrolledStudies = overview?.studies.filter(study => study.enrolled) ?? []
|
const enrolledStudies = overview?.studies.filter(study => study.enrolled) ?? []
|
||||||
|
// Finished studies stay in the classroom (with their certificate) even if the student left the enrollment
|
||||||
|
const classroomStudies = [...(overview?.studies.filter(study => study.enrolled || study.completed) ?? [])]
|
||||||
|
.sort((a, b) => Number(Boolean(a.completed)) - Number(Boolean(b.completed)))
|
||||||
|
const completedStudyCount = classroomStudies.filter(study => study.completed).length
|
||||||
const availableStudies = [...(overview?.studies ?? [])].sort((a, b) => Number(b.status === 'active') - Number(a.status === 'active'))
|
const availableStudies = [...(overview?.studies ?? [])].sort((a, b) => Number(b.status === 'active') - Number(a.status === 'active'))
|
||||||
const totalEnrolled = enrolledStudies.length
|
const totalEnrolled = enrolledStudies.length
|
||||||
const totalCompletedLessons = enrolledStudies.reduce((sum, study) => sum + study.completedLessons, 0)
|
const totalCompletedLessons = classroomStudies.reduce((sum, study) => sum + study.completedLessons, 0)
|
||||||
const totalLessons = enrolledStudies.reduce((sum, study) => sum + study.totalLessons, 0)
|
const totalLessons = classroomStudies.reduce((sum, study) => sum + study.totalLessons, 0)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -744,6 +749,7 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
|
|
||||||
<div className="study-course-meta" style={{ marginTop: '1.5rem', gap: '0.75rem' }}>
|
<div className="study-course-meta" style={{ marginTop: '1.5rem', gap: '0.75rem' }}>
|
||||||
<span>{totalEnrolled} enrolled track{totalEnrolled === 1 ? '' : 's'}</span>
|
<span>{totalEnrolled} enrolled track{totalEnrolled === 1 ? '' : 's'}</span>
|
||||||
|
{completedStudyCount > 0 && <span>{completedStudyCount} completed</span>}
|
||||||
<span>{totalCompletedLessons}/{totalLessons} lessons completed</span>
|
<span>{totalCompletedLessons}/{totalLessons} lessons completed</span>
|
||||||
<span>{overview.stats.noteCount} notes saved</span>
|
<span>{overview.stats.noteCount} notes saved</span>
|
||||||
{auth.studyRemindersEnabled ? <span>Lesson reminders enabled</span> : <span>Reminders off</span>}
|
{auth.studyRemindersEnabled ? <span>Lesson reminders enabled</span> : <span>Reminders off</span>}
|
||||||
@@ -778,6 +784,7 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
<StudyStatusBadge status={studyInfo.status} />
|
<StudyStatusBadge status={studyInfo.status} />
|
||||||
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
||||||
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
|
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
|
||||||
|
{studyInfo.completed && <p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: '#6fcf97' }}>✓ Completed — leaving only stops reminders. You keep your lessons, notes and certificate.</p>}
|
||||||
</div>
|
</div>
|
||||||
{confirmLeaveSlug === studyInfo.slug ? (
|
{confirmLeaveSlug === studyInfo.slug ? (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
@@ -819,6 +826,7 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
<StudyStatusBadge status={studyInfo.status} />
|
<StudyStatusBadge status={studyInfo.status} />
|
||||||
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
||||||
{!studyInfo.enrolled && studyInfo.status === 'planned' ? <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem', color: '#b09e79' }}>Not open for enrollment yet</p> : <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>}
|
{!studyInfo.enrolled && studyInfo.status === 'planned' ? <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem', color: '#b09e79' }}>Not open for enrollment yet</p> : <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>}
|
||||||
|
{studyInfo.completed && <p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: '#6fcf97' }}>✓ Completed — you keep your lessons, notes and certificate.</p>}
|
||||||
</div>
|
</div>
|
||||||
{studyInfo.enrolled && confirmLeaveSlug === studyInfo.slug ? (
|
{studyInfo.enrolled && confirmLeaveSlug === studyInfo.slug ? (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
@@ -886,10 +894,10 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
<div className="section-inner">
|
<div className="section-inner">
|
||||||
<div className="study-module-header">
|
<div className="study-module-header">
|
||||||
<p>Your Classroom</p>
|
<p>Your Classroom</p>
|
||||||
<h2>Enrolled Tracks</h2>
|
<h2>Your Tracks</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalEnrolled === 0 ? (
|
{classroomStudies.length === 0 ? (
|
||||||
<article className="study-class-block">
|
<article className="study-class-block">
|
||||||
<h2>No enrollments yet</h2>
|
<h2>No enrollments yet</h2>
|
||||||
<p className="study-detail-copy">You are signed in, but you have not enrolled in any study track yet. Browse the available tracks and join the one you want to start.</p>
|
<p className="study-detail-copy">You are signed in, but you have not enrolled in any study track yet. Browse the available tracks and join the one you want to start.</p>
|
||||||
@@ -897,14 +905,14 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
</article>
|
</article>
|
||||||
) : (
|
) : (
|
||||||
<div className="study-module-list">
|
<div className="study-module-list">
|
||||||
{enrolledStudies.map(study => {
|
{classroomStudies.map(study => {
|
||||||
const studyMeta = getStudyBySlug(studies, study.slug)
|
const studyMeta = getStudyBySlug(studies, study.slug)
|
||||||
const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0
|
const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0
|
||||||
return (
|
return (
|
||||||
<div key={study.slug}>
|
<div key={study.slug}>
|
||||||
<article className="study-module-row">
|
<article className="study-module-row">
|
||||||
<div className="study-module-row-left">
|
<div className="study-module-row-left">
|
||||||
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
|
<p className="study-module-lesson">{study.completed ? '✓ Completed' : study.enrolled ? 'Enrolled' : 'Track'}</p>
|
||||||
<h3>{study.title}</h3>
|
<h3>{study.title}</h3>
|
||||||
<p>{studyMeta?.description ?? 'Continue your study with the lessons and notes available in this track.'}</p>
|
<p>{studyMeta?.description ?? 'Continue your study with the lessons and notes available in this track.'}</p>
|
||||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
|
||||||
@@ -921,9 +929,10 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
<p>{progress}% complete</p>
|
<p>{progress}% complete</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}>
|
||||||
<Link to={`/study/${study.slug}`} className="btn-primary">Continue</Link>
|
<Link to={`/study/${study.slug}`} className="btn-primary">{study.completed ? 'Review Lessons' : 'Continue'}</Link>
|
||||||
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>
|
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>
|
||||||
<Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>
|
<Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>
|
||||||
|
{study.completed && <Link to="/study/account" className="btn-secondary">🏅 Certificate</Link>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -1621,6 +1630,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
// Resume at the first released lesson the student hasn't finished yet
|
// Resume at the first released lesson the student hasn't finished yet
|
||||||
const resumeSection = releasedSections.find(item => !studyProgress.completedSectionIds.includes(item.id)) ?? firstReleasedSection
|
const resumeSection = releasedSections.find(item => !studyProgress.completedSectionIds.includes(item.id)) ?? firstReleasedSection
|
||||||
const hasStarted = enrolled && studyProgress.completedSectionIds.length > 0
|
const hasStarted = enrolled && studyProgress.completedSectionIds.length > 0
|
||||||
|
const allLessonsDone = enrolled && !progressLoading && sections.length > 0 && sections.every(item => studyProgress.completedSectionIds.includes(item.id))
|
||||||
const resumeChapter = resumeSection?.chapter ?? chapterSummaries.find(chapter => chapter.lessonCount > 0)?.chapter
|
const resumeChapter = resumeSection?.chapter ?? chapterSummaries.find(chapter => chapter.lessonCount > 0)?.chapter
|
||||||
const completedCount = sections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length
|
const completedCount = sections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length
|
||||||
const chaptersWithLessons = chapterSummaries.filter(chapter => chapter.lessonCount > 0)
|
const chaptersWithLessons = chapterSummaries.filter(chapter => chapter.lessonCount > 0)
|
||||||
@@ -1659,6 +1669,9 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
<span>Student notes enabled</span>
|
<span>Student notes enabled</span>
|
||||||
{isSelfPaced && <span>Finished study — go at your own pace</span>}
|
{isSelfPaced && <span>Finished study — go at your own pace</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{allLessonsDone && (
|
||||||
|
<p className="study-completed-banner" role="status">🎉 You've completed this study. Your lessons, notes and certificate are yours to keep.</p>
|
||||||
|
)}
|
||||||
{enrolled && !progressLoading && sections.length > 0 && (
|
{enrolled && !progressLoading && sections.length > 0 && (
|
||||||
<div className="study-progress" role="group" aria-label="Your progress">
|
<div className="study-progress" role="group" aria-label="Your progress">
|
||||||
<div className="study-progress-label">
|
<div className="study-progress-label">
|
||||||
@@ -1671,13 +1684,14 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="study-course-hero-actions">
|
<div className="study-course-hero-actions">
|
||||||
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{hasStarted ? 'Resume Where You Left Off' : 'Start Class'}</Link>}
|
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{allLessonsDone ? 'Review Lessons' : hasStarted ? 'Resume Where You Left Off' : 'Start Class'}</Link>}
|
||||||
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
|
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
|
||||||
{auth.authenticated && !enrolled && (
|
{auth.authenticated && !enrolled && (
|
||||||
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
||||||
{enrolling ? 'Enrolling...' : 'Enroll in This Study'}
|
{enrolling ? 'Enrolling...' : 'Enroll in This Study'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{allLessonsDone && <Link to="/study/account" className="btn-secondary">🏅 My Certificate</Link>}
|
||||||
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
|
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
|
||||||
{enrolled && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
{enrolled && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
||||||
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</Link>}
|
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</Link>}
|
||||||
@@ -3219,7 +3233,8 @@ export function StudyAccountPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enrolledProgress = overview?.studies.filter(item => item.enrolled && item.totalLessons > 0) ?? []
|
// Finished studies stay listed (with their certificate) even after the student leaves the enrollment
|
||||||
|
const enrolledProgress = overview?.studies.filter(item => (item.enrolled || item.completed) && item.totalLessons > 0) ?? []
|
||||||
const memberSince = overview?.stats.memberSince && !isNaN(new Date(overview.stats.memberSince).getTime())
|
const memberSince = overview?.stats.memberSince && !isNaN(new Date(overview.stats.memberSince).getTime())
|
||||||
? new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
? new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
: '—'
|
: '—'
|
||||||
@@ -3288,7 +3303,7 @@ export function StudyAccountPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="account-progress" key={item.slug}>
|
<div className="account-progress" key={item.slug}>
|
||||||
<div className="account-progress-row">
|
<div className="account-progress-row">
|
||||||
<Link to={`/study/${item.slug}`}>{item.title}</Link>
|
<Link to={`/study/${item.slug}`}>{item.title}{item.completed && <span className="account-chip account-chip--ok account-chip--inline">Completed</span>}</Link>
|
||||||
<span>{item.completedLessons}/{item.totalLessons} lessons · {pct}%</span>
|
<span>{item.completedLessons}/{item.totalLessons} lessons · {pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
|
<div className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
|
||||||
@@ -3464,6 +3479,7 @@ export function StudyAccountPage() {
|
|||||||
<StudyStatusBadge status={studyInfo.status} />
|
<StudyStatusBadge status={studyInfo.status} />
|
||||||
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
||||||
{!studyInfo.enrolled && studyInfo.status === 'planned' ? <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem', color: '#b09e79' }}>Not open for enrollment yet</p> : <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>}
|
{!studyInfo.enrolled && studyInfo.status === 'planned' ? <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem', color: '#b09e79' }}>Not open for enrollment yet</p> : <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>}
|
||||||
|
{studyInfo.completed && <p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: '#6fcf97' }}>✓ Completed — leaving only stops reminders. You keep your lessons, notes and certificate.</p>}
|
||||||
</div>
|
</div>
|
||||||
{!studyInfo.enrolled && studyInfo.status === 'planned' ? (
|
{!studyInfo.enrolled && studyInfo.status === 'planned' ? (
|
||||||
<span style={{ fontSize: '0.85rem', color: '#b09e79' }}>Coming soon</span>
|
<span style={{ fontSize: '0.85rem', color: '#b09e79' }}>Coming soon</span>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { state } from '../server/state.js'
|
||||||
|
import {
|
||||||
|
hasStudyAccess,
|
||||||
|
isStudyUserCompleted,
|
||||||
|
getAccessibleStudySlugs,
|
||||||
|
markStudyCompletedIfDone,
|
||||||
|
scheduleReengagementEmails,
|
||||||
|
} from '../server/study-helpers.js'
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
state.cachedSiteContent = {
|
||||||
|
studies: [{ slug: 'mini', sections: [{ id: 'a' }, { id: 'b' }] }, { slug: 'other', sections: [{ id: 'x' }] }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('completing every lesson marks the study completed and keeps access after leaving', () => {
|
||||||
|
setup()
|
||||||
|
const user = { id: 'u1', enrolledStudySlugs: ['mini'] }
|
||||||
|
assert.equal(markStudyCompletedIfDone(user, 'mini', ['a']), false)
|
||||||
|
assert.equal(markStudyCompletedIfDone(user, 'mini', ['a', 'b']), true)
|
||||||
|
assert.ok(isStudyUserCompleted(user, 'mini'))
|
||||||
|
|
||||||
|
user.enrolledStudySlugs = [] // student leaves
|
||||||
|
assert.ok(hasStudyAccess(user, 'mini'))
|
||||||
|
assert.ok(!hasStudyAccess(user, 'other'))
|
||||||
|
assert.deepEqual(getAccessibleStudySlugs(user), ['mini'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('completion is sticky when a lesson is added later, and not marked twice', () => {
|
||||||
|
setup()
|
||||||
|
const user = { id: 'u2', enrolledStudySlugs: ['mini'] }
|
||||||
|
markStudyCompletedIfDone(user, 'mini', ['a', 'b'])
|
||||||
|
state.cachedSiteContent.studies[0].sections.push({ id: 'c' })
|
||||||
|
assert.equal(markStudyCompletedIfDone(user, 'mini', ['a', 'b']), false)
|
||||||
|
assert.ok(isStudyUserCompleted(user, 'mini'))
|
||||||
|
assert.deepEqual(user.completedStudySlugs, ['mini'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unknown or empty studies are never marked completed', () => {
|
||||||
|
setup()
|
||||||
|
const user = { id: 'u3', enrolledStudySlugs: [] }
|
||||||
|
assert.equal(markStudyCompletedIfDone(user, 'nope', ['a']), false)
|
||||||
|
state.cachedSiteContent.studies.push({ slug: 'empty', sections: [] })
|
||||||
|
assert.equal(markStudyCompletedIfDone(user, 'empty', []), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('re-engagement emails skip students whose only study is finished', async () => {
|
||||||
|
setup()
|
||||||
|
const sent = []
|
||||||
|
state.studyUsers = [{
|
||||||
|
id: 'u4', username: 'a@b.co', studyRemindersEnabled: true, lastLoginAt: new Date(Date.now() - 40 * 864e5).toISOString(),
|
||||||
|
enrolledStudySlugs: ['mini'], completedStudySlugs: ['mini'], reengagementSentAt: {},
|
||||||
|
}, {
|
||||||
|
id: 'u5', username: 'c@d.co', studyRemindersEnabled: true, lastLoginAt: new Date(Date.now() - 40 * 864e5).toISOString(),
|
||||||
|
enrolledStudySlugs: ['mini'], reengagementSentAt: {},
|
||||||
|
}]
|
||||||
|
await scheduleReengagementEmails(async (email) => { sent.push(email) })
|
||||||
|
assert.deepEqual(sent, ['c@d.co'])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user