Discussion only open while a study is active; v2.11.1
Publish Container / build (push) Has been cancelled
Publish Container / build (push) Has been cancelled
- Community posts, replies, likes and lesson comments are refused (403) for finished and planned studies - Reading a closed study's community/comments returns an empty list with closed:true - Discussion hub lists only active studies; community page shows a closed notice - Lesson page hides Lesson Discussion, 'Discuss this lesson' and 'Go to Community' for inactive studies; Community buttons hidden on study/dashboard pages - Tests for isStudyDiscussionOpen Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb83f3e8c5
commit
f1d4909acb
7 files changed
+98
-14
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"version": "2.11.0",
|
||||
"version": "2.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "siteforge",
|
||||
"version": "2.11.0",
|
||||
"version": "2.11.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1135.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.11.0",
|
||||
"version": "2.11.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, hasStudyAccess, findStudyUserById, getCommunityDisplayName } from '../study-helpers.js'
|
||||
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, hasStudyAccess, findStudyUserById, getCommunityDisplayName, isStudyDiscussionOpen, DISCUSSION_CLOSED_MESSAGE } from '../study-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueStudyCommentsWrite } from '../data.js'
|
||||
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
|
||||
@@ -14,6 +14,10 @@ export function register(app) {
|
||||
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(studySlug)) {
|
||||
res.json({ comments: [], closed: true, message: DISCUSSION_CLOSED_MESSAGE }); return
|
||||
}
|
||||
|
||||
const comments = state.studyComments
|
||||
.filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true)
|
||||
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
@@ -44,6 +48,10 @@ export function register(app) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(studySlug)) {
|
||||
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true }); return
|
||||
}
|
||||
|
||||
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
|
||||
if (!text || text.length < 2 || text.length > MAX_STUDY_COMMENT_LENGTH) {
|
||||
res.status(400).json({ message: `Comment must be between 2 and ${MAX_STUDY_COMMENT_LENGTH} characters.` }); return
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
getCommunityDisplayName,
|
||||
getAccessibleStudySlugs,
|
||||
markStudyCompletedIfDone,
|
||||
isStudyDiscussionOpen,
|
||||
DISCUSSION_CLOSED_MESSAGE,
|
||||
} from '../study-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
@@ -368,6 +370,11 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(studySlug)) {
|
||||
res.json({ studySlug, posts: [], closed: true, message: DISCUSSION_CLOSED_MESSAGE })
|
||||
return
|
||||
}
|
||||
|
||||
const posts = state.studyCommunityPosts
|
||||
.filter(post => post.studySlug === studySlug)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
@@ -413,6 +420,11 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(studySlug)) {
|
||||
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true })
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const authorName = getCommunityDisplayName(user)
|
||||
const post = {
|
||||
@@ -455,6 +467,11 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(post.studySlug)) {
|
||||
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true })
|
||||
return
|
||||
}
|
||||
|
||||
const reply = {
|
||||
id: randomUUID(),
|
||||
authorUserId: user.id,
|
||||
@@ -480,6 +497,9 @@ export function register(app) {
|
||||
if (!hasStudyAccess(user, post.studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return
|
||||
}
|
||||
if (!isStudyDiscussionOpen(post.studySlug)) {
|
||||
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true }); return
|
||||
}
|
||||
if (!Array.isArray(post.likedBy)) post.likedBy = []
|
||||
const idx = post.likedBy.indexOf(user.id)
|
||||
if (idx === -1) {
|
||||
|
||||
@@ -225,6 +225,18 @@ export async function backfillCompletedStudies() {
|
||||
return updated
|
||||
}
|
||||
|
||||
// Discussion (community posts, replies, likes and lesson comments) is only open while a
|
||||
// study is active. Finished and planned studies are closed.
|
||||
export function isStudyDiscussionOpen(studySlug) {
|
||||
const normalized = normalizeStudySlug(studySlug)
|
||||
const studies = state.cachedSiteContent?.studies
|
||||
if (!normalized || !Array.isArray(studies)) return false
|
||||
const study = studies.find(item => normalizeStudySlug(item?.slug) === normalized)
|
||||
return Boolean(study) && getEffectiveStudyStatus(study) === 'active'
|
||||
}
|
||||
|
||||
export const DISCUSSION_CLOSED_MESSAGE = 'Discussion is only open while a study is active.'
|
||||
|
||||
export function findStudyUserById(userId) {
|
||||
if (typeof userId !== 'string' || !userId.trim()) return undefined
|
||||
return state.studyUsers.find(user => user.id === userId)
|
||||
|
||||
+35
-10
@@ -212,6 +212,11 @@ function getEffectiveStudyStatus(study: StudyProgram): 'active' | 'planned' | 'c
|
||||
return 'active'
|
||||
}
|
||||
|
||||
// Discussion (community board and lesson comments) is only open while a study is active.
|
||||
function isDiscussionOpen(study: StudyProgram | undefined): boolean {
|
||||
return Boolean(study) && getEffectiveStudyStatus(study as StudyProgram) === 'active'
|
||||
}
|
||||
|
||||
function getNextReleaseLabel(study: StudyProgram): string {
|
||||
const upcoming = study.sections
|
||||
.map(getSectionReleaseDate)
|
||||
@@ -931,7 +936,7 @@ export function StudyLandingPage({ content }: Props) {
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}>
|
||||
<Link to={`/study/${study.slug}`} className="btn-primary">{study.completed ? 'Review Lessons' : 'Continue'}</Link>
|
||||
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>
|
||||
<Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>
|
||||
{isDiscussionOpen(studyMeta) && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
||||
{study.completed && <Link to="/study/account" className="btn-secondary">🏅 Certificate</Link>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1693,7 +1698,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
)}
|
||||
{allLessonsDone && <Link to="/study/account" className="btn-secondary">🏅 My Certificate</Link>}
|
||||
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
|
||||
{enrolled && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
||||
{enrolled && studyStatus === 'active' && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
||||
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</Link>}
|
||||
</div>
|
||||
{enrollMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{enrollMessage}</p>}
|
||||
@@ -1856,6 +1861,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
const currentStudySlug = study?.slug ?? 'colossians'
|
||||
const noteId = section?.id ? getNoteId(currentStudySlug, section.id) : ''
|
||||
const isEnrolled = isEnrolledInStudy(auth, currentStudySlug)
|
||||
const discussionOpen = isDiscussionOpen(study)
|
||||
const lessonCompleted = section?.id ? completedSectionIds.includes(section.id) : false
|
||||
|
||||
usePageMeta(
|
||||
@@ -2371,7 +2377,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
))}
|
||||
</ol>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link>
|
||||
{discussionOpen && <Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link>}
|
||||
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
|
||||
</div>
|
||||
</article>
|
||||
@@ -2480,14 +2486,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
)
|
||||
})()}
|
||||
|
||||
<article className="study-class-block">
|
||||
{discussionOpen && <article className="study-class-block">
|
||||
<h2>Lesson Discussion</h2>
|
||||
<p className="study-detail-copy" style={{ fontSize: '0.88rem', color: '#7a7060', margin: '0 0 1rem' }}>
|
||||
Share a thought or reaction with other students in this lesson.{' '}
|
||||
<Link to={`/study/${study.slug}/community`} style={{ color: '#e0b840' }}>See all community posts →</Link>
|
||||
</p>
|
||||
<StudySectionComments studySlug={currentStudySlug} sectionId={section.id} isEnrolled={isEnrolled} />
|
||||
</article>
|
||||
</article>}
|
||||
</div>
|
||||
|
||||
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
||||
@@ -2566,7 +2572,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
</article>
|
||||
|
||||
{/* Community shortcut */}
|
||||
{isEnrolled && (
|
||||
{isEnrolled && discussionOpen && (
|
||||
<article className="study-class-side-block study-class-side-block--community">
|
||||
<Link to={`/study/${study.slug}/community?sectionId=${section.id}`} className="study-community-shortcut">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="study-community-shortcut-icon"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" /></svg>
|
||||
@@ -4169,6 +4175,7 @@ export function StudyDiscussionHubPage({ content }: Props) {
|
||||
}, [])
|
||||
|
||||
usePageMeta('Study Discussion | Bible Study', 'Pick a study track and join the conversation with other students.')
|
||||
const openDiscussionStudies = studies.filter(study => isDiscussionOpen(study))
|
||||
|
||||
if (!auth.checked) {
|
||||
return (
|
||||
@@ -4197,15 +4204,15 @@ export function StudyDiscussionHubPage({ content }: Props) {
|
||||
<div className="section-inner study-course-hero-inner">
|
||||
<p className="eyebrow">Student Community</p>
|
||||
<h1>Study Discussion</h1>
|
||||
<p className="study-course-hero-copy">Pick a study track to see what other students are saying, ask questions, and share what God is teaching you.</p>
|
||||
<p className="study-course-hero-copy">Pick an active study to see what other students are saying, ask questions, and share what God is teaching you. Discussion is open while a study is active.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-inner" style={{ paddingTop: '1rem', paddingBottom: '4rem' }}>
|
||||
{studies.length === 0 ? (
|
||||
<p className="study-detail-copy">No study tracks are available yet.</p>
|
||||
{openDiscussionStudies.length === 0 ? (
|
||||
<p className="study-detail-copy">Discussion opens while a study is active. There are no active studies right now — check back when the next one starts.</p>
|
||||
) : (
|
||||
<div className="study-module-list">
|
||||
{studies.map(study => {
|
||||
{openDiscussionStudies.map(study => {
|
||||
const enrolled = isEnrolledInStudy(auth, study.slug)
|
||||
return (
|
||||
<article key={study.slug} className="study-module-row">
|
||||
@@ -4281,6 +4288,24 @@ export function StudyCommunityPage({ content }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDiscussionOpen(study)) {
|
||||
return (
|
||||
<main className="auth-page" aria-label="Discussion closed">
|
||||
<div className="auth-shell auth-shell--single">
|
||||
<div className="auth-card auth-card--center">
|
||||
<p className="eyebrow">{study.title}</p>
|
||||
<h1>Discussion Is Closed</h1>
|
||||
<p>Discussion is only open while a study is active, and this study isn't active right now.</p>
|
||||
<div className="study-signup-actions">
|
||||
<Link to={`/study/${study.slug}`} className="btn-primary">Back to Study</Link>
|
||||
</div>
|
||||
<p className="auth-footer-links"><Link to="/study/discussion">← Study Discussion</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const isEnrolled = isEnrolledInStudy(auth, study.slug)
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,3 +59,22 @@ test('re-engagement emails skip students whose only study is finished', async ()
|
||||
await scheduleReengagementEmails(async (email) => { sent.push(email) })
|
||||
assert.deepEqual(sent, ['c@d.co'])
|
||||
})
|
||||
|
||||
test('discussion is open only for active studies', async () => {
|
||||
const { isStudyDiscussionOpen } = await import('../server/study-helpers.js')
|
||||
const past = '2020-01-01T00:00:00Z'
|
||||
const future = '2099-01-01T00:00:00Z'
|
||||
state.cachedSiteContent = {
|
||||
studies: [
|
||||
{ slug: 'live', status: 'active', sections: [{ id: 'a', releasedAt: past }, { id: 'b', releasedAt: future }] },
|
||||
{ slug: 'done', status: 'complete', sections: [{ id: 'a', releasedAt: past }] },
|
||||
{ slug: 'auto-done', sections: [{ id: 'a', releasedAt: past }] }, // every lesson released => complete
|
||||
{ slug: 'soon', status: 'planned', sections: [] },
|
||||
],
|
||||
}
|
||||
assert.equal(isStudyDiscussionOpen('live'), true)
|
||||
assert.equal(isStudyDiscussionOpen('done'), false)
|
||||
assert.equal(isStudyDiscussionOpen('auto-done'), false)
|
||||
assert.equal(isStudyDiscussionOpen('soon'), false)
|
||||
assert.equal(isStudyDiscussionOpen('missing'), false)
|
||||
})
|
||||
Reference in new issue
Block a user