diff --git a/package.json b/package.json index 67cea13..7867ecd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.2.2", + "version": "2.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 6536faa..6ac0d88 100644 --- a/server.js +++ b/server.js @@ -31,7 +31,7 @@ import { refreshContentCaches, queueHitStatsWrite, } from './server/data.js' -import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail } from './server/email.js' +import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail, sendStudyWeeklyDigestEmail } from './server/email.js' import { detectBot, sanitizeUserAgent, @@ -39,6 +39,7 @@ import { recordHit, scheduleStudyReminders, scheduleReengagementEmails, + scheduleWeeklyDigestEmails, } from './server/study-helpers.js' import { recordVisitor } from './server/routes/analytics.js' @@ -194,6 +195,14 @@ Promise.all([ }, 60 * 60 * 1000) void scheduleReengagementEmails(sendStudyReengagementEmail) + // Send weekly lesson digest emails every hour (only actually fires every 7 days per user) + setInterval(() => { + scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => { + console.error('[study-digest] failed to schedule weekly digest:', err) + }) + }, 60 * 60 * 1000) + void scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) + app.listen(PORT, () => { logResendEmailAlignmentWarnings() console.log(`Siteforge listening on http://localhost:${PORT}`) diff --git a/server/data.js b/server/data.js index 40290d1..e68eef8 100644 --- a/server/data.js +++ b/server/data.js @@ -1415,6 +1415,9 @@ export function sanitizeStudyUsers(value) { totpEnabledAt: typeof item?.totpEnabledAt === 'string' ? item.totpEnabledAt : null, totpRecoveryCodes: Array.isArray(item?.totpRecoveryCodes) ? item.totpRecoveryCodes.filter(h => typeof h === 'string') : [], totpSecretPending: typeof item?.totpSecretPending === 'string' ? item.totpSecretPending : undefined, + remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null, + reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate', + weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null, }) } return out.slice(0, MAX_STUDY_USERS) @@ -1459,8 +1462,12 @@ export function sanitizeStudyProgress(value) { ]) ) : {} + const bookmarkedSectionIds = Array.isArray(studyData?.bookmarkedSectionIds) + ? Array.from(new Set(studyData.bookmarkedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()))) + : [] progress.byStudy[studySlug.trim().toLowerCase()] = { completedSectionIds: Array.from(new Set(completedSectionIds)), + bookmarkedSectionIds, quizAnswers, } } @@ -1516,6 +1523,7 @@ export function sanitizeStudyCommunityPosts(value) { ? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50) : [] if (!studySlug || !message) return null + const likedBy = Array.isArray(item.likedBy) ? item.likedBy.filter(id => typeof id === 'string' && id.trim()).slice(0, 1000) : [] return { id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), studySlug, @@ -1525,6 +1533,7 @@ export function sanitizeStudyCommunityPosts(value) { message, createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(), replies, + likedBy, } }) .filter(Boolean) diff --git a/server/email.js b/server/email.js index 471756c..3b482bf 100644 --- a/server/email.js +++ b/server/email.js @@ -635,3 +635,80 @@ export async function sendStudyReengagementEmail(email, displayName, studyTitle, console.error('[study-reengagement] send exception:', err) } } + +export async function sendStudyCertificateEmail(email, displayName, studyTitle, certUrl) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = state.cachedSiteContent ?? {} + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const subject = cfg.studyCertificateEmailSubject?.trim() || 'Your certificate is ready' + const bodyText = cfg.studyCertificateEmailBody?.trim() || 'Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.' + const ctaLabel = cfg.studyCertificateEmailCtaLabel?.trim() || 'View Certificate' + const signoff = cfg.studyCertificateEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' + const bodyHtml = ( + `

Congratulations, ${escapeHtml(namePart)}!

` + + `

${escapeHtml(bodyText)}

` + + `

${escapeHtml(studyTitle)}

` + ) + const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

` + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `Congratulations, ${namePart}!\n\n${bodyText}\n\n${studyTitle}\n\nView your certificate: ${certUrl}\n\n${signoff}`, + html: buildBrandedEmailHtml({ + title: 'Study Complete', + eyebrow: 'Certificate of Completion', + bodyHtml, + ctaLabel, + ctaUrl: certUrl, + footerHtml, + }), + }) + if (error) console.error('[study-certificate] email send error:', error) + } catch (err) { + console.error('[study-certificate] email exception:', err) + } +} + +export async function sendStudyWeeklyDigestEmail(email, displayName, lessons, unsubUrl) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const cfg = state.cachedSiteContent ?? {} + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const subject = cfg.studyWeeklyDigestEmailSubject?.trim() || 'Your weekly study digest' + const introText = cfg.studyWeeklyDigestEmailBody?.trim() || 'Here are the lessons released this week in your study track. Open any to continue where you left off.' + const signoff = cfg.studyWeeklyDigestEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate' + const lessonListHtml = lessons.map(l => + `

