Add study community discussion board and Ask Nate button

This commit is contained in:
nmemmert
2026-05-21 14:04:12 -04:00
parent ffa860d5bc
commit eee200cf9c
3 changed files with 460 additions and 15 deletions
+231 -15
View File
@@ -61,6 +61,7 @@ const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration
const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes')
const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
@@ -630,6 +631,8 @@ let studyUsers = []
let studyUsersWritePromise = Promise.resolve()
const studyNotesCache = new Map() // userId -> { [sectionId]: string }
const studyNotesWriteQueues = new Map() // userId -> Promise
let studyCommunityPosts = []
let studyCommunityWritePromise = Promise.resolve()
let downloadCounts = {}
let downloadCountsWritePromise = Promise.resolve()
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
@@ -1245,6 +1248,7 @@ async function createBackupSnapshot(reason = 'scheduled') {
hitStats,
visitorStats,
contactSubmissions,
studyCommunityPosts,
replyTemplates,
replyHistory,
}
@@ -1931,6 +1935,38 @@ function getCanonicalBaseUrl() {
return configured.trim()
}
function buildBrandedEmailHtml({
title,
eyebrow,
bodyHtml,
ctaLabel,
ctaUrl,
footerHtml,
}) {
const ctaBlock = ctaLabel && ctaUrl
? `<p style="margin:24px 0 0;"><a href="${escapeHtml(ctaUrl)}" target="_blank" style="display:inline-block;padding:12px 22px;background:#c9a84c;border:1px solid #e0c070;border-radius:999px;color:#111111;font-family:Georgia,serif;font-size:13px;font-weight:700;letter-spacing:0.14em;text-decoration:none;text-transform:uppercase;">${escapeHtml(ctaLabel)}</a></p>`
: ''
return (
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:28px;font-weight:600;color:#e0c070;line-height:1.2;">${escapeHtml(title)}</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<div style="font-family:Georgia,serif;font-size:15px;line-height:1.8;color:#c8c0ac;">${bodyHtml}</div>` +
`${ctaBlock}` +
`</td></tr>` +
`<tr><td style="padding:28px 40px 40px;text-align:center;">${footerHtml ?? ''}</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`
)
}
async function sendStudyWelcomeEmail(email, displayName) {
if (!process.env.RESEND_API_KEY) return
try {
@@ -1939,6 +1975,12 @@ async function sendStudyWelcomeEmail(email, displayName) {
const baseUrl = getCanonicalBaseUrl()
const studiesUrl = buildAbsoluteUrl(baseUrl, '/study')
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
const bodyHtml = (
`<p style="margin:0 0 16px;">Welcome, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>.</p>` +
`<p style="margin:0 0 16px;">Your student account is ready.</p>` +
`<p style="margin:0 0 16px;">Open studies and continue learning, or manage your account details anytime.</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
to: [email],
@@ -1949,13 +1991,14 @@ async function sendStudyWelcomeEmail(email, displayName) {
`Open studies: ${studiesUrl}\n` +
`Manage account: ${accountUrl}\n\n` +
`Grace and peace,\nVerse by Verse with Nate`,
html:
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
`<p>Welcome, <strong>${escapeHtml(namePart)}</strong>.</p>` +
`<p>Your student account is ready.</p>` +
`<p><a href="${escapeHtml(studiesUrl)}">Open studies</a><br/><a href="${escapeHtml(accountUrl)}">Manage account</a></p>` +
`<p>Grace and peace,<br/>Verse by Verse with Nate</p>` +
`</div>`,
html: buildBrandedEmailHtml({
title: 'Welcome to the Study Community',
eyebrow: 'Study Account',
bodyHtml,
ctaLabel: 'Open Studies',
ctaUrl: studiesUrl,
footerHtml: footerHtml + `<p style="margin:14px 0 0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;"><a href="${escapeHtml(accountUrl)}" target="_blank" style="color:#c9a84c;text-decoration:none;">Manage your account</a></p>`,
}),
})
if (error) console.error('[study-signup] welcome email send error:', error)
} catch (err) {
@@ -1970,6 +2013,12 @@ async function sendStudyAccountDeletedEmail(email, displayName) {
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const baseUrl = getCanonicalBaseUrl()
const signupUrl = buildAbsoluteUrl(baseUrl, '/study/signup')
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">This confirms your study account and saved notes were deleted.</p>` +
`<p style="margin:0 0 16px;">If this was not you, please contact us immediately.</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Verse by Verse with Nate</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
to: [email],
@@ -1980,14 +2029,14 @@ async function sendStudyAccountDeletedEmail(email, displayName) {
`If this was not you, please contact us immediately.\n\n` +
`Create a new account anytime: ${signupUrl}\n\n` +
`Verse by Verse with Nate`,
html:
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
`<p>Hi ${escapeHtml(namePart)},</p>` +
`<p>This confirms your study account and saved notes were deleted.</p>` +
`<p>If this was not you, please contact us immediately.</p>` +
`<p><a href="${escapeHtml(signupUrl)}">Create a new account</a></p>` +
`<p>Verse by Verse with Nate</p>` +
`</div>`,
html: buildBrandedEmailHtml({
title: 'Study Account Deleted',
eyebrow: 'Account Update',
bodyHtml,
ctaLabel: 'Create a New Account',
ctaUrl: signupUrl,
footerHtml,
}),
})
if (error) console.error('[study-account] delete email send error:', error)
} catch (err) {
@@ -2056,6 +2105,74 @@ function sanitizeUserNotes(value) {
return out
}
function sanitizeStudyCommunityReply(reply) {
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return null
const message = typeof reply.message === 'string' ? reply.message.trim().slice(0, 3000) : ''
if (!message) return null
return {
id: typeof reply.id === 'string' && reply.id.trim() ? reply.id.trim() : randomUUID(),
authorUserId: typeof reply.authorUserId === 'string' && reply.authorUserId.trim() ? reply.authorUserId.trim() : '',
authorName: typeof reply.authorName === 'string' ? reply.authorName.trim().slice(0, 120) : '',
message,
createdAt: typeof reply.createdAt === 'string' ? reply.createdAt : new Date().toISOString(),
}
}
function sanitizeStudyCommunityPosts(value) {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && !Array.isArray(item))
.map(item => {
const studySlug = normalizeStudySlug(item.studySlug)
const sectionId = typeof item.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(item.sectionId) ? item.sectionId.trim() : ''
const message = typeof item.message === 'string' ? item.message.trim().slice(0, 3000) : ''
const replies = Array.isArray(item.replies)
? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50)
: []
if (!studySlug || !message) return null
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
studySlug,
sectionId,
authorUserId: typeof item.authorUserId === 'string' && item.authorUserId.trim() ? item.authorUserId.trim() : '',
authorName: typeof item.authorName === 'string' ? item.authorName.trim().slice(0, 120) : '',
message,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
replies,
}
})
.filter(Boolean)
}
async function loadStudyCommunityFromDisk() {
return readFile(STUDY_COMMUNITY_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
studyCommunityPosts = sanitizeStudyCommunityPosts(parsed?.posts ?? parsed)
})
.catch(() => {
studyCommunityPosts = []
})
}
function queueStudyCommunityWrite() {
studyCommunityWritePromise = studyCommunityWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
STUDY_COMMUNITY_FILE,
JSON.stringify({ posts: studyCommunityPosts, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[study-community] failed to write discussion posts:', err)
})
}
function getUserNotesFilePath(userId) {
// userId is a UUID — safe as a filename
return path.join(STUDY_NOTES_DIR, `${userId}.json`)
@@ -2449,6 +2566,104 @@ app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
res.json({ ok: true, note })
})
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '')
if (!studySlug) {
res.status(400).json({ message: 'Study slug is required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
return
}
const posts = studyCommunityPosts
.filter(post => post.studySlug === studySlug)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 50)
res.json({
studySlug,
posts,
})
})
app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.body?.studySlug)
const sectionId = typeof req.body?.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(req.body.sectionId) ? req.body.sectionId.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
if (!studySlug || !message) {
res.status(400).json({ message: 'Study slug and message are required.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
return
}
const now = new Date().toISOString()
const authorName = user.displayName?.trim() || user.username
const post = {
id: randomUUID(),
studySlug,
sectionId,
authorUserId: user.id,
authorName,
message,
createdAt: now,
replies: [],
}
studyCommunityPosts.unshift(post)
studyCommunityPosts = sanitizeStudyCommunityPosts(studyCommunityPosts).slice(0, 500)
queueStudyCommunityWrite()
res.json({ ok: true, post })
})
app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
if (!postId || !message) {
res.status(400).json({ message: 'Post id and message are required.' })
return
}
const post = 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 reply in the community.' })
return
}
const reply = {
id: randomUUID(),
authorUserId: user.id,
authorName: user.displayName?.trim() || user.username,
message,
createdAt: new Date().toISOString(),
}
post.replies = Array.isArray(post.replies) ? post.replies : []
post.replies.push(reply)
post.replies = sanitizeStudyCommunityPosts([post])[0]?.replies ?? []
queueStudyCommunityWrite()
res.json({ ok: true, reply })
})
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
@@ -4337,6 +4552,7 @@ Promise.all([
loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(),
loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(),
migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(),
loadPodcastChecklistFromDisk(),