feat(learning): add persistent achievement records

New achievements table: stage_completed/curriculum_completed/assessment_completed/
capstone_completed, idempotent (WHERE NOT EXISTS guard, not a unique-index dance — simple and
correct at this write volume). Not a certificate — a durable internal record Phase 4/5 can
render one from later. recordAssessmentAchievement only fires on an actual pass, defensively,
even though today's only completion_rule already guarantees that.
This commit is contained in:
2026-08-18 14:35:11 -05:00
parent b908ade8e0
commit 0beb0e8f5e
2 changed files with 79 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
// 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 };