feat(hints): wire student-facing learning API and progressive hint reveal
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.
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user