Files
Siteforge/server/routes/study-comments.js
T
nmemmertandClaude Sonnet 5 8496e227b3
Publish Container / build (push) Successful in 14m56s
Stop showing email-derived names in student discussions; v2.2.1
Community posts/replies fell back to the local part of the student's email
(e.g. "student.test" from student.test@versebyverse.local) whenever they
hadn't set a display name. The per-lesson discussion comments were worse —
they fell back to the FULL email address, and that endpoint has no auth
requirement, so it was publicly readable.

Adds getCommunityDisplayName() in study-helpers.js: uses the student's
display name when set, otherwise assigns a stable "Student XXXX" handle
derived from their account id — never their email. Both the community board
and lesson comments now resolve names live from the author's current
account at read time (falling back to the stored snapshot only if the
account was deleted), so this also fixes any already-posted content
without a data migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:15:54 -04:00

126 lines
4.8 KiB
JavaScript

import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled, findStudyUserById, getCommunityDisplayName } 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
}
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 (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); 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 })
})
}