Files
Siteforge/server/routes/study-certificate.js
T
nmemmertandClaude Sonnet 4.6 038ef2b039 Add study account engagement features; v1.2.4
- Pause reminders: remindersPausedUntil field + scheduler skip + account UI with 7/14/30-day presets
- Email digest mode: reminderFrequency field (immediate/weekly) + weekly digest email + scheduler + account UI toggle
- Admin user list improvements: streak, reengagement, frequency, pause, per-study lesson progress
- Admin manual re-engagement: POST endpoint + tier selector button in admin panel
- Study bookmarks: bookmarkedSectionIds in progress file + POST/DELETE endpoints + toggle button in lesson UI
- Study completion certificate email: auto-sent on first certificate issuance
- Content fields for certificate and weekly digest email templates in admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 15:16:47 -04:00

130 lines
5.0 KiB
JavaScript

import { randomUUID } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
import { state } from '../state.js'
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
import { buildAbsoluteUrl } from '../helpers.js'
const publicCertRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many certificate lookups. Please wait a moment.' },
})
/**
* Returns the list of required section IDs for a study (only released sections).
*/
function getRequiredSectionIds(studySlug) {
const content = state.cachedSiteContent
if (!content || !Array.isArray(content.studies)) return null
const study = content.studies.find(s => s?.slug === studySlug)
if (!study || !Array.isArray(study.sections)) return null
return study.sections
.filter(s => s?.status !== 'unreleased' && s?.id)
.map(s => s.id)
}
export function register(app) {
// GET — check eligibility and return existing certificate token if one exists
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return
}
const requiredIds = getRequiredSectionIds(studySlug)
if (!requiredIds || requiredIds.length === 0) {
res.status(404).json({ message: 'Study sections not found.' }); return
}
const progress = await loadUserProgress(user.id)
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const eligible = requiredIds.every(id => completedIds.includes(id))
const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
res.json({
eligible,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
completedCount: completedIds.filter(id => requiredIds.includes(id)).length,
totalCount: requiredIds.length,
token: existing?.token ?? null,
issuedAt: existing?.issuedAt ?? null,
})
})
// POST — issue/re-issue a certificate (must be eligible)
app.post('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return
}
const requiredIds = getRequiredSectionIds(studySlug)
if (!requiredIds || requiredIds.length === 0) {
res.status(404).json({ message: 'Study sections not found.' }); return
}
const progress = await loadUserProgress(user.id)
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const eligible = requiredIds.every(id => completedIds.includes(id))
if (!eligible) {
res.status(403).json({ message: 'Complete all sections to earn your certificate.' }); return
}
// Find or create
let cert = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
if (!cert) {
cert = {
id: randomUUID(),
token: randomUUID(),
userId: user.id,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
displayName: user.displayName || user.username,
issuedAt: new Date().toISOString(),
}
state.studyCertificates.push(cert)
queueStudyCertificatesWrite()
const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/certificate/${cert.token}`)
sendStudyCertificateEmail(user.username, user.displayName || user.username, cert.studyTitle, certUrl)
.catch(err => console.error('[study-certificate] email exception:', err))
}
res.json({ ok: true, token: cert.token, issuedAt: cert.issuedAt })
})
// GET — public certificate page data (no auth, by token)
app.get('/api/public/certificate/:token', publicCertRateLimiter, (req, res) => {
const { token } = req.params
if (!token || typeof token !== 'string' || token.length > 100) {
res.status(400).json({ message: 'Invalid token.' }); return
}
const cert = state.studyCertificates.find(c => c.token === token)
if (!cert) { res.status(404).json({ message: 'Certificate not found.' }); return }
res.json({
studyTitle: cert.studyTitle,
displayName: cert.displayName,
issuedAt: cert.issuedAt,
})
})
// Admin: list all certificates
app.get('/api/admin-study-certificates', requireAdminAuth, (_req, res) => {
res.json({ certificates: state.studyCertificates })
})
}