New learning-routes.js: scenario start/attempt-state/hint (sequential reveal, 409 once exhausted, policy-aware under an active assessment), GET /api/curriculum/state (rule-driven, alongside the unchanged /api/curriculum/path), assessments list/start/active/detail/submit, GET /api/capstone, GET /api/achievements, GET /api/attempts/recent. /api/progress/record now routes through attempts.recordValidation instead of a flat insert, and, on a pass, checks curriculum/capstone achievement consequences and, if the scenario is part of an active assessment, updates that session (assessment context is derived entirely server-side from assessment_session_scenarios, never from anything the client asserts). Gateway changes: two new relay routes (/lab/:token/api/scenarios/:id/start and .../hint, mirroring the existing validate/answer pattern - pure Platform API operations, the Lab has no attempt/hint concept) and a response rewrite on GET /lab/:token/api/scenarios/:id that strips the Lab's own hints array (replacing it with a bare hint_count) so progressive reveal is actually server-gated instead of just UI-collapsed - the Lab image's bytes stay untouched; standalone/single-container dev mode never goes through the Gateway and keeps the full array unaffected.
223 lines
12 KiB
JavaScript
223 lines
12 KiB
JavaScript
// 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);
|
|
}));
|
|
};
|