import { randomUUID } from 'node:crypto' 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' /** * 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() } 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', (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 }) }) }