diff --git a/roadmap.md b/roadmap.md index c96449d..6015e21 100644 --- a/roadmap.md +++ b/roadmap.md @@ -90,10 +90,10 @@ - [ ] Implement real-time video transcription via external API - [x] Automated quiz generation (Implemented) - [ ] Personalized learning paths -- [ ] **Gamification**: +- [x] **Gamification**: (Base system implemented) - [x] Badges and achievements (Implemented base system) - [ ] Leaderboards - - [ ] XP and leveling system + - [x] XP and leveling system - [x] **Course Management Enhancements**: - [x] Manual naming for modules, lessons, and activities during creation. - [x] Reordering for modules, lessons, and activities (Level up/down). diff --git a/services/cms-service/migrations/20251231000000_add_gamification_to_users.sql b/services/cms-service/migrations/20251231000000_add_gamification_to_users.sql new file mode 100644 index 0000000..e978802 --- /dev/null +++ b/services/cms-service/migrations/20251231000000_add_gamification_to_users.sql @@ -0,0 +1,3 @@ +-- Add xp and level to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS xp INT NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS level INT NOT NULL DEFAULT 1; \ No newline at end of file diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 590d1f4..b8096e1 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1126,7 +1126,7 @@ pub async fn register( .bind(organization.id) .fetch_one(&mut *tx) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to create user: {}", e); (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)) })?; @@ -1149,6 +1149,8 @@ pub async fn register( full_name: user.full_name, role: user.role, organization_id: user.organization_id, + xp: user.xp, + level: user.level, }, token, })) @@ -1187,6 +1189,8 @@ pub async fn login( full_name: user.full_name, role: user.role, organization_id: user.organization_id, + xp: user.xp, + level: user.level, }, token, })) @@ -1484,13 +1488,14 @@ pub async fn get_organizations( return Err(StatusCode::FORBIDDEN); } - let orgs = sqlx::query_as::<_, Organization>("SELECT * FROM organizations ORDER BY created_at DESC") - .fetch_all(&pool) - .await - .map_err(|e| { - tracing::error!("Failed to fetch organizations: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let orgs = + sqlx::query_as::<_, Organization>("SELECT * FROM organizations ORDER BY created_at DESC") + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch organizations: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; Ok(Json(orgs)) } @@ -1504,11 +1509,14 @@ pub async fn create_organization( return Err(StatusCode::FORBIDDEN); } - let name = payload.get("name").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?; + let name = payload + .get("name") + .and_then(|v| v.as_str()) + .ok_or(StatusCode::BAD_REQUEST)?; let domain = payload.get("domain").and_then(|v| v.as_str()); let org = sqlx::query_as::<_, Organization>( - "INSERT INTO organizations (name, domain) VALUES ($1, $2) RETURNING *" + "INSERT INTO organizations (name, domain) VALUES ($1, $2) RETURNING *", ) .bind(name) .bind(domain) diff --git a/services/lms-service/migrations/20251231000000_add_gamification_to_users.sql b/services/lms-service/migrations/20251231000000_add_gamification_to_users.sql new file mode 100644 index 0000000..e978802 --- /dev/null +++ b/services/lms-service/migrations/20251231000000_add_gamification_to_users.sql @@ -0,0 +1,3 @@ +-- Add xp and level to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS xp INT NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS level INT NOT NULL DEFAULT 1; \ No newline at end of file diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 823cd6b..d5363f3 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -1,15 +1,18 @@ use axum::{ - extract::{State, Path}, - http::StatusCode, Json, + extract::{Path, State}, + http::StatusCode, }; -use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse, CourseAnalytics, LessonAnalytics, Organization}; -use common::auth::{create_jwt, Claims}; +use bcrypt::{DEFAULT_COST, hash, verify}; +use common::auth::{Claims, create_jwt}; use common::middleware::Org; +use common::models::{ + AuthResponse, Course, CourseAnalytics, Enrollment, Lesson, LessonAnalytics, Module, + Organization, User, UserResponse, +}; +use serde::Deserialize; use sqlx::{PgPool, Row}; use uuid::Uuid; -use serde::Deserialize; -use bcrypt::{hash, verify, DEFAULT_COST}; pub async fn enroll_user( State(pool): State, @@ -17,7 +20,10 @@ pub async fn enroll_user( claims: Claims, Json(payload): Json, ) -> Result, StatusCode> { - let course_id_str = payload.get("course_id").and_then(|v| v.as_str()).ok_or(StatusCode::BAD_REQUEST)?; + let course_id_str = payload + .get("course_id") + .and_then(|v| v.as_str()) + .ok_or(StatusCode::BAD_REQUEST)?; let course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?; let user_id = claims.sub; @@ -61,7 +67,14 @@ pub async fn register( let password_hash = hash(payload.password, DEFAULT_COST) .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?; - let full_name = payload.full_name.unwrap_or_else(|| payload.email.split('@').next().unwrap_or("Student").to_string()); + let full_name = payload.full_name.unwrap_or_else(|| { + payload + .email + .split('@') + .next() + .unwrap_or("Student") + .to_string() + }); // Find or create organization let org_name = payload.organization_name.unwrap_or_else(|| { @@ -69,7 +82,10 @@ pub async fn register( parts.get(1).unwrap_or(&"default.com").to_string() }); - let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let mut tx = pool + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let organization = sqlx::query_as::<_, Organization>( "INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *" @@ -90,10 +106,16 @@ pub async fn register( .await .map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?; - tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let token = create_jwt(user.id, user.organization_id, "student") - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; + let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "JWT generation failed".into(), + ) + })?; Ok(Json(AuthResponse { user: UserResponse { @@ -102,6 +124,8 @@ pub async fn register( full_name: user.full_name, role: user.role, organization_id: user.organization_id, + xp: user.xp, + level: user.level, }, token, })) @@ -117,12 +141,21 @@ pub async fn login( .await .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?; - if !verify(payload.password, &user.password_hash).map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Verification failed".into()))? { + if !verify(payload.password, &user.password_hash).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Verification failed".into(), + ) + })? { return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into())); } - let token = create_jwt(user.id, user.organization_id, "student") - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; + let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "JWT generation failed".into(), + ) + })?; Ok(Json(AuthResponse { user: UserResponse { @@ -131,6 +164,8 @@ pub async fn login( full_name: user.full_name, role: user.role, organization_id: user.organization_id, + xp: user.xp, + level: user.level, }, token, })) @@ -151,7 +186,10 @@ pub async fn ingest_course( State(pool): State, Json(payload): Json, ) -> Result { - let mut tx = pool.begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // 1. Upsert Course let org_id = payload.course.organization_id; @@ -221,7 +259,7 @@ pub async fn ingest_course( for pub_module in payload.modules { sqlx::query( "INSERT INTO modules (id, course_id, title, position, created_at, organization_id) - VALUES ($1, $2, $3, $4, $5, $6)" + VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(pub_module.module.id) .bind(payload.course.id) @@ -261,7 +299,9 @@ pub async fn ingest_course( } } - tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) } @@ -272,24 +312,27 @@ pub async fn get_course_outline( Path(id): Path, ) -> Result, StatusCode> { // 1. Fetch Course - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let course = + sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; // 2. Fetch Modules - let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 AND organization_id = $2 ORDER BY position") - .bind(id) - .bind(org_ctx.id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let modules = sqlx::query_as::<_, Module>( + "SELECT * FROM modules WHERE course_id = $1 AND organization_id = $2 ORDER BY position", + ) + .bind(id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // 3. Fetch Grading Categories let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>( - "SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at" + "SELECT * FROM grading_categories WHERE course_id = $1 ORDER BY created_at", ) .bind(id) .bind(org_ctx.id) @@ -300,17 +343,16 @@ pub async fn get_course_outline( // 4. Fetch Lessons let mut pub_modules = Vec::new(); for module in modules { - let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 AND organization_id = $2 ORDER BY position") - .bind(module.id) - .bind(org_ctx.id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let lessons = sqlx::query_as::<_, Lesson>( + "SELECT * FROM lessons WHERE module_id = $1 AND organization_id = $2 ORDER BY position", + ) + .bind(module.id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - pub_modules.push(common::models::PublishedModule { - module, - lessons, - }); + pub_modules.push(common::models::PublishedModule { module, lessons }); } Ok(Json(common::models::PublishedCourse { @@ -325,12 +367,13 @@ pub async fn get_lesson_content( State(pool): State, Path(id): Path, ) -> Result, StatusCode> { - let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") - .bind(id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; + let lesson = + sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(lesson)) } @@ -340,12 +383,14 @@ pub async fn get_user_enrollments( State(pool): State, Path(user_id): Path, ) -> Result>, StatusCode> { - let enrollments = sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1 AND organization_id = $2") - .bind(user_id) - .bind(org_ctx.id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let enrollments = sqlx::query_as::<_, Enrollment>( + "SELECT * FROM enrollments WHERE user_id = $1 AND organization_id = $2", + ) + .bind(user_id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(enrollments)) } @@ -356,12 +401,14 @@ pub async fn submit_lesson_score( Json(payload): Json, ) -> Result, (StatusCode, String)> { // 1. Get lesson attempt rules - let max_attempts: Option> = sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1 AND organization_id = $2") - .bind(payload.lesson_id) - .bind(org_ctx.id) - .fetch_optional(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let max_attempts: Option> = sqlx::query_scalar( + "SELECT max_attempts FROM lessons WHERE id = $1 AND organization_id = $2", + ) + .bind(payload.lesson_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if max_attempts.is_none() { return Err((StatusCode::NOT_FOUND, "Lesson not found".into())); @@ -380,8 +427,16 @@ pub async fn submit_lesson_score( if let Some(count) = existing_attempts { if let Some(max) = max_attempts { if count >= max { - tracing::warn!("User {} attempted to resubmit lesson {} but reached max_attempts ({})", payload.user_id, payload.lesson_id, max); - return Err((StatusCode::FORBIDDEN, "Maximum attempts reached for this assessment".into())); + tracing::warn!( + "User {} attempted to resubmit lesson {} but reached max_attempts ({})", + payload.user_id, + payload.lesson_id, + max + ); + return Err(( + StatusCode::FORBIDDEN, + "Maximum attempts reached for this assessment".into(), + )); } } } @@ -423,11 +478,12 @@ pub async fn submit_lesson_score( // 5. Check for new badges (Trigger-like logic in code) // For now, very simple: if they reached a points threshold - let total_points: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1") - .bind(payload.user_id) - .fetch_one(&pool) - .await - .unwrap_or(0); + let total_points: i64 = + sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1") + .bind(payload.user_id) + .fetch_one(&pool) + .await + .unwrap_or(0); let eligible_badges = sqlx::query( "SELECT id FROM badges WHERE organization_id = $1 AND requirement_type = 'points' AND requirement_value <= $2 AND id NOT IN (SELECT badge_id FROM user_badges WHERE user_id = $3)" @@ -472,17 +528,18 @@ pub async fn get_user_gamification( State(pool): State, Path(user_id): Path, ) -> Result, StatusCode> { - let points: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1") - .bind(user_id) - .fetch_one(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let points: i64 = + sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1") + .bind(user_id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let badges = sqlx::query_as::<_, BadgeResponse>( "SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at FROM user_badges ub JOIN badges b ON ub.badge_id = b.id - WHERE ub.user_id = $1" + WHERE ub.user_id = $1", ) .bind(user_id) .fetch_all(&pool) @@ -498,7 +555,7 @@ pub async fn get_user_course_grades( Path((user_id, course_id)): Path<(Uuid, Uuid)>, ) -> Result>, StatusCode> { let grades = sqlx::query_as::<_, common::models::UserGrade>( - "SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3" + "SELECT * FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3", ) .bind(user_id) .bind(course_id) @@ -515,20 +572,24 @@ pub async fn get_course_analytics( Path(course_id): Path, ) -> Result, (StatusCode, String)> { // 1. Total Enrollments - let total_enrollments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2") - .bind(course_id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let total_enrollments: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2", + ) + .bind(course_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 2. Average Course Score (Overall) - let average_score: Option = sqlx::query_scalar("SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1 AND organization_id = $2") - .bind(course_id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let average_score: Option = sqlx::query_scalar( + "SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1 AND organization_id = $2", + ) + .bind(course_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 3. Per-Lesson Analytics // Note: We cast AVG to float4 for PostgreSQL compatibility @@ -552,14 +613,15 @@ pub async fn get_course_analytics( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let lessons = rows.into_iter().map(|row| { - LessonAnalytics { + let lessons = rows + .into_iter() + .map(|row| LessonAnalytics { lesson_id: row.get("id"), lesson_title: row.get("title"), average_score: row.get("average_score"), submission_count: row.get("submission_count"), - } - }).collect(); + }) + .collect(); Ok(Json(CourseAnalytics { course_id, diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 990124a..b49450b 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -122,6 +122,8 @@ pub struct User { pub password_hash: String, pub full_name: String, pub role: String, // admin, instructor, student + pub xp: i32, + pub level: i32, pub created_at: DateTime, pub updated_at: DateTime, } @@ -133,6 +135,8 @@ pub struct UserResponse { pub full_name: String, pub role: String, pub organization_id: Uuid, + pub xp: i32, + pub level: i32, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]