From 0beb0e8f5eaf3b27242b59ed26bf59d13120dde6 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 18 Aug 2026 14:35:11 -0500 Subject: [PATCH] feat(learning): add persistent achievement records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- platform/api/migrations/014_achievements.sql | 19 +++++++ platform/api/src/achievements.js | 60 ++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 platform/api/migrations/014_achievements.sql create mode 100644 platform/api/src/achievements.js diff --git a/platform/api/migrations/014_achievements.sql b/platform/api/migrations/014_achievements.sql new file mode 100644 index 0000000..eeff9be --- /dev/null +++ b/platform/api/migrations/014_achievements.sql @@ -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); diff --git a/platform/api/src/achievements.js b/platform/api/src/achievements.js new file mode 100644 index 0000000..1ddb3c9 --- /dev/null +++ b/platform/api/src/achievements.js @@ -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 };