Add study user re-engagement reminder emails; v1.1.19
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>
This commit is contained in:
+65
-1
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { parseCookies, cookieSecureFlag } from './helpers.js'
|
||||
import {
|
||||
@@ -666,3 +666,67 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user