Files
nmemmertandClaude Sonnet 5.5 f1d4909acb
Publish Container / build (push) Has been cancelled
Discussion only open while a study is active; v2.11.1
- 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>
2026-10-09 11:49:03 -04:00

134 lines
5.2 KiB
JavaScript

import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.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'
export function register(app) {
// GET public-approved comments for a section (no auth)
app.get('/api/study-comments/:studySlug/:sectionId', (req, res) => {
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 (!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))
.map(c => {
const author = findStudyUserById(c.userId)
return {
id: c.id,
displayName: author ? getCommunityDisplayName(author) : c.displayName,
text: c.text,
createdAt: c.createdAt,
}
})
res.json({ comments })
})
// POST — enrolled study users post a comment (auto-approved for enrolled users)
app.post('/api/study-comments/:studySlug/:sectionId', requireStudyAuth, (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 (!hasStudyAccess(user, studySlug)) {
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
}
// Auto-approve for enrolled users
const now = new Date().toISOString()
const comment = {
id: randomUUID(),
studySlug,
sectionId,
userId: user.id,
displayName: getCommunityDisplayName(user),
text,
createdAt: now,
isApproved: true,
approvedAt: now,
}
state.studyComments.unshift(comment)
if (state.studyComments.length > MAX_STUDY_COMMENTS) {
state.studyComments = state.studyComments.slice(0, MAX_STUDY_COMMENTS)
}
queueStudyCommentsWrite()
res.status(201).json({
ok: true,
comment: {
id: comment.id,
displayName: comment.displayName,
text: comment.text,
createdAt: comment.createdAt,
},
})
})
// DELETE — enrolled users can delete their own comment
app.delete('/api/study-comments/:commentId', requireStudyAuth, (req, res) => {
const user = req.studyUser
const { commentId } = req.params
const idx = state.studyComments.findIndex(c => c.id === commentId && c.userId === user.id)
if (idx === -1) {
res.status(404).json({ message: 'Comment not found.' }); return
}
state.studyComments.splice(idx, 1)
queueStudyCommentsWrite()
res.json({ ok: true })
})
// Admin: list all comments (optionally filter by studySlug)
app.get('/api/admin-study-comments', requireAdminAuth, (req, res) => {
const { studySlug, sectionId, approved } = req.query
let comments = state.studyComments
if (studySlug) comments = comments.filter(c => c.studySlug === studySlug)
if (sectionId) comments = comments.filter(c => c.sectionId === sectionId)
if (approved === 'true') comments = comments.filter(c => c.isApproved === true)
if (approved === 'false') comments = comments.filter(c => c.isApproved !== true)
res.json({ comments: comments.slice(0, 500) })
})
// Admin: approve a comment
app.post('/api/admin-study-comments/:commentId/approve', requireAdminAuth, (req, res) => {
const comment = state.studyComments.find(c => c.id === req.params.commentId)
if (!comment) { res.status(404).json({ message: 'Comment not found.' }); return }
comment.isApproved = true
comment.approvedAt = new Date().toISOString()
queueStudyCommentsWrite()
res.json({ ok: true, comment })
})
// Admin: delete any comment
app.delete('/api/admin-study-comments/:commentId', requireAdminAuth, (req, res) => {
const idx = state.studyComments.findIndex(c => c.id === req.params.commentId)
if (idx === -1) { res.status(404).json({ message: 'Comment not found.' }); return }
state.studyComments.splice(idx, 1)
queueStudyCommentsWrite()
res.json({ ok: true })
})
}