diff --git a/data/study-notes/e43e7217-be69-49c6-a65d-edd0e7745884.json b/data/study-notes/e43e7217-be69-49c6-a65d-edd0e7745884.json
new file mode 100644
index 0000000..0cb7c90
--- /dev/null
+++ b/data/study-notes/e43e7217-be69-49c6-a65d-edd0e7745884.json
@@ -0,0 +1,3 @@
+{
+ "1-1-2": "Test"
+}
\ No newline at end of file
diff --git a/server.js b/server.js
index ac4ad12..2c7aad3 100644
--- a/server.js
+++ b/server.js
@@ -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
+ ? `
${escapeHtml(ctaLabel)}
`
+ : ''
+
+ return (
+ `` +
+ `
` +
+ `` +
+ `` +
+ `` +
+ `${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}
` +
+ `${escapeHtml(title)}
` +
+ ` ` +
+ `` +
+ `${bodyHtml}
` +
+ `${ctaBlock}` +
+ ` ` +
+ `${footerHtml ?? ''} ` +
+ `
` +
+ `
` +
+ `
`
+ )
+}
+
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 = (
+ `Welcome, ${escapeHtml(namePart)} .
` +
+ `Your student account is ready.
` +
+ `Open studies and continue learning, or manage your account details anytime.
`
+ )
+ const footerHtml = `Grace and peace, Verse by Verse with Nate
`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ',
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:
- `` +
- `
Welcome, ${escapeHtml(namePart)} .
` +
- `
Your student account is ready.
` +
- `
Open studies Manage account
` +
- `
Grace and peace, Verse by Verse with Nate
` +
- `
`,
+ html: buildBrandedEmailHtml({
+ title: 'Welcome to the Study Community',
+ eyebrow: 'Study Account',
+ bodyHtml,
+ ctaLabel: 'Open Studies',
+ ctaUrl: studiesUrl,
+ footerHtml: footerHtml + `Manage your account
`,
+ }),
})
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 = (
+ `Hi ${escapeHtml(namePart)} ,
` +
+ `This confirms your study account and saved notes were deleted.
` +
+ `If this was not you, please contact us immediately.
`
+ )
+ const footerHtml = `Verse by Verse with Nate
`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ',
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:
- `` +
- `
Hi ${escapeHtml(namePart)},
` +
- `
This confirms your study account and saved notes were deleted.
` +
- `
If this was not you, please contact us immediately.
` +
- `
Create a new account
` +
- `
Verse by Verse with Nate
` +
- `
`,
+ 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(),
diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx
index 3585a85..a92ae71 100644
--- a/src/colossiansStudy.tsx
+++ b/src/colossiansStudy.tsx
@@ -46,6 +46,23 @@ type StudyAccountOverview = {
type StudyNotesMap = Record
+type StudyCommunityReply = {
+ id: string
+ authorName: string
+ message: string
+ createdAt: string
+}
+
+type StudyCommunityPost = {
+ id: string
+ studySlug: string
+ sectionId: string
+ authorName: string
+ message: string
+ createdAt: string
+ replies: StudyCommunityReply[]
+}
+
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
const legacySections = content.colossiansStudySections?.length ? content.colossiansStudySections : DEFAULT_COLOSSIANS_STUDY_SECTIONS
return {
@@ -145,6 +162,213 @@ function getNoteId(studySlug: string, sectionId: string) {
return `${studySlug}--${sectionId}`
}
+function getDisplayName(auth: StudyAuthState) {
+ return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
+}
+
+function formatCommunityDate(value: string) {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return ''
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+}
+
+function StudyCommunityWidget({
+ study,
+ section,
+ auth,
+ isEnrolled,
+}: {
+ study: StudyProgram
+ section: StudySection
+ auth: StudyAuthState
+ isEnrolled: boolean
+}) {
+ const [posts, setPosts] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [message, setMessage] = useState('')
+ const [replyDrafts, setReplyDrafts] = useState>({})
+ const [replyingTo, setReplyingTo] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+ const [newPost, setNewPost] = useState('')
+
+ const canParticipate = auth.authenticated && isEnrolled
+ const communityDisplayName = getDisplayName(auth)
+
+ useEffect(() => {
+ let cancelled = false
+
+ async function load() {
+ if (!canParticipate) {
+ setPosts([])
+ setLoading(false)
+ return
+ }
+
+ setLoading(true)
+ setError('')
+ try {
+ const data = await readJson<{ posts?: StudyCommunityPost[] }>('/api/study-community?' + new URLSearchParams({ studySlug: study.slug }).toString())
+ if (cancelled) return
+ setPosts(Array.isArray(data.posts) ? data.posts : [])
+ } catch (err) {
+ if (cancelled) return
+ setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
+ } finally {
+ if (cancelled) return
+ setLoading(false)
+ }
+ }
+
+ void load()
+ return () => {
+ cancelled = true
+ }
+ }, [canParticipate, study.slug])
+
+ async function submitPost() {
+ if (!newPost.trim() || !canParticipate) return
+ setSubmitting(true)
+ setMessage('')
+ setError('')
+ try {
+ const data = await readJson<{ post?: StudyCommunityPost }>('/api/study-community/posts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ studySlug: study.slug, sectionId: section.id, message: newPost }),
+ })
+ if (data.post) setPosts(prev => [data.post!, ...prev])
+ setNewPost('')
+ setMessage('Posted to the community.')
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unable to post right now.')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ async function submitReply(postId: string) {
+ const reply = replyDrafts[postId]?.trim()
+ if (!reply || !canParticipate) return
+ setSubmitting(true)
+ setError('')
+ try {
+ const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: reply }),
+ })
+ if (data.reply) {
+ setPosts(prev => prev.map(post => post.id === postId ? { ...post, replies: [...(post.replies ?? []), data.reply!] } : post))
+ }
+ setReplyDrafts(prev => ({ ...prev, [postId]: '' }))
+ setReplyingTo(null)
+ setMessage('Reply posted.')
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unable to post your reply.')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
Study Community
+
Talk with other enrolled students about {study.title}. Keep it gracious and on topic.
+
+
Ask Nate
+
+
+ {!auth.checked && Checking your account status...
}
+ {auth.checked && !auth.authenticated && (
+
+
Sign in to join the study community.
+
+ Create Account
+ My Account
+
+
+ )}
+ {auth.checked && auth.authenticated && !isEnrolled && (
+
+
Enroll in this study to participate in the community.
+
+ Go to My Account
+
+
+ )}
+
+ {canParticipate && (
+
+ )}
+
+ {message && {message}
}
+ {error && {error}
}
+
+ {canParticipate && (
+
+ {loading ? (
+
Loading community posts...
+ ) : posts.length === 0 ? (
+
No one has posted here yet. Be the first to start the conversation.
+ ) : posts.map(post => (
+
+
+ {post.authorName || 'Student'}
+ {formatCommunityDate(post.createdAt)}
+
+
{post.message}
+
+ setReplyingTo(curr => curr === post.id ? null : post.id)}>{replyingTo === post.id ? 'Cancel Reply' : 'Reply'}
+
+ {replyingTo === post.id && canParticipate && (
+
+
+ )}
+ {Array.isArray(post.replies) && post.replies.length > 0 && (
+
+ {post.replies.map(reply => (
+
+
+ {reply.authorName || 'Student'}
+ {formatCommunityDate(reply.createdAt)}
+
+
{reply.message}
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
+
async function readJson(url: string, init?: RequestInit): Promise {
const response = await fetch(url, init)
const data = await response.json().catch(() => ({})) as T & { message?: string }
@@ -888,6 +1112,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
))}
+
+