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:
1 parent
5bc8245ed2
commit
038ef2b039
12 files changed
+540
-38
No files matched your search
+80
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user