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 && ( +
+