Files
Siteforge/server/study-helpers.js
T
nmemmertandClaude Sonnet 5 8496e227b3
Publish Container / build (push) Successful in 14m56s
Stop showing email-derived names in student discussions; v2.2.1
Community posts/replies fell back to the local part of the student's email
(e.g. "student.test" from student.test@versebyverse.local) whenever they
hadn't set a display name. The per-lesson discussion comments were worse —
they fell back to the FULL email address, and that endpoint has no auth
requirement, so it was publicly readable.

Adds getCommunityDisplayName() in study-helpers.js: uses the student's
display name when set, otherwise assigns a stable "Student XXXX" handle
derived from their account id — never their email. Both the community board
and lesson comments now resolve names live from the author's current
account at read time (falling back to the stored snapshot only if the
account was deleted), so this also fixes any already-posted content
without a data migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:15:54 -04:00

780 lines
30 KiB
JavaScript

import { createHash, createHmac, randomUUID } from 'node:crypto'
import path from 'node:path'
import {
parseCookies,
cookieSecureFlag,
normalizeIp,
isPrivateOrLocalIp,
normalizeRedirectPath,
sanitizeUrl,
sanitizeRedirectRules,
} from './helpers.js'
export { normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl, sanitizeRedirectRules } from './helpers.js'
import {
STUDY_SESSION_COOKIE,
STUDY_SESSION_TTL_MS,
STUDY_TOTP_PENDING_TTL_MS,
EMAIL_OTP_TTL_MS,
EMAIL_OTP_MAX_ATTEMPTS,
CONTACT_EMAIL_COOLDOWN_MS,
DEFAULT_REDIRECT_RULES,
VISITOR_RETENTION_DAYS_DEFAULT,
DOWNLOAD_TOKEN_TTL_MS,
} from './config.js'
import { state } from './state.js'
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite } from './data.js'
// ── Username / slug normalizers ────────────────────────────────────────────
export function normalizeStudyUsername(value) {
if (typeof value !== 'string') return ''
return value.trim().toLowerCase()
}
export function normalizeStudySlug(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim().toLowerCase()
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
}
export function isValidStudyUsername(value) {
return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254
}
export function normalizeLessonSectionId(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim().toLowerCase()
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
}
export function getStudySlugFromNoteId(sectionId) {
if (typeof sectionId !== 'string') return ''
const separatorIndex = sectionId.indexOf('--')
if (separatorIndex <= 0) return ''
return normalizeStudySlug(sectionId.slice(0, separatorIndex))
}
// ── Password / token hashing ───────────────────────────────────────────────
export function hashStudyPassword(password) {
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
}
export function hashEmailChangeToken(token) {
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
}
export function hashPasswordResetToken(token) {
return createHash('sha256').update(`study-password-reset:${String(token)}`).digest('hex')
}
export function hashEmailVerifyToken(token) {
return createHash('sha256').update(`study-email-verify:${String(token)}`).digest('hex')
}
// ── Catalog / enrollment helpers ───────────────────────────────────────────
export function getStudyCatalog() {
const fallback = [
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
]
const content = state.cachedSiteContent
if (!content || typeof content !== 'object') return fallback
if (Array.isArray(content.studies) && content.studies.length > 0) {
const out = []
const seen = new Set()
for (const study of content.studies) {
const slug = normalizeStudySlug(study?.slug)
if (!slug || seen.has(slug)) continue
seen.add(slug)
out.push({
slug,
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
status: study?.status === 'planned' ? 'planned' : 'active',
})
}
if (out.length > 0) return out
}
return fallback
}
export function isEnrollableStudySlug(studySlug) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized) return false
return getStudyCatalog().some(study => study.slug === normalized)
}
export function getStudyTitleBySlug(studySlug) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized) return ''
const study = getStudyCatalog().find(item => item.slug === normalized)
return study?.title ?? ''
}
export function isStudyUserEnrolled(user, studySlug) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized || !user) return false
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
}
export function findStudyUserById(userId) {
if (typeof userId !== 'string' || !userId.trim()) return undefined
return state.studyUsers.find(user => user.id === userId)
}
export function findStudyUserByUsername(username) {
return state.studyUsers.find(user => user.username === normalizeStudyUsername(username))
}
// ── Community display name ──────────────────────────────────────────────────
// Never derived from the student's email — falls back to a stable, anonymous
// handle assigned from their account id so nobody's address leaks into posts.
export function getCommunityDisplayName(user) {
const displayName = typeof user?.displayName === 'string' ? user.displayName.trim() : ''
if (displayName) return displayName
const id = typeof user?.id === 'string' ? user.id : ''
return `Student ${(id.replace(/-/g, '').slice(0, 4) || '0000').toUpperCase()}`
}
// ── Avatar ─────────────────────────────────────────────────────────────────
export function getStudyAvatarUrl(subject) {
let customAvatar = ''
let username = ''
if (subject && typeof subject === 'object') {
customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : ''
username = normalizeStudyUsername(subject.username)
} else if (typeof subject === 'string') {
username = normalizeStudyUsername(subject)
}
if (customAvatar) return customAvatar
if (!username) return ''
const hash = createHash('md5').update(username).digest('hex')
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96`
}
// ── Session management ─────────────────────────────────────────────────────
export function createStudySession(userId) {
const token = randomUUID()
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
queueStudySessionsWrite()
return token
}
// Revokes every active session for a user (password change/reset, account deletion, "sign out everywhere").
export function invalidateAllStudySessions(userId, exceptToken = null) {
let changed = false
for (const [token, session] of state.studySessions) {
if (session.userId === userId && token !== exceptToken) {
state.studySessions.delete(token)
changed = true
}
}
if (changed) queueStudySessionsWrite()
}
export function setStudySessionCookie(res, token) {
res.append(
'Set-Cookie',
`${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
export function clearStudySessionCookie(res) {
res.append(
'Set-Cookie',
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
export function getStudyUserFromRequest(req) {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[STUDY_SESSION_COOKIE]
if (!token) return null
const session = state.studySessions.get(token)
if (!session || session.expiresAt <= Date.now()) {
state.studySessions.delete(token)
return null
}
const user = state.studyUsers.find(item => item.id === session.userId)
if (!user) {
state.studySessions.delete(token)
return null
}
session.expiresAt = Date.now() + STUDY_SESSION_TTL_MS
state.studySessions.set(token, session)
return user
}
export function requireStudyAuth(req, res, next) {
const user = getStudyUserFromRequest(req)
if (!user) {
res.status(401).json({ message: 'Please sign in to save notes.' })
return
}
req.studyUser = user
next()
}
// ── TOTP pending tokens ────────────────────────────────────────────────────
export function createStudyTotpPendingToken(userId) {
const token = randomUUID()
state.studyTotpPendingTokens.set(token, { userId, expiresAt: Date.now() + STUDY_TOTP_PENDING_TTL_MS })
return token
}
export function consumeStudyTotpPendingToken(token) {
const entry = state.studyTotpPendingTokens.get(token)
if (!entry) return null
state.studyTotpPendingTokens.delete(token)
if (Date.now() > entry.expiresAt) return null
return entry.userId
}
// ── Email OTP ──────────────────────────────────────────────────────────────
export function generateEmailOtp() {
return String(Math.floor(100000 + Math.random() * 900000))
}
function hashEmailOtp(code) {
return createHash('sha256').update(String(code).trim()).digest('hex')
}
export function storeEmailOtp(userId, code) {
state.emailOtpStore.set(userId, { codeHash: hashEmailOtp(code), expiresAt: Date.now() + EMAIL_OTP_TTL_MS, attempts: 0 })
}
export function verifyEmailOtp(userId, code) {
const entry = state.emailOtpStore.get(userId)
if (!entry) return 'no-code'
if (Date.now() > entry.expiresAt) { state.emailOtpStore.delete(userId); return 'expired' }
entry.attempts += 1
if (entry.attempts > EMAIL_OTP_MAX_ATTEMPTS) { state.emailOtpStore.delete(userId); return 'too-many' }
if (hashEmailOtp(String(code).trim()) !== entry.codeHash) return 'wrong'
state.emailOtpStore.delete(userId)
return 'ok'
}
// ── Titus download tokens ──────────────────────────────────────────────────
export function createTitusDownloadToken(email) {
const token = randomUUID()
state.titusDownloadTokens.set(token, {
email,
expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS,
})
return token
}
export function consumeTitusDownloadToken(token) {
const entry = state.titusDownloadTokens.get(token)
if (!entry) return false
state.titusDownloadTokens.delete(token)
if (entry.expiresAt <= Date.now()) return false
return true
}
// ── Release date helpers ───────────────────────────────────────────────────
export function getSectionReleaseTime(section) {
if (!section || typeof section !== 'object') return Number.NaN
const candidateValues = [section.releasedAt, section.releaseDate, section.availableAt, section.publishAt]
for (const candidate of candidateValues) {
if (typeof candidate !== 'string' || !candidate.trim()) continue
const releaseTime = Date.parse(candidate)
if (Number.isFinite(releaseTime)) return releaseTime
}
return Number.NaN
}
export function getSectionReleaseDate(section) {
const releaseTime = getSectionReleaseTime(section)
if (!Number.isFinite(releaseTime)) return null
return new Date(releaseTime)
}
export function isSectionReleased(section) {
const releaseTime = getSectionReleaseTime(section)
if (!Number.isFinite(releaseTime)) return false
return releaseTime <= Date.now()
}
export function redactUnreleasedSection(section) {
if (!section || typeof section !== 'object') return section
if (isSectionReleased(section)) return section
return {
...section,
passageText: '',
commentary: '',
greekNotes: [],
studyQuestions: [],
audioEmbedUrl: '',
}
}
export function filterSiteContentByReleaseDate(siteContent) {
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return siteContent
const filteredStudies = Array.isArray(siteContent.studies)
? siteContent.studies.map(study => {
if (!study || typeof study !== 'object') return study
const sections = Array.isArray(study.sections) ? study.sections.map(redactUnreleasedSection) : []
return { ...study, sections }
})
: siteContent.studies
const filteredLegacySections = Array.isArray(siteContent.colossiansStudySections)
? siteContent.colossiansStudySections.map(redactUnreleasedSection)
: siteContent.colossiansStudySections
return {
...siteContent,
studies: filteredStudies,
colossiansStudySections: filteredLegacySections,
}
}
// ── Analytics helpers ──────────────────────────────────────────────────────
export function normalizeHitPath(pathname) {
if (!pathname || pathname === '') return '/'
if (pathname.length > 1 && pathname.endsWith('/')) {
return pathname.slice(0, -1)
}
return pathname
}
export function shouldCountHit(req) {
if (req.method !== 'GET') return false
if (req.path.startsWith('/api/')) return false
if (req.path === '/admin' || req.path.startsWith('/admin/')) return false
if (req.path === '/favicon.ico') return false
const hasFileExt = path.extname(req.path) !== ''
if (hasFileExt) return false
const accept = req.get('accept') ?? ''
return accept.includes('text/html') || accept === '*/*' || accept === ''
}
export function sanitizeUserAgent(userAgent) {
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
return userAgent.trim().slice(0, 300) || 'unknown'
}
export function detectDevice(userAgent) {
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
const ua = userAgent.toLowerCase()
if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet'
if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile'
return 'desktop'
}
export function sanitizeReferrer(referrer) {
if (!referrer || typeof referrer !== 'string') return ''
try {
const parsed = new URL(referrer.trim())
return `${parsed.hostname}${parsed.pathname}`.slice(0, 200)
} catch {
return ''
}
}
export function detectBot(userAgent) {
if (!userAgent || typeof userAgent !== 'string') {
return { isBot: true, reason: 'missing-user-agent' }
}
const ua = userAgent.toLowerCase()
if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) {
return { isBot: true, reason: 'search-crawler' }
}
if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) {
return { isBot: true, reason: 'social-crawler' }
}
if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) {
return { isBot: true, reason: 'headless-browser' }
}
if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) {
return { isBot: true, reason: 'monitoring-tool' }
}
if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) {
return { isBot: true, reason: 'security-scanner' }
}
if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) {
return { isBot: true, reason: 'http-client' }
}
if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) {
if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) {
return { isBot: true, reason: 'bot-keyword' }
}
}
return { isBot: false, reason: null }
}
export function buildTopLocations(list, key) {
const counts = {}
for (const row of list) {
const val = row?.[key] || 'Unknown'
counts[val] = (counts[val] ?? 0) + 1
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, hits]) => ({ name, hits }))
}
export function buildLastNDaysStats(days) {
const out = []
const today = new Date()
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(today)
d.setDate(today.getDate() - i)
const dayKey = d.toISOString().slice(0, 10)
out.push({ day: dayKey, hits: state.hitStats.byDay[dayKey] ?? 0 })
}
return out
}
export function recordHit(pathname, isBot = false, botReason = null) {
const nowIso = new Date().toISOString()
const dayKey = nowIso.slice(0, 10)
const safePath = normalizeHitPath(pathname)
state.hitStats.totalHits += 1
state.hitStats.lastHitAt = nowIso
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
if (isBot) {
state.hitStats.botHits += 1
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
if (botReason) {
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
}
} else {
state.hitStats.realHits += 1
state.hitStats.byPathReal[safePath] = (state.hitStats.byPathReal[safePath] ?? 0) + 1
state.hitStats.byDayReal[dayKey] = (state.hitStats.byDayReal[dayKey] ?? 0) + 1
}
state.hitStats.byPath[safePath] = (state.hitStats.byPath[safePath] ?? 0) + 1
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
}
export function pruneStatsByDays(daysRaw) {
const days = Number(daysRaw)
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
const keepRecent = state.visitorStats.recentVisits.filter(v => {
const ts = new Date(v.at).getTime()
return Number.isFinite(ts) && ts >= cutoff
})
const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId))
const nextVisitors = {}
for (const [id, data] of Object.entries(state.visitorStats.visitors)) {
const lastSeen = new Date(data.lastSeenAt ?? 0).getTime()
if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) {
nextVisitors[id] = data
}
}
const nextByDay = {}
const nextByDayReal = {}
const nextByDayBot = {}
for (const [day, count] of Object.entries(state.hitStats.byDay)) {
const ts = new Date(`${day}T00:00:00.000Z`).getTime()
if (Number.isFinite(ts) && ts >= cutoff) {
nextByDay[day] = count
nextByDayReal[day] = state.hitStats.byDayReal?.[day] ?? 0
nextByDayBot[day] = state.hitStats.byDayBot?.[day] ?? 0
}
}
state.visitorStats.recentVisits = keepRecent
state.visitorStats.visitors = nextVisitors
state.visitorStats.uniqueVisitors = Object.keys(nextVisitors).length
state.visitorStats.totalVisits = keepRecent.length
state.visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length
state.visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null
state.visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null
state.hitStats.byDay = nextByDay
state.hitStats.byDayReal = nextByDayReal
state.hitStats.byDayBot = nextByDayBot
return {
retentionDays,
remainingVisits: state.visitorStats.totalVisits,
remainingVisitors: state.visitorStats.uniqueVisitors,
}
}
// ── Contact / email tracking ───────────────────────────────────────────────
export function noteContactEmailCooldown(emailAddress) {
const normalized = String(emailAddress || '').trim().toLowerCase()
if (!normalized) return { ok: true, retryAfterMs: 0 }
const now = Date.now()
const lastAt = state.contactSubmitCooldownByEmail.get(normalized)
if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) {
return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) }
}
state.contactSubmitCooldownByEmail.set(normalized, now)
if (state.contactSubmitCooldownByEmail.size > 8000) {
const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3
for (const [email, timestamp] of state.contactSubmitCooldownByEmail.entries()) {
if (timestamp < cutoff) state.contactSubmitCooldownByEmail.delete(email)
}
}
return { ok: true, retryAfterMs: 0 }
}
export function extractTagValue(tags, name) {
if (!Array.isArray(tags)) return ''
const target = String(name || '').trim().toLowerCase()
if (!target) return ''
for (const tag of tags) {
if (!tag || typeof tag !== 'object') continue
const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : ''
const value = typeof tag.value === 'string' ? tag.value.trim() : ''
if (key === target && value) return value
}
return ''
}
export function mapResendEventToStatus(eventType) {
const normalized = String(eventType || '').trim().toLowerCase()
if (!normalized) return 'updated'
if (normalized.includes('delivered')) return 'delivered'
if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed'
if (normalized.includes('bounce')) return 'bounced'
if (normalized.includes('complain')) return 'complained'
if (normalized.includes('click')) return 'clicked'
if (normalized.includes('open')) return 'opened'
if (normalized.includes('send')) return 'sent'
return 'updated'
}
export function extractResendMessageId(result) {
if (!result || typeof result !== 'object') return ''
if (typeof result.id === 'string' && result.id.trim()) return result.id.trim()
if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) {
return result.data.id.trim()
}
return ''
}
// ── Study reminder scheduler ───────────────────────────────────────────────
export async function scheduleStudyReminders(sendStudyReminderEmail) {
if (!state.cachedSiteContent) return
const now = new Date()
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 : []
if (enrolledStudySlugs.length === 0) continue
const userSent = state.studyReminders.users[user.id] ?? {}
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 sectionId = section.id
const releaseDate = getSectionReleaseDate(section)
if (!releaseDate) continue
// Lessons release at 1:00 AM; reminder emails go out 8 hours later, at 9:00 AM the same day.
const sendDate = new Date(releaseDate.getTime() + 8 * 60 * 60 * 1000)
if (sendDate > now) continue
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
if (sentForStudy.includes(sectionId)) continue
const hoursSinceSend = (now.getTime() - sendDate.getTime()) / (1000 * 60 * 60)
if (hoursSinceSend > 24) continue
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const sectionUrl = `${base}/study/${study.slug}/${section.id}`
try {
await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl)
} catch (err) {
console.error(`[study-reminders] failed to send reminder to ${email} for ${studySlug}/${sectionId}:`, err)
continue
}
userSent[studySlug] = [...sentForStudy, sectionId]
state.studyReminders.users[user.id] = userSent
// Persist after each successful send so a later crash doesn't re-send
queueStudyRemindersWrite()
}
}
}
state.studyReminders.updatedAt = new Date().toISOString()
queueStudyRemindersWrite()
}
// ── Study re-engagement scheduler ─────────────────────────────────────────
function getReengagementTiers() {
const cfg = state.cachedSiteContent ?? {}
const raw = typeof cfg.studyReengagementDays === 'string' ? cfg.studyReengagementDays : '7,14,30'
const days = raw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0)
if (days.length === 0) return [{ key: 't1', days: 7 }, { key: 't2', days: 14 }, { key: 't3', days: 30 }]
return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d }))
}
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/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
return `${base}/api/study-auth/unsubscribe-reminders?uid=${encodeURIComponent(userId)}&token=${token}`
}
export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
if (!state.cachedSiteContent) return
const now = new Date()
const nowMs = now.getTime()
const tiers = getReengagementTiers()
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)
const sent = typeof user.reengagementSentAt === 'object' && user.reengagementSentAt !== null ? user.reengagementSentAt : {}
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
const enrolledStudies = enrolledStudySlugs
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
.filter(Boolean)
if (enrolledStudies.length === 0) continue
const firstStudy = enrolledStudies[0]
const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const studyUrl = enrolledStudies.length === 1
? `${base}/study/${firstStudy.slug}`
: `${base}/study`
for (const tier of tiers) {
if (daysSinceLogin < tier.days) continue
if (sent[tier.key]) continue
const unsubUrl = makeUnsubUrl(user.id)
try {
await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier.key, unsubUrl)
} catch (err) {
console.error(`[study-reengagement] failed to send ${tier.key} to ${user.username}:`, err)
continue
}
user.reengagementSentAt = { ...sent, [tier.key]: new Date().toISOString() }
queueStudyUsersWrite()
break // one tier per run so we don't spam if they've been gone >30 days
}
}
}
// ── 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()
}
}