// Student-facing learning API — scenario attempt lifecycle (start/hint), curriculum state, // assessments (list/start/active/detail/submit), capstone status, and achievement history. // Everything here requires a student session (studentSession middleware, mounted by the // caller) and derives identity/ownership entirely server-side — never from anything the client // asserts. See docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §19. const attempts = require('./attempts'); const assessmentsLib = require('./assessments'); const achievementsLib = require('./achievements'); const { computeCurriculumState } = require('./curriculum-rules'); async function studentCohortId(pool, studentId) { const { rows } = await pool.query( `SELECT cohort_id FROM cohort_enrollments WHERE student_id = $1 AND enrollment_status = 'enrolled' ORDER BY enrolled_at LIMIT 1`, [studentId] ); return rows[0]?.cohort_id || null; } function findStageForScenario(path, scenarioId) { return path?.stages.find((s) => (s.scenario_ids || []).includes(scenarioId))?.id || null; } async function getCapstoneAchievement(pool, studentId) { const { rows } = await pool.query( `SELECT * FROM achievements WHERE student_id = $1 AND achievement_type = 'capstone_completed' LIMIT 1`, [studentId] ); return rows[0] || null; } module.exports = function registerLearningRoutes(app, deps) { const { studentSession, getPool, curriculum, loadProgress, wrap } = deps; // ── Scenario attempt lifecycle ────────────────────────────────────────────────────────── app.post('/api/scenarios/:scenarioId/start', studentSession, wrap(async (req, res) => { const pool = getPool(); const { environment_id } = req.body || {}; const path = curriculum.loadCurriculumPath(); const cohortId = await studentCohortId(pool, req.studentId); const stage = findStageForScenario(path, req.params.scenarioId); const assessmentCtx = await assessmentsLib.findActiveAssessmentContextForScenario(pool, req.studentId, req.params.scenarioId); const attempt = await attempts.getOrCreateOpenAttempt(pool, { studentId: req.studentId, scenarioId: req.params.scenarioId, environmentId: environment_id || null, cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id, }); res.json({ attempt_id: attempt.attempt_id, status: attempt.status, started_at: attempt.started_at }); })); app.get('/api/scenarios/:scenarioId/attempt', studentSession, wrap(async (req, res) => { const attempt = await attempts.getAttemptSummary(getPool(), { studentId: req.studentId, scenarioId: req.params.scenarioId, environmentId: req.query.environment_id, }); const scenario = curriculum.loadScenarios().find((s) => s.id === req.params.scenarioId); res.json({ attempt: attempt ? { status: attempt.status, validation_attempt_count: attempt.validation_attempt_count, validation_pass_count: attempt.validation_pass_count, validation_fail_count: attempt.validation_fail_count, hint_count: attempt.hint_count, highest_hint_level: attempt.highest_hint_level, duration_seconds: attempt.duration_seconds, started_at: attempt.started_at, completed_at: attempt.completed_at, } : null, total_hints: scenario?.hints?.length || 0, }); })); app.post('/api/scenarios/:scenarioId/hint', studentSession, wrap(async (req, res) => { const pool = getPool(); const { environment_id } = req.body || {}; const scenario = curriculum.loadScenarios().find((s) => s.id === req.params.scenarioId); if (!scenario) return res.status(404).json({ error: 'scenario_not_found' }); const hints = scenario.hints || []; if (!hints.length) return res.status(404).json({ error: 'no_hints' }); const assessmentCtx = await assessmentsLib.findActiveAssessmentContextForScenario(pool, req.studentId, req.params.scenarioId); if (assessmentCtx && assessmentCtx.session.hint_policy === 'disabled') { return res.status(403).json({ error: 'hints_disabled', message: 'Hints are disabled for this assessment.' }); } const path = curriculum.loadCurriculumPath(); const cohortId = await studentCohortId(pool, req.studentId); const stage = findStageForScenario(path, req.params.scenarioId); const result = await attempts.recordHintReveal(pool, { studentId: req.studentId, scenarioId: req.params.scenarioId, environmentId: environment_id || null, cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id, }, hints.length); if (!result.ok) return res.status(409).json({ error: result.reason }); const { title, body, command } = hints[result.level - 1]; res.json({ hint: { title, body, command }, level: result.level, total_hints: hints.length, flagged: !!(assessmentCtx && assessmentCtx.session.hint_policy === 'flagged'), }); })); // ── Curriculum state (extends /api/curriculum/path's response with rule-driven state) ───── app.get('/api/curriculum/state', studentSession, wrap(async (req, res) => { const pool = getPool(); const path = curriculum.loadCurriculumPath(); if (!path) return res.status(404).json({ error: 'curriculum_not_found' }); const progress = await loadProgress(pool, req.studentId); const capstoneAchievement = await getCapstoneAchievement(pool, req.studentId); const capstoneStatus = capstoneAchievement ? 'completed' : undefined; // undefined lets computeCurriculumState derive locked/unlocked from the rule res.json(computeCurriculumState(path, progress, capstoneStatus)); })); // ── Assessments ────────────────────────────────────────────────────────────────────────── app.get('/api/assessments', studentSession, wrap(async (req, res) => { const pool = getPool(); const cohortId = await studentCohortId(pool, req.studentId); const { rows } = await pool.query( `SELECT d.assessment_definition_id, d.type, d.title, d.description, d.duration_minutes, d.hint_policy FROM assessment_definitions d JOIN assessment_cohort_assignments a ON a.assessment_definition_id = d.assessment_definition_id WHERE a.cohort_id = $1 AND d.status = 'active' AND d.type = 'exam'`, [cohortId || '__none__'] ); const { rows: sessions } = await pool.query( `SELECT assessment_definition_id, status, result, started_at, submitted_at, assessment_session_id FROM assessment_sessions WHERE student_id = $1 ORDER BY created_at DESC`, [req.studentId] ); res.json(rows.map((d) => ({ ...d, sessions: sessions.filter((s) => s.assessment_definition_id === d.assessment_definition_id), }))); })); app.get('/api/assessments/active', studentSession, wrap(async (req, res) => { const pool = getPool(); const session = await assessmentsLib.getActiveSessionForStudent(pool, req.studentId); if (!session || session.status !== 'in_progress') return res.json({ active: false }); const scenarios = await assessmentsLib.getSessionScenarios(pool, session.assessment_session_id); res.json({ active: true, assessment_session_id: session.assessment_session_id, type: session.type, title: session.title, hint_policy: session.hint_policy, started_at: session.started_at, expires_at: session.expires_at, scenarios: scenarios.map((s) => ({ scenario_id: s.scenario_id, position: s.position, status: s.status })), }); })); app.get('/api/assessments/:sessionId', studentSession, wrap(async (req, res) => { const pool = getPool(); const { rows } = await pool.query( `SELECT * FROM assessment_sessions WHERE assessment_session_id = $1 AND student_id = $2`, [req.params.sessionId, req.studentId] ); if (!rows[0]) return res.status(404).json({ error: 'not_found' }); const session = await assessmentsLib.lazyExpire(pool, rows[0]); const scenarios = await assessmentsLib.getSessionScenarios(pool, session.assessment_session_id); res.json({ ...session, scenarios }); })); app.post('/api/assessments/:definitionId/start', studentSession, wrap(async (req, res) => { const pool = getPool(); const { rows } = await pool.query( `SELECT * FROM assessment_definitions WHERE assessment_definition_id = $1 AND status = 'active'`, [req.params.definitionId] ); const definition = rows[0]; if (!definition) return res.status(404).json({ error: 'not_found' }); // Phase 6B §10 — a known Phase 3B gap, documented in docs/PHASE6A_ROOT_COURSE_FOUNDATION.md // §10: this route only ever checked the definition existed/was active. GET /api/capstone // computes and displays lock state correctly and the UI never exposes a "start" action while // locked, but student UI state is not the enforcement boundary — a direct POST here could // start a capstone session before its prerequisite stages were actually complete. Fixed by // binding to the SAME gate /api/capstone already derives from the curriculum path's own // capstone stage (computeCurriculumState), rather than re-parsing definition.unlock_rule // independently — one computation of "is the capstone unlocked," not two that could drift. // Only a definition the path actually names as ITS capstone is gated this way; an ordinary // exam (no capstone stage names it) is unaffected — see e2e-phase6b.js for both cases. const path = curriculum.loadCurriculumPath(); const capstoneStage = path?.stages.find((s) => s.type === 'capstone' && s.capstone_assessment_id === definition.assessment_definition_id); if (capstoneStage) { const progress = await loadProgress(pool, req.studentId); const capstoneAchievement = await getCapstoneAchievement(pool, req.studentId); const state = computeCurriculumState(path, progress, capstoneAchievement ? 'completed' : undefined); if (state.capstone?.status === 'locked') { return res.status(403).json({ error: 'locked', message: 'Complete the required stages before starting this assessment.', blocking_stage_ids: state.capstone.blocking_stage_ids, }); } } const cohortId = await studentCohortId(pool, req.studentId); try { const session = await assessmentsLib.startSession(pool, { definition, studentId: req.studentId, cohortId }); const scenarios = await assessmentsLib.getSessionScenarios(pool, session.assessment_session_id); res.status(201).json({ assessment_session_id: session.assessment_session_id, type: session.type, title: session.title, started_at: session.started_at, expires_at: session.expires_at, hint_policy: session.hint_policy, scenarios: scenarios.map((s) => ({ scenario_id: s.scenario_id, position: s.position, status: s.status })), }); } catch (e) { if (e.code === 'ALREADY_ACTIVE') return res.status(409).json({ error: 'already_active', message: e.message }); throw e; } })); app.post('/api/assessments/:sessionId/submit', studentSession, wrap(async (req, res) => { const pool = getPool(); const { rows } = await pool.query( `SELECT * FROM assessment_sessions WHERE assessment_session_id = $1 AND student_id = $2`, [req.params.sessionId, req.studentId] ); if (!rows[0]) return res.status(404).json({ error: 'not_found' }); if (rows[0].status !== 'in_progress') return res.status(409).json({ error: 'not_in_progress' }); const session = await assessmentsLib.submitSession(pool, req.params.sessionId); await achievementsLib.recordAssessmentAchievement(pool, session); res.json(session); })); // ── Capstone (a thin, student-friendly view over curriculum state + the capstone definition) ─ app.get('/api/capstone', studentSession, wrap(async (req, res) => { const pool = getPool(); const path = curriculum.loadCurriculumPath(); const progress = await loadProgress(pool, req.studentId); const capstoneAchievement = await getCapstoneAchievement(pool, req.studentId); const state = computeCurriculumState(path, progress, capstoneAchievement ? 'completed' : undefined); if (!state.capstone) return res.status(404).json({ error: 'no_capstone_configured' }); const { rows } = await pool.query( `SELECT assessment_definition_id, title, description, duration_minutes, hint_policy FROM assessment_definitions WHERE assessment_definition_id = $1`, [state.capstone.assessment_definition_id] ); const { rows: sessionRows } = await pool.query( `SELECT assessment_session_id, status, started_at, expires_at, submitted_at, result FROM assessment_sessions WHERE student_id = $1 AND assessment_definition_id = $2 ORDER BY created_at DESC`, [req.studentId, state.capstone.assessment_definition_id] ); res.json({ ...state.capstone, definition: rows[0] || null, sessions: sessionRows }); })); // ── Achievements ───────────────────────────────────────────────────────────────────────── app.get('/api/achievements', studentSession, wrap(async (req, res) => { res.json(await achievementsLib.listAchievements(getPool(), req.studentId)); })); // ── Recent attempts (for the student's own Practice view) ────────────────────────────────── app.get('/api/attempts/recent', studentSession, wrap(async (req, res) => { const { rows } = await getPool().query( `SELECT scenario_id, status, result, validation_attempt_count, validation_pass_count, validation_fail_count, hint_count, duration_seconds, started_at, completed_at FROM scenario_attempts WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 20`, [req.studentId] ); res.json(rows); })); };