Publish Container / build (push) Successful in 17m50s
- Study status 'complete' (manual or auto when all lessons released); stays enrollable and self-paced (no sequential lock, Resume button, Finished Studies hub section) - Studies get lane (A/B) and expected start date; planned studies show "Starts <date>"; hub shows next lesson date instead of hardcoded pace - Second concurrent series (homepage card, admin panel) with its own scheduled launch; transitions are per lane and flip the ending study to Complete and the incoming study to Active - Finished books link to their study: "Study This Book" on playlist page, audio-only link from hub (playlist stays public, no account) - Admin: bulk release scheduler (every N days), same-day collision warning, linked-study selects - Weekly digest no longer dumps a finished study's backlog on late enrollees - CI: build and tag the container as :beta on pushes to the beta branch Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
131 lines
5.1 KiB
JavaScript
131 lines
5.1 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. Every lesson is required, so a
|
|
* certificate is only available once the whole study has been released and completed.
|
|
*/
|
|
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?.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 })
|
|
})
|
|
}
|