// Persistent completion records. recordAchievement is idempotent (a WHERE NOT EXISTS guard, // not a unique-index/ON CONFLICT dance — simple and correct at this write volume) so calling it // again for something already recorded is a safe no-op, not a duplicate row. async function recordAchievement(pool, { studentId, type, curriculumPathId, curriculumVersion, stageId, assessmentSessionId, metadata }) { const { rows } = await pool.query( `INSERT INTO achievements (student_id, achievement_type, curriculum_path_id, curriculum_version, stage_id, assessment_session_id, metadata) SELECT $1, $2, $3, $4, $5, $6, $7 WHERE NOT EXISTS ( SELECT 1 FROM achievements WHERE student_id = $1 AND achievement_type = $2 AND stage_id IS NOT DISTINCT FROM $5 AND assessment_session_id IS NOT DISTINCT FROM $6 ) RETURNING *`, [studentId, type, curriculumPathId || null, curriculumVersion || null, stageId || null, assessmentSessionId || null, metadata ? JSON.stringify(metadata) : null] ); return rows[0] || null; // null means it already existed — not an error } // Called after a progress update — checks whether this newly-completed scenario just finished // a stage (and, transitively, the whole curriculum), recording achievements as needed. Cheap: // only runs the curriculum-state computation the caller already has on hand. async function checkStageAndCurriculumAchievements(pool, { studentId, path, curriculumState }) { for (const stage of curriculumState.stages) { if (stage.complete) { await recordAchievement(pool, { studentId, type: 'stage_completed', curriculumPathId: path.id, curriculumVersion: path.version, stageId: stage.id, }); } } const allContentStagesComplete = curriculumState.stages.every((s) => s.complete); if (allContentStagesComplete) { await recordAchievement(pool, { studentId, type: 'curriculum_completed', curriculumPathId: path.id, curriculumVersion: path.version, }); } } // Called when assessments.recordScenarioOutcome/submitSession finalizes a session — records // 'capstone_completed' or 'assessment_completed' depending on the session's type. Only fires on // an actual pass (defensive check — the only completion_rule implemented today is // all_scenarios_passed, which already guarantees this, but a future threshold-based rule // shouldn't silently start granting achievements for a partial result). async function recordAssessmentAchievement(pool, session) { if (!session || !session.result?.passed) return null; const type = session.type === 'capstone' ? 'capstone_completed' : 'assessment_completed'; return recordAchievement(pool, { studentId: session.student_id, type, assessmentSessionId: session.assessment_session_id, metadata: { assessment_definition_id: session.assessment_definition_id, title: session.title }, }); } async function listAchievements(pool, studentId) { const { rows } = await pool.query(`SELECT * FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`, [studentId]); return rows; } module.exports = { recordAchievement, checkStageAndCurriculumAchievements, recordAssessmentAchievement, listAchievements };