diff --git a/platform/api/src/learning-routes.js b/platform/api/src/learning-routes.js new file mode 100644 index 0000000..aae3b1b --- /dev/null +++ b/platform/api/src/learning-routes.js @@ -0,0 +1,222 @@ +// 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' }); + 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); + })); +}; diff --git a/platform/api/src/server.js b/platform/api/src/server.js index 4a6e6ec..0db3766 100644 --- a/platform/api/src/server.js +++ b/platform/api/src/server.js @@ -20,6 +20,10 @@ const { parseCookies, checkCsrf, makeCsrfToken } = require('./security'); const { buildCookie, clearCookie } = require('./cookies'); const createRbac = require('./rbac'); const { INSTRUCTOR_COOKIE, CSRF_COOKIE: INSTRUCTOR_CSRF_COOKIE } = require('./instructor-auth-routes'); +const attemptsLib = require('./attempts'); +const assessments = require('./assessments'); +const achievements = require('./achievements'); +const { computeCurriculumState } = require('./curriculum-rules'); const PORT = Number(process.env.PLATFORM_API_PORT) || 4300; const STUDENT_COOKIE = 'rn_session'; @@ -167,24 +171,52 @@ app.get('/api/progress', wrap(studentSession), async (req, res) => { }); app.post('/api/progress/record', wrap(studentSession), async (req, res) => { - const { scenario_id, status, environment_id, validation_count } = req.body || {}; + const { scenario_id, status, environment_id } = req.body || {}; if (!scenario_id || !['completed', 'in_progress'].includes(status)) { return res.status(400).json({ error: 'scenario_id and a valid status are required' }); } const pool = getPool(); + const passed = status === 'completed'; + + // Attempt telemetry (Phase 3B) — one row per continuous working session, updated in place. + const curriculumPath = curriculum.loadCurriculumPath(); + const stage = curriculumPath?.stages.find((s) => (s.scenario_ids || []).includes(scenario_id))?.id || null; + const { rows: enrolledRows } = await pool.query( + `SELECT cohort_id FROM cohort_enrollments WHERE student_id = $1 AND enrollment_status = 'enrolled' ORDER BY enrolled_at LIMIT 1`, + [req.studentId] + ); + const cohortId = enrolledRows[0]?.cohort_id || null; + const assessmentCtx = await assessments.findActiveAssessmentContextForScenario(pool, req.studentId, scenario_id); + const attempt = await attemptsLib.recordValidation(pool, { + studentId: req.studentId, scenarioId: scenario_id, environmentId: environment_id || null, + cohortId, curriculumStage: stage, assessmentSessionId: assessmentCtx?.session.assessment_session_id, + }, passed); + + // Legacy aggregate progress row (Phase 1/2A/2B) — kept for the existing curriculum/dashboard + // views that read it; attempt-level detail above is the new source of truth for telemetry. const progress = await loadProgress(pool, req.studentId); const prev = progress[scenario_id] || { attempts: 0 }; const now = new Date().toISOString(); await saveOneProgress(pool, req.studentId, scenario_id, { ...prev, status, attempts: (prev.attempts || 0) + 1, last_validated: now, - completed_at: status === 'completed' ? now : prev.completed_at, + completed_at: passed ? now : prev.completed_at, }); - await pool.query( - `INSERT INTO scenario_attempts (student_id, scenario_id, environment_id, completed_at, result, validation_count) - VALUES ($1, $2, $3, now(), $4, $5)`, - [req.studentId, scenario_id, environment_id || null, status === 'completed' ? 'passed' : 'failed', validation_count ?? null] - ); await touchStudent(pool, req.studentId); + + if (passed) { + if (assessmentCtx) { + const finalizedSession = await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, true); + await achievements.recordAssessmentAchievement(pool, finalizedSession); + } + if (curriculumPath) { + const freshProgress = await loadProgress(pool, req.studentId); + const state = computeCurriculumState(curriculumPath, freshProgress); + await achievements.checkStageAndCurriculumAchievements(pool, { studentId: req.studentId, path: curriculumPath, curriculumState: state }); + } + } else if (assessmentCtx) { + await assessments.recordScenarioOutcome(pool, assessmentCtx.session.assessment_session_id, scenario_id, attempt.attempt_id, false); + } + res.json({ ok: true }); }); @@ -249,10 +281,16 @@ app.get('/api/environment/by-token/:token', async (req, res) => { require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) }); +require('./learning-routes')(app, { studentSession, getPool, curriculum, loadProgress, wrap }); + +const rbac = createRbac(getPool()); require('./admin-routes')(app, { - instructorSession: wrap(instructorSession), rbac: createRbac(getPool()), provisioner, curriculum, + instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, saveOneProgress, recordAudit, }); +require('./admin-learning-routes')(app, { + instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit, +}); // Fallback to the platform-shell SPA for any unmatched non-API route app.get('*', (req, res) => { diff --git a/platform/gateway/src/server.js b/platform/gateway/src/server.js index 2276f49..86dc6db 100644 --- a/platform/gateway/src/server.js +++ b/platform/gateway/src/server.js @@ -166,6 +166,66 @@ app.post('/lab/:token/api/scenarios/:scenarioId/answer', express.json(), async ( }); }); +// ── Phase 3B learning relay — /start and /hint are pure Platform API operations, unlike +// validate/answer above (those call the Lab first, then relay the outcome). The Lab has no +// concept of attempts, hints-as-tracked-state, or assessments; these forward straight through +// to Platform API with the browser's own session + CSRF token (same forwarding requirement +// documented on relayScenarioOutcome above — both cookie AND header, not just the header). ──── +async function relayToPlatformApi(req, res, platformPath, extraBody) { + const cookies = parseCookies(req); + const token = cookies[STUDENT_COOKIE]; + const csrf = cookies[STUDENT_CSRF_COOKIE] || ''; + try { + const platformRes = await fetch(`${PLATFORM_API_URL}${platformPath}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `${STUDENT_COOKIE}=${token}; ${STUDENT_CSRF_COOKIE}=${csrf}`, + 'X-CSRF-Token': csrf, + }, + body: JSON.stringify({ ...(req.body || {}), ...extraBody }), + }); + const body = await platformRes.json().catch(() => ({})); + res.status(platformRes.status).json(body); + } catch (e) { + res.status(502).json({ error: 'platform_unreachable' }); + } +} + +app.post('/lab/:token/api/scenarios/:scenarioId/start', express.json(), async (req, res) => { + const env = await resolveByToken(req.params.token); + const own = await checkOwnership(req, env); + if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.'); + await relayToPlatformApi(req, res, `/api/scenarios/${req.params.scenarioId}/start`, { environment_id: env.environment_id }); +}); + +app.post('/lab/:token/api/scenarios/:scenarioId/hint', express.json(), async (req, res) => { + const env = await resolveByToken(req.params.token); + const own = await checkOwnership(req, env); + if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.'); + await relayToPlatformApi(req, res, `/api/scenarios/${req.params.scenarioId}/hint`, { environment_id: env.environment_id }); +}); + +// Strips `hints` from the Lab's own scenario-detail response before it reaches the browser — +// the Lab backend still returns the full array (it has no student/attempt concept and was +// never modified), but hints must only be revealed progressively through the endpoint above. +// The Lab image itself stays byte-for-byte unmodified; this is a response rewrite at the +// Gateway, not a Lab change. +app.get('/lab/:token/api/scenarios/:scenarioId', async (req, res) => { + const env = await resolveByToken(req.params.token); + const own = await checkOwnership(req, env); + if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.'); + if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.'); + try { + const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}/api/scenarios/${req.params.scenarioId}`); + const body = await labRes.json().catch(() => ({})); + const { hints, ...withoutHints } = body; + res.status(labRes.status).json({ ...withoutHints, hint_count: Array.isArray(hints) ? hints.length : 0 }); + } catch (e) { + res.status(502).json({ error: 'lab_unreachable' }); + } +}); + // ── /lab/:token/* — HTTP proxy into the student's own Lab, prefix-stripped ───────────────── app.use('/lab/:token', async (req, res, next) => { const { token } = req.params;