${escapeHtml(l.sectionTitle)}` + + (l.sectionReference ? ` (${escapeHtml(l.sectionReference)})` : '') + + (l.studyTitle ? ` — ${escapeHtml(l.studyTitle)}` : '') + + `

` + ).join('') + const lessonListText = lessons.map(l => `• ${l.sectionTitle}${l.sectionReference ? ` (${l.sectionReference})` : ''}: ${l.sectionUrl}`).join('\n') + const bodyHtml = ( + `

Hi ${escapeHtml(namePart)},

` + + `

${escapeHtml(introText)}

` + + `
${lessonListHtml}
` + ) + const unsubLine = unsubUrl ? `

Not interested? Unsubscribe from these reminders.

` : '' + const footerHtml = `

${escapeHtml(signoff).replace(/\n/g, '
')}

${unsubLine}` + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM, + to: [email], + subject, + text: `Hi ${namePart},\n\n${introText}\n\n${lessonListText}\n\n${signoff}${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`, + html: buildBrandedEmailHtml({ + title: 'Weekly Study Digest', + eyebrow: 'Study Reminder', + bodyHtml, + footerHtml, + }), + }) + if (error) console.error('[study-digest] send error:', error) + } catch (err) { + console.error('[study-digest] send exception:', err) + } +} diff --git a/server/routes/admin-assets.js b/server/routes/admin-assets.js index 0a48bca..80574e4 100644 --- a/server/routes/admin-assets.js +++ b/server/routes/admin-assets.js @@ -9,6 +9,7 @@ import { readUploadsMetadata, writeUploadsMetadata, getUserNotesFilePath, + loadUserProgress, queueStudyUsersWrite, } from '../data.js' import { @@ -16,7 +17,9 @@ import { getStudyCatalog, normalizeStudySlug, isEnrollableStudySlug, + makeUnsubUrl, } from '../study-helpers.js' +import { sendStudyReengagementEmail } from '../email.js' export function register(app) { app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => { @@ -117,9 +120,19 @@ export function register(app) { noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length } catch { /* ignore */ } + let progressByStudy = {} + try { + const prog = await loadUserProgress(user.id) + for (const [slug, data] of Object.entries(prog.byStudy)) { + progressByStudy[slug] = { completedLessons: (data.completedSectionIds ?? []).length } + } + } catch { /* ignore */ } + const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => { const study = catalog.find(s => s.slug === slug) - return study ? { slug, title: study.title } : { slug, title: slug } + return study + ? { slug, title: study.title, completedLessons: progressByStudy[slug]?.completedLessons ?? 0 } + : { slug, title: slug, completedLessons: 0 } }) return { @@ -132,12 +145,50 @@ export function register(app) { noteCount, subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, + reminderFrequency: user.reminderFrequency ?? 'immediate', + remindersPausedUntil: user.remindersPausedUntil ?? null, + currentStreak: user.currentStreak ?? 0, + longestStreak: user.longestStreak ?? 0, + reengagementSentAt: user.reengagementSentAt ?? {}, } })) res.json({ users }) }) + app.post('/api/admin/study-users/:id/send-reengagement', requireAdminAuth, async (req, res) => { + const user = state.studyUsers.find(u => u.id === req.params.id) + if (!user) { res.status(404).json({ message: 'User not found.' }); return } + + const tier = req.body?.tier + if (tier !== 't1' && tier !== 't2' && tier !== 't3') { + res.status(400).json({ message: 'tier must be t1, t2, or t3.' }); return + } + + const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : [] + const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] + const enrolledStudies = enrolledStudySlugs + .map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug)) + .filter(Boolean) + + if (enrolledStudies.length === 0) { res.status(400).json({ message: 'User has no enrolled studies.' }); return } + + 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` + const unsubUrl = makeUnsubUrl(user.id) + + try { + await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier, unsubUrl) + res.json({ ok: true }) + } catch (err) { + console.error(`[admin] manual reengagement send failed for ${user.username}:`, err) + res.status(500).json({ message: 'Failed to send email.' }) + } + }) + app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => { const user = state.studyUsers.find(u => u.id === req.params.id) if (!user) { res.status(404).json({ message: 'User not found.' }); return } diff --git a/server/routes/study-account.js b/server/routes/study-account.js index d90d553..8dedda8 100644 --- a/server/routes/study-account.js +++ b/server/routes/study-account.js @@ -218,6 +218,8 @@ export function register(app) { displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, + reminderFrequency: user.reminderFrequency ?? 'immediate', + remindersPausedUntil: user.remindersPausedUntil ?? null, avatarUrl: getStudyAvatarUrl(user), }, stats: { @@ -303,6 +305,20 @@ export function register(app) { const studyRemindersEnabled = req.body?.studyRemindersEnabled === true user.subscribeNewsletter = subscribeNewsletter user.studyRemindersEnabled = studyRemindersEnabled + + const reminderFrequency = req.body?.reminderFrequency + if (reminderFrequency === 'weekly' || reminderFrequency === 'immediate') { + user.reminderFrequency = reminderFrequency + } + + const rawPaused = req.body?.remindersPausedUntil + if (rawPaused === null || rawPaused === '') { + user.remindersPausedUntil = null + } else if (typeof rawPaused === 'string') { + const parsed = new Date(rawPaused) + user.remindersPausedUntil = !isNaN(parsed.getTime()) && parsed.getTime() > Date.now() ? parsed.toISOString() : null + } + user.updatedAt = new Date().toISOString() queueStudyUsersWrite() @@ -310,7 +326,13 @@ export function register(app) { syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err)) } - res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true }) + res.json({ + ok: true, + subscribeNewsletter: user.subscribeNewsletter, + studyRemindersEnabled: user.studyRemindersEnabled === true, + reminderFrequency: user.reminderFrequency ?? 'immediate', + remindersPausedUntil: user.remindersPausedUntil ?? null, + }) }) app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => { diff --git a/server/routes/study-certificate.js b/server/routes/study-certificate.js index 224512b..ceea801 100644 --- a/server/routes/study-certificate.js +++ b/server/routes/study-certificate.js @@ -4,6 +4,8 @@ import { requireAdminAuth } from '../auth.js' import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js' import { state } from '../state.js' import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js' +import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js' +import { buildAbsoluteUrl } from '../helpers.js' const publicCertRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, @@ -95,6 +97,9 @@ export function register(app) { } state.studyCertificates.push(cert) queueStudyCertificatesWrite() + const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/certificate/${cert.token}`) + sendStudyCertificateEmail(user.username, user.displayName || user.username, cert.studyTitle, certUrl) + .catch(err => console.error('[study-certificate] email exception:', err)) } res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt }) diff --git a/server/routes/study-data.js b/server/routes/study-data.js index 289926c..47e290e 100644 --- a/server/routes/study-data.js +++ b/server/routes/study-data.js @@ -171,6 +171,7 @@ export function register(app) { const progress = await loadUserProgress(user.id) const completedSectionIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] + const bookmarkedSectionIds = progress.byStudy[studySlug]?.bookmarkedSectionIds ?? [] // Record first visit timestamp on the user record (additive, never overwrite) if (!user.firstVisitAt) { @@ -178,7 +179,7 @@ export function register(app) { queueStudyUsersWrite() } - res.json({ studySlug, completedSectionIds }) + res.json({ studySlug, completedSectionIds, bookmarkedSectionIds }) }) app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { @@ -301,6 +302,45 @@ export function register(app) { res.json({ ok: true, studySlug, sectionId, answers }) }) + // ── Bookmarks ───────────────────────────────────────────────────────────── + + app.post('/api/study-progress/:studySlug/:sectionId/bookmark', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return } + if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return } + + const progress = await loadUserProgress(user.id) + const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] } + const bookmarkedSectionIds = Array.isArray(studyProgress.bookmarkedSectionIds) ? studyProgress.bookmarkedSectionIds : [] + if (!bookmarkedSectionIds.includes(sectionId)) { + studyProgress.bookmarkedSectionIds = [...bookmarkedSectionIds, sectionId] + } + progress.byStudy[studySlug] = studyProgress + progress.updatedAt = new Date().toISOString() + state.studyProgressCache.set(user.id, progress) + queueUserProgressWrite(user.id) + res.json({ ok: true, studySlug, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds }) + }) + + app.delete('/api/study-progress/:studySlug/:sectionId/bookmark', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return } + if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return } + + const progress = await loadUserProgress(user.id) + const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] } + studyProgress.bookmarkedSectionIds = (studyProgress.bookmarkedSectionIds ?? []).filter(id => id !== sectionId) + progress.byStudy[studySlug] = studyProgress + progress.updatedAt = new Date().toISOString() + state.studyProgressCache.set(user.id, progress) + queueUserProgressWrite(user.id) + res.json({ ok: true, studySlug, bookmarkedSectionIds: studyProgress.bookmarkedSectionIds }) + }) + // ── Community ───────────────────────────────────────────────────────────── app.get('/api/study-community', requireStudyAuth, async (req, res) => { @@ -323,9 +363,12 @@ export function register(app) { .slice(0, 50) .map(post => { const author = findStudyUserById(post.authorUserId) + const likedBy = Array.isArray(post.likedBy) ? post.likedBy : [] return { ...post, authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''), + likedByMe: likedBy.includes(user.id), + likeCount: likedBy.length, replies: Array.isArray(post.replies) ? post.replies.map(reply => { const replyAuthor = findStudyUserById(reply.authorUserId) @@ -412,4 +455,23 @@ export function register(app) { res.json({ ok: true, reply }) }) + + app.post('/api/study-community/posts/:postId/like', requireStudyAuth, (req, res) => { + const user = req.studyUser + const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : '' + const post = state.studyCommunityPosts.find(item => item.id === postId) + if (!post) { res.status(404).json({ message: 'Post not found.' }); return } + if (!isStudyUserEnrolled(user, post.studySlug)) { + res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return + } + if (!Array.isArray(post.likedBy)) post.likedBy = [] + const idx = post.likedBy.indexOf(user.id) + if (idx === -1) { + post.likedBy.push(user.id) + } else { + post.likedBy.splice(idx, 1) + } + queueStudyCommunityWrite() + res.json({ ok: true, likeCount: post.likedBy.length, likedByMe: post.likedBy.includes(user.id) }) + }) } diff --git a/server/study-helpers.js b/server/study-helpers.js index 92dfba1..c223cfc 100644 --- a/server/study-helpers.js +++ b/server/study-helpers.js @@ -622,6 +622,9 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) { 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 : [] @@ -677,7 +680,7 @@ function getReengagementTiers() { return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d })) } -function makeUnsubUrl(userId) { +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/' @@ -694,6 +697,8 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) { 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) @@ -733,3 +738,77 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) { } } } + +// ── 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() + } +} diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index a6805a1..4ba71fe 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -78,6 +78,9 @@ interface StudyUserRecord { noteCount: number subscribeNewsletter: boolean studyRemindersEnabled: boolean + reminderFrequency: 'immediate' | 'weekly' + remindersPausedUntil: string | null + reengagementSentAt: Record } function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { @@ -89,6 +92,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { const [newPassword, setNewPassword] = useState>({}) const [msg, setMsg] = useState>({}) const [confirmDeleteId, setConfirmDeleteId] = useState(null) + const [reengagementTier, setReengagementTier] = useState>({}) function flashMsg(id: string, text: string) { setMsg(prev => ({ ...prev, [id]: text })) @@ -154,6 +158,20 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } } + async function handleSendReengagement(user: StudyUserRecord) { + const tier = reengagementTier[user.id] ?? 't1' + try { + const res = await fetch(`/api/admin/study-users/${user.id}/send-reengagement`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tier }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.message ?? 'Error') + flashMsg(user.id, `Tier ${tier.slice(1)} re-engagement email sent.`) + } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } + } + async function handleDelete(id: string) { try { await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' }) @@ -222,7 +240,13 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { Last studied: {user.lastStudiedDate ?? '—'} Streak: {user.currentStreak ?? 0} 🔥 (best: {user.longestStreak ?? 0}) Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'} - Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'} + Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'} · {user.reminderFrequency === 'weekly' ? 'Weekly digest' : 'Per-lesson'} + {user.remindersPausedUntil && new Date(user.remindersPausedUntil) > new Date() && ( + Paused until {new Date(user.remindersPausedUntil).toLocaleDateString()} + )} + {user.reengagementSentAt && Object.keys(user.reengagementSentAt).length > 0 && ( + Re-engagement sent: {Object.keys(user.reengagementSentAt).sort().join(', ')} + )} {/* Display Name */} @@ -292,6 +316,25 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { )} + {/* Re-engagement */} + {user.enrolledStudies.length > 0 && ( +
+

