2732730f5e
Sends 7/14/30-day inactivity emails to study users who haven't logged in, with one-click HMAC-signed unsubscribe and automatic re-arm on next login. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
733 lines
27 KiB
JavaScript
733 lines
27 KiB
JavaScript
import { createHash, createHmac, randomUUID } from 'node:crypto'
|
|
import path from 'node:path'
|
|
import { parseCookies, cookieSecureFlag } 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,
|
|
} from './config.js'
|
|
import { state } from './state.js'
|
|
import { queueStudyRemindersWrite } 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')
|
|
}
|
|
|
|
// ── 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))
|
|
}
|
|
|
|
// ── 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 cookieFlags() {
|
|
return cookieSecureFlag()
|
|
}
|
|
|
|
export function createStudySession(userId) {
|
|
const token = randomUUID()
|
|
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
|
return token
|
|
}
|
|
|
|
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${cookieFlags()}`,
|
|
)
|
|
}
|
|
|
|
export function clearStudySessionCookie(res) {
|
|
res.append(
|
|
'Set-Cookie',
|
|
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
|
)
|
|
}
|
|
|
|
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() + (10 * 60 * 1000),
|
|
})
|
|
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 normalizeIp(rawIp) {
|
|
if (!rawIp) return 'unknown'
|
|
let ip = String(rawIp).trim()
|
|
if (ip.includes(',')) ip = ip.split(',')[0].trim()
|
|
if (ip.startsWith('::ffff:')) ip = ip.slice(7)
|
|
if (ip === '::1') ip = '127.0.0.1'
|
|
return ip || 'unknown'
|
|
}
|
|
|
|
export function isPrivateOrLocalIp(ip) {
|
|
return (
|
|
ip === '127.0.0.1'
|
|
|| ip === 'localhost'
|
|
|| ip.startsWith('10.')
|
|
|| ip.startsWith('192.168.')
|
|
|| /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
|
|
|| ip.startsWith('fc')
|
|
|| ip.startsWith('fd')
|
|
|| ip.startsWith('fe80:')
|
|
|| ip === 'unknown'
|
|
)
|
|
}
|
|
|
|
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 { VISITOR_RETENTION_DAYS_DEFAULT, EMPTY_HIT_STATS: _unused } = { VISITOR_RETENTION_DAYS_DEFAULT: 180 }
|
|
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,
|
|
}
|
|
}
|
|
|
|
// ── Redirect / URL helpers ─────────────────────────────────────────────────
|
|
|
|
export function normalizeRedirectPath(value) {
|
|
if (typeof value !== 'string') return ''
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return ''
|
|
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
|
const normalized = withSlash.replace(/\/+/g, '/')
|
|
if (normalized === '/') return ''
|
|
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
|
|
return normalized
|
|
}
|
|
|
|
export function sanitizeUrl(value) {
|
|
if (typeof value !== 'string') return ''
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return ''
|
|
if (trimmed.startsWith('/')) return trimmed
|
|
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
|
return ''
|
|
}
|
|
|
|
export function sanitizeRedirectRules(value) {
|
|
const source = Array.isArray(value) ? value : []
|
|
const seen = new Set()
|
|
const out = []
|
|
|
|
for (const item of source) {
|
|
const pathValue = normalizeRedirectPath(item?.path)
|
|
const target = sanitizeUrl(item?.target)
|
|
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
|
|
if (!pathValue || !target) continue
|
|
if (seen.has(pathValue)) continue
|
|
seen.add(pathValue)
|
|
out.push({
|
|
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
|
path: pathValue,
|
|
target,
|
|
statusCode,
|
|
})
|
|
}
|
|
|
|
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
|
|
}
|
|
|
|
// ── 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 ''
|
|
}
|
|
|
|
export function normalizeMessageType(value) {
|
|
if (value === 'question' || value === 'testimony' || value === 'topic') return value
|
|
return 'general'
|
|
}
|
|
|
|
// ── 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
|
|
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 ─────────────────────────────────────────
|
|
|
|
const REENGAGEMENT_TIERS = [
|
|
{ key: '7d', days: 7 },
|
|
{ key: '14d', days: 14 },
|
|
{ key: '30d', days: 30 },
|
|
]
|
|
|
|
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()
|
|
|
|
for (const user of state.studyUsers) {
|
|
if (user.studyRemindersEnabled !== true) continue
|
|
if (!user.lastLoginAt) 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 REENGAGEMENT_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
|
|
}
|
|
}
|
|
}
|