scenario_attempts.status = 'abandoned' (a valid value since Phase 3B, never previously set by anything) now gets set on: environment destroy/reset, logout, session expiry (piggybacked on the existing hourly session sweeper - no new background job), and switching to a different scenario while one is still open. Never an academic failure marker - result stays NULL, history is preserved. Call-site wiring in server.js/admin-routes.js lands in the next commit alongside other platform hardening in those same files.
142 lines
7.3 KiB
JavaScript
142 lines
7.3 KiB
JavaScript
// Attempt lifecycle — the Phase 3B core. One scenario_attempts row per continuous working
|
|
// session on a scenario (not per validation click, see migrations/012). Every mutation here
|
|
// updates that one row in place; nothing is ever inserted twice for the same open attempt.
|
|
//
|
|
// Phase 4.1 (docs/PHASE4_1_PREPRODUCTION_HARDENING.md §13) added real abandonment handling —
|
|
// see abandonAttempt/abandonOpenAttemptsFor* below and their call sites (environment
|
|
// destroy/reset in server.js and admin-routes.js, logout in server.js, the session sweeper in
|
|
// sessions.js, and the "different scenario" transition inline in getOrCreateOpenAttempt just
|
|
// below). Still deliberately NOT a new standalone background job — every trigger either rides
|
|
// an existing request handler or the session sweeper that already runs periodically.
|
|
|
|
// An attempt is never silently deleted or marked as a failure by abandonment — result stays
|
|
// NULL, the row stays queryable, instructors see "abandoned (reason)" as a real, honest status
|
|
// rather than a punitive one. No-op (and does not overwrite an existing abandoned_reason) if
|
|
// the attempt is already terminal.
|
|
async function abandonAttempt(pool, attemptId, reason) {
|
|
const { rows } = await pool.query(
|
|
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = $2
|
|
WHERE attempt_id = $1 AND status = 'in_progress' RETURNING *`,
|
|
[attemptId, reason]
|
|
);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
// Trigger: environment destroyed (idle reaper, student reset, admin reset/destroy — see
|
|
// server.js's student reset route and admin-routes.js's reset/destroy routes, all of which
|
|
// call this right after telling the Provisioner to tear the environment down). A reset creates
|
|
// a fresh environment_id in some flows and reuses the same one in others (see
|
|
// provisioner lifecycle.js's reset — same environment_id, fresh container) — either way, any
|
|
// attempt still open against the OLD container state is honestly "not going to be resumed as
|
|
// the same working session," so it's abandoned rather than left open indefinitely.
|
|
async function abandonOpenAttemptsForEnvironment(pool, environmentId) {
|
|
const { rows } = await pool.query(
|
|
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'environment_destroyed'
|
|
WHERE environment_id = $1 AND status = 'in_progress' RETURNING attempt_id`,
|
|
[environmentId]
|
|
);
|
|
return rows.length;
|
|
}
|
|
|
|
// Trigger: logout (server.js POST /api/logout) and the session sweeper's periodic pass for
|
|
// students whose sessions have all expired (sessions.js). Deliberately student-scoped, not
|
|
// environment-scoped — a student can log out while their Lab keeps running (it isn't
|
|
// destroyed until reaped or explicitly ended), but the WORKING SESSION on whatever scenario
|
|
// they had open is over the moment they're no longer authenticated to record more progress on it.
|
|
async function abandonOpenAttemptsForStudent(pool, studentId) {
|
|
const { rows } = await pool.query(
|
|
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'session_ended'
|
|
WHERE student_id = $1 AND status = 'in_progress' RETURNING attempt_id`,
|
|
[studentId]
|
|
);
|
|
return rows.length;
|
|
}
|
|
|
|
async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environmentId, cohortId, curriculumStage, assessmentSessionId }) {
|
|
const { rows: existing } = await pool.query(
|
|
`SELECT * FROM scenario_attempts
|
|
WHERE student_id = $1 AND scenario_id = $2 AND environment_id = $3 AND status = 'in_progress'
|
|
ORDER BY started_at DESC LIMIT 1`,
|
|
[studentId, scenarioId, environmentId]
|
|
);
|
|
if (existing[0]) return existing[0];
|
|
|
|
// Trigger: student starts a different scenario while a previous one is still open (started
|
|
// via /start or implicitly via /validate, never finished). Only one scenario is "the one
|
|
// currently being worked on" per student+environment at a time from the app's own UX, so an
|
|
// open attempt on ANY OTHER scenario in this same environment is abandoned before the new
|
|
// one is created — never silently left as a phantom "still in progress" row.
|
|
await pool.query(
|
|
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'scenario_switched'
|
|
WHERE student_id = $1 AND environment_id = $2 AND scenario_id != $3 AND status = 'in_progress'`,
|
|
[studentId, environmentId, scenarioId]
|
|
);
|
|
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO scenario_attempts
|
|
(student_id, scenario_id, environment_id, cohort_id, curriculum_stage, started_at, status, assessment_session_id)
|
|
VALUES ($1, $2, $3, $4, $5, now(), 'in_progress', $6)
|
|
RETURNING *`,
|
|
[studentId, scenarioId, environmentId, cohortId || null, curriculumStage || null, assessmentSessionId || null]
|
|
);
|
|
return rows[0];
|
|
}
|
|
|
|
// Records one validation outcome against the currently open attempt (creating one first if
|
|
// none exists — a student can hit Validate without an explicit "start" call ever having fired,
|
|
// e.g. if the frontend missed it; this stays correct either way). Closes the attempt on a pass.
|
|
async function recordValidation(pool, ctx, passed) {
|
|
const attempt = await getOrCreateOpenAttempt(pool, ctx);
|
|
const now = new Date();
|
|
if (passed) {
|
|
const durationSeconds = attempt.started_at ? Math.max(0, Math.round((now - new Date(attempt.started_at)) / 1000)) : null;
|
|
const { rows } = await pool.query(
|
|
`UPDATE scenario_attempts SET
|
|
validation_attempt_count = validation_attempt_count + 1,
|
|
validation_pass_count = validation_pass_count + 1,
|
|
status = 'completed', result = 'passed', completed_at = now(), duration_seconds = $2
|
|
WHERE attempt_id = $1 RETURNING *`,
|
|
[attempt.attempt_id, durationSeconds]
|
|
);
|
|
return rows[0];
|
|
}
|
|
const { rows } = await pool.query(
|
|
`UPDATE scenario_attempts SET
|
|
validation_attempt_count = validation_attempt_count + 1,
|
|
validation_fail_count = validation_fail_count + 1
|
|
WHERE attempt_id = $1 RETURNING *`,
|
|
[attempt.attempt_id]
|
|
);
|
|
return rows[0];
|
|
}
|
|
|
|
// Reveals the next hint, sequentially — level N can only be revealed once level N-1 has been.
|
|
// Returns null if the requested reveal is out of order (caller treats that as a 409/400) or if
|
|
// there are no more hints left.
|
|
async function recordHintReveal(pool, ctx, totalHints) {
|
|
const attempt = await getOrCreateOpenAttempt(pool, ctx);
|
|
const nextLevel = attempt.highest_hint_level + 1;
|
|
if (nextLevel > totalHints) return { ok: false, reason: 'no_more_hints', attempt };
|
|
const { rows } = await pool.query(
|
|
`UPDATE scenario_attempts SET hint_count = hint_count + 1, highest_hint_level = $2
|
|
WHERE attempt_id = $1 RETURNING *`,
|
|
[attempt.attempt_id, nextLevel]
|
|
);
|
|
return { ok: true, level: nextLevel, attempt: rows[0] };
|
|
}
|
|
|
|
async function getAttemptSummary(pool, { studentId, scenarioId, environmentId }) {
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM scenario_attempts
|
|
WHERE student_id = $1 AND scenario_id = $2 AND ($3::uuid IS NULL OR environment_id = $3)
|
|
ORDER BY started_at DESC LIMIT 1`,
|
|
[studentId, scenarioId, environmentId || null]
|
|
);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
module.exports = {
|
|
getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary,
|
|
abandonAttempt, abandonOpenAttemptsForEnvironment, abandonOpenAttemptsForStudent,
|
|
};
|