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 ─────────────────────────────────────────── // A study is 'complete' when an admin marks it so, or when it has lessons and every // one of them has already been released. Planned studies never auto-complete. export function getEffectiveStudyStatus(study) { if (!study || typeof study !== 'object') return 'active' if (study.status === 'planned') return 'planned' if (study.status === 'complete') return 'complete' const sections = Array.isArray(study.sections) ? study.sections : [] if (sections.length > 0 && sections.every(isSectionReleased)) return 'complete' return 'active' } 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: getEffectiveStudyStatus(study), }) } 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 // episodeNumber is an admin-only reference and is never sent to the public site const { episodeNumber: _episodeNumber, ...publicSection } = section if (isSectionReleased(section)) return publicSection return { ...publicSection, 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)) { // Generic keywords like "reader" or "agent" can appear as substrings of // otherwise-normal browser UAs (e.g. some webviews, regional browsers). // Any UA carrying a real rendering-engine or vendor token is treated as // a genuine browser rather than a bot, even if it also matches a keyword // above — this list should stay a superset of the previous one so it // only ever reduces false positives, never re-admits an actual bot. if (!/chrome|firefox|safari|opera|edge|msie|trident|gecko|webkit|khtml|presto|crios|fxios|ucbrowser|samsungbrowser/.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 // Finished studies are self-paced: only mention lessons from the last week so a // late enrollee isn't sent the whole back catalogue in one digest. const isComplete = getEffectiveStudyStatus(study) === 'complete' for (const section of study.sections ?? []) { const releaseTime = getSectionReleaseTime(section) if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue if (isComplete && nowMs - releaseTime > ONE_WEEK_MS) 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() } }