Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 108 additions & 2 deletions contracts/progress-tracker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ pub mod types;
use chainlearn_shared::ContractMetadata;
use soroban_sdk::{contract, contracterror, contractimpl, symbol_short, Address, Env, Symbol, Vec};
pub use types::{
Course, LearnerStats, ProgressExport, ProgressInfo, ProgressTrackerDataKey, QuizResult,
VersionedContractMetadata,
Achievement, AchievementType, Course, LearnerStats, ProgressExport, ProgressInfo,
ProgressTrackerDataKey, QuizResult, VersionedContractMetadata,
};

/// Sentinel meaning "no content hash set"; enrollment skips verification (#235).
Expand Down Expand Up @@ -510,6 +510,25 @@ impl ProgressTracker {
(Symbol::new(env, "credential_eligible"),),
(learner, course_id),
);

// Award FirstCourse achievement when learner becomes eligible for credential
Self::earn_achievement(
env,
learner,
AchievementType::FirstCourse,
Some(course_id.clone()),
);

// Check for CourseMaster achievement (5 courses completed)
let stats = Self::get_learner_stats_internal(env, learner);
if stats.courses_completed >= 5 {
Self::earn_achievement(
env,
learner,
AchievementType::CourseMaster,
None,
);
}
}
}

Expand Down Expand Up @@ -1450,6 +1469,93 @@ impl ProgressTracker {
}
}

/// Earn an achievement for a learner.
///
/// Internal function that checks if an achievement has already been earned
/// before awarding it. Achievements are stored per learner and can be
/// queried via `get_achievements`.
///
/// # Arguments
/// * `env` - The Soroban environment
/// * `learner` - The learner address
/// * `achievement_type` - Type of achievement to award
/// * `course_id` - Optional course ID associated with the achievement
fn earn_achievement(
env: &Env,
learner: &Address,
achievement_type: AchievementType,
course_id: Option<Symbol>,
) {
// Check if achievement already earned
let achievement_key = ProgressTrackerDataKey::AchievementEarned(
learner.clone(),
achievement_type.clone(),
);
if env.storage().persistent().has(&achievement_key) {
return; // Already earned, skip
}

let timestamp = env.ledger().timestamp();
let achievement = Achievement {
achievement_type: achievement_type.clone(),
earned_at: timestamp,
course_id: course_id.clone(),
};

// Store that this achievement type has been earned
env.storage()
.persistent()
.set(&achievement_key, &true);

// Add to learner's achievements list
let achievements_key = ProgressTrackerDataKey::Achievements(learner.clone());
let mut achievements: Vec<Achievement> = env
.storage()
.persistent()
.get(&achievements_key)
.unwrap_or_else(|| Vec::new(env));
achievements.push_back(achievement);
env.storage()
.persistent()
.set(&achievements_key, &achievements);

// Emit achievement earned event
env.events().publish(
(Symbol::new(env, "achievement_earned"),),
(learner, &achievement_type, course_id, timestamp),
);
}

/// Get all achievements earned by a learner.
///
/// Returns achievements in chronological order (oldest first).
///
/// # Arguments
/// * `learner` - The learner address
///
/// # Returns
/// List of achievements earned by the learner.
pub fn get_achievements(env: Env, learner: Address) -> Vec<Achievement> {
env.storage()
.persistent()
.get(&ProgressTrackerDataKey::Achievements(learner))
.unwrap_or_else(|| Vec::new(&env))
}

/// Check whether a learner has earned a specific achievement type.
///
/// # Arguments
/// * `learner` - The learner address
/// * `achievement_type` - Type of achievement to check
///
/// # Returns
/// `true` if the learner has earned the achievement.
pub fn has_achievement(env: Env, learner: Address, achievement_type: AchievementType) -> bool {
env.storage()
.persistent()
.has(&ProgressTrackerDataKey::AchievementEarned(learner, achievement_type))
}

/// Check whether a course has been registered via `create_course` (#108).
///
/// A cheap existence check -- unlike `get_course`, it never deserializes
Expand Down
32 changes: 32 additions & 0 deletions contracts/progress-tracker/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,34 @@ pub struct VersionedContractMetadata {
pub version: u32,
}

/// Represents an achievement earned by a learner.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Achievement {
/// Type of achievement earned.
pub achievement_type: AchievementType,
/// Timestamp when the achievement was earned.
pub earned_at: u64,
/// Optional course ID associated with the achievement (for course-specific achievements).
pub course_id: Option<Symbol>,
}

/// Types of achievements that learners can earn.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AchievementType {
/// First course completed (credential earned).
FirstCourse,
/// Perfect score (100%) on any quiz.
PerfectScore,
/// Completed a course in less than the average time (speed learner).
SpeedLearner,
/// Completed 5 courses.
CourseMaster,
/// Achieved 100% completion on a course (all modules, perfect quiz scores).
PerfectCourse,
}

/// Storage keys for the progress tracker contract.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
Expand All @@ -161,4 +189,8 @@ pub enum ProgressTrackerDataKey {
/// The address a learner has delegated progress-tracking to, if any
/// (#222). Absent when the learner has no active delegation.
DelegatedTo(Address),
/// Achievements earned by a learner.
Achievements(Address),
/// Achievement earned by a specific learner and achievement type (for deduplication).
AchievementEarned(Address, AchievementType),
}