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:
@@ -0,0 +1,19 @@
|
||||
-- Persistent completion records — stage/curriculum/assessment/capstone. Not a certificate
|
||||
-- (Phase 4/5 territory, see docs/PHASE3B_LEARNING_AND_ASSESSMENT.md): just the durable fact
|
||||
-- that something was achieved, with enough context to render a certificate from later without
|
||||
-- re-deriving it from raw attempt history.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
achievement_id BIGSERIAL PRIMARY KEY,
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
achievement_type TEXT NOT NULL CHECK (achievement_type IN
|
||||
('stage_completed', 'curriculum_completed', 'assessment_completed', 'capstone_completed')),
|
||||
curriculum_path_id TEXT,
|
||||
curriculum_version INTEGER,
|
||||
stage_id TEXT,
|
||||
assessment_session_id UUID REFERENCES assessment_sessions(assessment_session_id),
|
||||
achieved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_achievements_student ON achievements(student_id, achievement_type);
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user