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
@@ -1489,6 +1489,9 @@ export function sanitizeStudyUsers(value) {
|
||||
const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs)
|
||||
? 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 subscribeNewsletter = item?.subscribeNewsletter !== false
|
||||
const studyRemindersEnabled = item?.studyRemindersEnabled === true
|
||||
@@ -1523,6 +1526,11 @@ export function sanitizeStudyUsers(value) {
|
||||
subscribeNewsletter,
|
||||
studyRemindersEnabled,
|
||||
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() : '',
|
||||
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null,
|
||||
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
findStudyUserByUsername,
|
||||
getStudyAvatarUrl,
|
||||
isStudyUserEnrolled,
|
||||
isStudyUserCompleted,
|
||||
normalizeStudySlug,
|
||||
getStudyCatalog,
|
||||
clearStudySessionCookie,
|
||||
@@ -212,6 +213,7 @@ export function register(app) {
|
||||
title: study.title,
|
||||
status: study.status,
|
||||
enrolled: isStudyUserEnrolled(user, study.slug),
|
||||
completed: isStudyUserCompleted(user, study.slug),
|
||||
totalLessons,
|
||||
completedLessons,
|
||||
noteCount,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
storeEmailOtp,
|
||||
verifyEmailOtp,
|
||||
getStudyUserFromRequest,
|
||||
getAccessibleStudySlugs,
|
||||
} from '../study-helpers.js'
|
||||
import {
|
||||
sendEmailOtp,
|
||||
@@ -69,7 +70,9 @@ export function register(app) {
|
||||
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||||
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,
|
||||
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
|
||||
twoFaMethod: user?.twoFaMethod ?? null,
|
||||
@@ -211,7 +214,8 @@ export function register(app) {
|
||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||
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 rateLimit from 'express-rate-limit'
|
||||
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 { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
|
||||
import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
|
||||
@@ -15,20 +15,6 @@ const publicCertRateLimiter = rateLimit({
|
||||
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) {
|
||||
// GET — check eligibility and return existing certificate token if one exists
|
||||
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
|
||||
@@ -36,7 +22,7 @@ export function register(app) {
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -49,6 +35,8 @@ export function register(app) {
|
||||
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
||||
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)
|
||||
|
||||
res.json({
|
||||
@@ -67,7 +55,7 @@ export function register(app) {
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
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 { queueStudyCommentsWrite } from '../data.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
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
|
||||
}
|
||||
|
||||
|
||||
+22
-17
@@ -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 = []
|
||||
|
||||
+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