Send Re-engagement Email

+
+ + +
+

Sends the selected tier email immediately, regardless of inactivity threshold.

+
+ )} + {msg[user.id] &&

{msg[user.id]}

} {/* Delete */} @@ -419,6 +462,18 @@ function EmailTemplatesPanel({ fieldKeys={['studyReengagementDays','studyReengagementT1Subject','studyReengagementT1Body','studyReengagementT1Cta','studyReengagementT2Subject','studyReengagementT2Body','studyReengagementT2Cta','studyReengagementT3Subject','studyReengagementT3Body','studyReengagementT3Cta']} openKey="study-reengagement" /> + + + + new Date()) summary: '', url: '', embedUrl: '', - showNotes: '', + showNotesSummary: '', + showNotesThemes: [], + showNotesScripture: [], + showNotesQuotes: [], + showNotesResources: [], discussionQuestions: [], }, ], @@ -1656,6 +1722,16 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) })) } + function updatePodcastLinkStringArray(id: string, field: keyof PodcastFeaturedLink, raw: string) { + const items = raw.split('\n').map(s => s.trim()).filter(Boolean) + setForm(f => ({ + ...f, + podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => + link.id === id ? { ...link, [field]: items } : link + ), + })) + } + function updatePodcastLinkQuestions(id: string, raw: string) { const questions = raw.split('\n').map(q => q.trim()).filter(Boolean) setForm(f => ({ @@ -3440,8 +3516,54 @@ async function handleAssetUpload(event: ChangeEvent) {
- -