Publish Container / build (push) Successful in 14m56s
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>
780 lines
30 KiB
JavaScript
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()
|
|
}
|
|
}
|