Add study account engagement features; v1.2.4

- Pause reminders: remindersPausedUntil field + scheduler skip + account UI with 7/14/30-day presets
- Email digest mode: reminderFrequency field (immediate/weekly) + weekly digest email + scheduler + account UI toggle
- Admin user list improvements: streak, reengagement, frequency, pause, per-study lesson progress
- Admin manual re-engagement: POST endpoint + tier selector button in admin panel
- Study bookmarks: bookmarkedSectionIds in progress file + POST/DELETE endpoints + toggle button in lesson UI
- Study completion certificate email: auto-sent on first certificate issuance
- Content fields for certificate and weekly digest email templates in admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-03 15:16:47 -04:00
1 parent 5bc8245ed2
commit 038ef2b039
12 files changed
+540 -38

No files matched your search

+80 -1
View File
@@ -622,6 +622,9 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue
if (user.reminderFrequency === 'weekly') continue
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
const email = user.username
const displayName = user.displayName || email
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
@@ -677,7 +680,7 @@ function getReengagementTiers() {
return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d }))
}
function makeUnsubUrl(userId) {
export function makeUnsubUrl(userId) {
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
const token = createHmac('sha256', secret).update(userId).digest('hex')
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
@@ -694,6 +697,8 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
for (const user of state.studyUsers) {
if (user.studyRemindersEnabled !== true) continue
if (!user.lastLoginAt) continue
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
const lastLogin = new Date(user.lastLoginAt)
if (isNaN(lastLogin.getTime())) continue
const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24)
@@ -733,3 +738,77 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
}
}
}
// ── Weekly digest scheduler ───────────────────────────────────────────────
export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
if (!state.cachedSiteContent) return
const now = new Date()
const nowMs = now.getTime()
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000
for (const user of state.studyUsers) {
if (user.reminderFrequency !== 'weekly') continue
if (user.studyRemindersEnabled !== true) continue
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
const lastDigestMs = user.weeklyDigestSentAt ? new Date(user.weeklyDigestSentAt).getTime() : 0
if (nowMs - lastDigestMs < ONE_WEEK_MS) continue
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (enrolledStudySlugs.length === 0) continue
const userSent = state.studyReminders.users[user.id] ?? {}
const lessons = []
const newlySentByStudy = {}
for (const studySlug of enrolledStudySlugs) {
const study = Array.isArray(state.cachedSiteContent.studies)
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined
if (!study) continue
for (const section of study.sections ?? []) {
const releaseTime = getSectionReleaseTime(section)
if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
if (sentForStudy.includes(section.id)) continue
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
lessons.push({
sectionId: section.id,
studySlug,
sectionTitle: section.title ?? section.id,
sectionReference: section.reference ?? '',
studyTitle: study.title ?? studySlug,
sectionUrl: `${base}/study/${study.slug}/${section.id}`,
})
if (!newlySentByStudy[studySlug]) newlySentByStudy[studySlug] = []
newlySentByStudy[studySlug].push(section.id)
}
}
if (lessons.length === 0) continue
const unsubUrl = makeUnsubUrl(user.id)
try {
await sendStudyWeeklyDigestEmail(user.username, user.displayName || user.username, lessons, unsubUrl)
} catch (err) {
console.error(`[study-digest] failed to send digest to ${user.username}:`, err)
continue
}
for (const [studySlug, sectionIds] of Object.entries(newlySentByStudy)) {
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
userSent[studySlug] = [...new Set([...sentForStudy, ...sectionIds])]
}
state.studyReminders.users[user.id] = userSent
queueStudyRemindersWrite()
user.weeklyDigestSentAt = now.toISOString()
queueStudyUsersWrite()
}
}