Files
Siteforge/server/routes/study-comments.js
T
nmemmertandClaude Sonnet 5.5 bb83f3e8c5
Publish Container / build (push) Successful in 15m31s
Completed studies: finishing a study keeps access after leaving; certificates always shown; no reminders for finished studies; v2.11.0
- Completion is stored on the user (completedStudySlugs, sticky) when every lesson is done; backfilled at startup
- Lessons, notes, progress, quiz, community and certificate stay open for completed studies after leaving the enrollment
- Dashboard/My Account list completed studies with Review Lessons and certificate access
- Study page shows a completed banner
- Lesson-release reminders, weekly digest and re-engagement emails skip finished studies
- Fix: streak fields (current/longest/last studied) and firstCompletionAt were dropped on every restart when users were loaded
- Tests for completion/access/re-engagement

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:42:58 -04:00

126 lines
4.8 KiB
JavaScript

import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, hasStudyAccess, 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 (!hasStudyAccess(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 })
})
}