feat: add gamification features for users, including XP and leveling, with corresponding database schema and API response updates.

This commit is contained in:
2026-01-04 21:40:21 -03:00
parent 6326cad39d
commit a19da8de76
6 changed files with 181 additions and 101 deletions
+2 -2
View File
@@ -90,10 +90,10 @@
- [ ] Implement real-time video transcription via external API - [ ] Implement real-time video transcription via external API
- [x] Automated quiz generation (Implemented) - [x] Automated quiz generation (Implemented)
- [ ] Personalized learning paths - [ ] Personalized learning paths
- [ ] **Gamification**: - [x] **Gamification**: (Base system implemented)
- [x] Badges and achievements (Implemented base system) - [x] Badges and achievements (Implemented base system)
- [ ] Leaderboards - [ ] Leaderboards
- [ ] XP and leveling system - [x] XP and leveling system
- [x] **Course Management Enhancements**: - [x] **Course Management Enhancements**:
- [x] Manual naming for modules, lessons, and activities during creation. - [x] Manual naming for modules, lessons, and activities during creation.
- [x] Reordering for modules, lessons, and activities (Level up/down). - [x] Reordering for modules, lessons, and activities (Level up/down).
@@ -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;
+12 -4
View File
@@ -1126,7 +1126,7 @@ pub async fn register(
.bind(organization.id) .bind(organization.id)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|e| { .map_err(|e: sqlx::Error| {
tracing::error!("Failed to create user: {}", e); tracing::error!("Failed to create user: {}", e);
(StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)) (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e))
})?; })?;
@@ -1149,6 +1149,8 @@ pub async fn register(
full_name: user.full_name, full_name: user.full_name,
role: user.role, role: user.role,
organization_id: user.organization_id, organization_id: user.organization_id,
xp: user.xp,
level: user.level,
}, },
token, token,
})) }))
@@ -1187,6 +1189,8 @@ pub async fn login(
full_name: user.full_name, full_name: user.full_name,
role: user.role, role: user.role,
organization_id: user.organization_id, organization_id: user.organization_id,
xp: user.xp,
level: user.level,
}, },
token, token,
})) }))
@@ -1484,7 +1488,8 @@ pub async fn get_organizations(
return Err(StatusCode::FORBIDDEN); return Err(StatusCode::FORBIDDEN);
} }
let orgs = sqlx::query_as::<_, Organization>("SELECT * FROM organizations ORDER BY created_at DESC") let orgs =
sqlx::query_as::<_, Organization>("SELECT * FROM organizations ORDER BY created_at DESC")
.fetch_all(&pool) .fetch_all(&pool)
.await .await
.map_err(|e| { .map_err(|e| {
@@ -1504,11 +1509,14 @@ pub async fn create_organization(
return Err(StatusCode::FORBIDDEN); 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 domain = payload.get("domain").and_then(|v| v.as_str());
let org = sqlx::query_as::<_, Organization>( 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(name)
.bind(domain) .bind(domain)
@@ -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;
+103 -41
View File
@@ -1,15 +1,18 @@
use axum::{ use axum::{
extract::{State, Path},
http::StatusCode,
Json, Json,
extract::{Path, State},
http::StatusCode,
}; };
use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse, CourseAnalytics, LessonAnalytics, Organization}; use bcrypt::{DEFAULT_COST, hash, verify};
use common::auth::{create_jwt, Claims}; use common::auth::{Claims, create_jwt};
use common::middleware::Org; 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 sqlx::{PgPool, Row};
use uuid::Uuid; use uuid::Uuid;
use serde::Deserialize;
use bcrypt::{hash, verify, DEFAULT_COST};
pub async fn enroll_user( pub async fn enroll_user(
State(pool): State<PgPool>, State(pool): State<PgPool>,
@@ -17,7 +20,10 @@ pub async fn enroll_user(
claims: Claims, claims: Claims,
Json(payload): Json<serde_json::Value>, Json(payload): Json<serde_json::Value>,
) -> Result<Json<Enrollment>, StatusCode> { ) -> Result<Json<Enrollment>, 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 course_id = Uuid::parse_str(course_id_str).map_err(|_| StatusCode::BAD_REQUEST)?;
let user_id = claims.sub; let user_id = claims.sub;
@@ -61,7 +67,14 @@ pub async fn register(
let password_hash = hash(payload.password, DEFAULT_COST) let password_hash = hash(payload.password, DEFAULT_COST)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?; .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 // Find or create organization
let org_name = payload.organization_name.unwrap_or_else(|| { 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() 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>( let organization = sqlx::query_as::<_, Organization>(
"INSERT INTO organizations (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING *" "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 .await
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?; .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") let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| {
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; (
StatusCode::INTERNAL_SERVER_ERROR,
"JWT generation failed".into(),
)
})?;
Ok(Json(AuthResponse { Ok(Json(AuthResponse {
user: UserResponse { user: UserResponse {
@@ -102,6 +124,8 @@ pub async fn register(
full_name: user.full_name, full_name: user.full_name,
role: user.role, role: user.role,
organization_id: user.organization_id, organization_id: user.organization_id,
xp: user.xp,
level: user.level,
}, },
token, token,
})) }))
@@ -117,12 +141,21 @@ pub async fn login(
.await .await
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".into()))?; .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())); return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
} }
let token = create_jwt(user.id, user.organization_id, "student") let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| {
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?; (
StatusCode::INTERNAL_SERVER_ERROR,
"JWT generation failed".into(),
)
})?;
Ok(Json(AuthResponse { Ok(Json(AuthResponse {
user: UserResponse { user: UserResponse {
@@ -131,6 +164,8 @@ pub async fn login(
full_name: user.full_name, full_name: user.full_name,
role: user.role, role: user.role,
organization_id: user.organization_id, organization_id: user.organization_id,
xp: user.xp,
level: user.level,
}, },
token, token,
})) }))
@@ -151,7 +186,10 @@ pub async fn ingest_course(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(payload): Json<common::models::PublishedCourse>, Json(payload): Json<common::models::PublishedCourse>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
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 // 1. Upsert Course
let org_id = payload.course.organization_id; let org_id = payload.course.organization_id;
@@ -221,7 +259,7 @@ pub async fn ingest_course(
for pub_module in payload.modules { for pub_module in payload.modules {
sqlx::query( sqlx::query(
"INSERT INTO modules (id, course_id, title, position, created_at, organization_id) "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(pub_module.module.id)
.bind(payload.course.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) Ok(StatusCode::OK)
} }
@@ -272,7 +312,8 @@ pub async fn get_course_outline(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<common::models::PublishedCourse>, StatusCode> { ) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
// 1. Fetch Course // 1. Fetch Course
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") let course =
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
.bind(id) .bind(id)
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_one(&pool) .fetch_one(&pool)
@@ -280,7 +321,9 @@ pub async fn get_course_outline(
.map_err(|_| StatusCode::NOT_FOUND)?; .map_err(|_| StatusCode::NOT_FOUND)?;
// 2. Fetch Modules // 2. Fetch Modules
let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 AND organization_id = $2 ORDER BY position") let modules = sqlx::query_as::<_, Module>(
"SELECT * FROM modules WHERE course_id = $1 AND organization_id = $2 ORDER BY position",
)
.bind(id) .bind(id)
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_all(&pool) .fetch_all(&pool)
@@ -289,7 +332,7 @@ pub async fn get_course_outline(
// 3. Fetch Grading Categories // 3. Fetch Grading Categories
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>( 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(id)
.bind(org_ctx.id) .bind(org_ctx.id)
@@ -300,17 +343,16 @@ pub async fn get_course_outline(
// 4. Fetch Lessons // 4. Fetch Lessons
let mut pub_modules = Vec::new(); let mut pub_modules = Vec::new();
for module in modules { for module in modules {
let lessons = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 AND organization_id = $2 ORDER BY position") let lessons = sqlx::query_as::<_, Lesson>(
"SELECT * FROM lessons WHERE module_id = $1 AND organization_id = $2 ORDER BY position",
)
.bind(module.id) .bind(module.id)
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_all(&pool) .fetch_all(&pool)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
pub_modules.push(common::models::PublishedModule { pub_modules.push(common::models::PublishedModule { module, lessons });
module,
lessons,
});
} }
Ok(Json(common::models::PublishedCourse { Ok(Json(common::models::PublishedCourse {
@@ -325,7 +367,8 @@ pub async fn get_lesson_content(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, StatusCode> { ) -> Result<Json<Lesson>, StatusCode> {
let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") let lesson =
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(id) .bind(id)
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_one(&pool) .fetch_one(&pool)
@@ -340,7 +383,9 @@ pub async fn get_user_enrollments(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(user_id): Path<Uuid>, Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<Enrollment>>, StatusCode> { ) -> Result<Json<Vec<Enrollment>>, StatusCode> {
let enrollments = sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1 AND organization_id = $2") let enrollments = sqlx::query_as::<_, Enrollment>(
"SELECT * FROM enrollments WHERE user_id = $1 AND organization_id = $2",
)
.bind(user_id) .bind(user_id)
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_all(&pool) .fetch_all(&pool)
@@ -356,7 +401,9 @@ pub async fn submit_lesson_score(
Json(payload): Json<GradeSubmissionPayload>, Json(payload): Json<GradeSubmissionPayload>,
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> { ) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
// 1. Get lesson attempt rules // 1. Get lesson attempt rules
let max_attempts: Option<Option<i32>> = sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1 AND organization_id = $2") let max_attempts: Option<Option<i32>> = sqlx::query_scalar(
"SELECT max_attempts FROM lessons WHERE id = $1 AND organization_id = $2",
)
.bind(payload.lesson_id) .bind(payload.lesson_id)
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_optional(&pool) .fetch_optional(&pool)
@@ -380,8 +427,16 @@ pub async fn submit_lesson_score(
if let Some(count) = existing_attempts { if let Some(count) = existing_attempts {
if let Some(max) = max_attempts { if let Some(max) = max_attempts {
if count >= max { if count >= max {
tracing::warn!("User {} attempted to resubmit lesson {} but reached max_attempts ({})", payload.user_id, payload.lesson_id, max); tracing::warn!(
return Err((StatusCode::FORBIDDEN, "Maximum attempts reached for this assessment".into())); "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,7 +478,8 @@ pub async fn submit_lesson_score(
// 5. Check for new badges (Trigger-like logic in code) // 5. Check for new badges (Trigger-like logic in code)
// For now, very simple: if they reached a points threshold // 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") let total_points: i64 =
sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1")
.bind(payload.user_id) .bind(payload.user_id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
@@ -472,7 +528,8 @@ pub async fn get_user_gamification(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Path(user_id): Path<Uuid>, Path(user_id): Path<Uuid>,
) -> Result<Json<GamificationStatus>, StatusCode> { ) -> Result<Json<GamificationStatus>, StatusCode> {
let points: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1") let points: i64 =
sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1")
.bind(user_id) .bind(user_id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
@@ -482,7 +539,7 @@ pub async fn get_user_gamification(
"SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at "SELECT b.id, b.name, b.description, b.icon_url, ub.earned_at
FROM user_badges ub FROM user_badges ub
JOIN badges b ON ub.badge_id = b.id JOIN badges b ON ub.badge_id = b.id
WHERE ub.user_id = $1" WHERE ub.user_id = $1",
) )
.bind(user_id) .bind(user_id)
.fetch_all(&pool) .fetch_all(&pool)
@@ -498,7 +555,7 @@ pub async fn get_user_course_grades(
Path((user_id, course_id)): Path<(Uuid, Uuid)>, Path((user_id, course_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> { ) -> Result<Json<Vec<common::models::UserGrade>>, StatusCode> {
let grades = sqlx::query_as::<_, common::models::UserGrade>( 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(user_id)
.bind(course_id) .bind(course_id)
@@ -515,7 +572,9 @@ pub async fn get_course_analytics(
Path(course_id): Path<Uuid>, Path(course_id): Path<Uuid>,
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> { ) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
// 1. Total Enrollments // 1. Total Enrollments
let total_enrollments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2") let total_enrollments: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2",
)
.bind(course_id) .bind(course_id)
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_one(&pool) .fetch_one(&pool)
@@ -523,7 +582,9 @@ pub async fn get_course_analytics(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 2. Average Course Score (Overall) // 2. Average Course Score (Overall)
let average_score: Option<f32> = sqlx::query_scalar("SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1 AND organization_id = $2") let average_score: Option<f32> = sqlx::query_scalar(
"SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1 AND organization_id = $2",
)
.bind(course_id) .bind(course_id)
.bind(org_ctx.id) .bind(org_ctx.id)
.fetch_one(&pool) .fetch_one(&pool)
@@ -552,14 +613,15 @@ pub async fn get_course_analytics(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let lessons = rows.into_iter().map(|row| { let lessons = rows
LessonAnalytics { .into_iter()
.map(|row| LessonAnalytics {
lesson_id: row.get("id"), lesson_id: row.get("id"),
lesson_title: row.get("title"), lesson_title: row.get("title"),
average_score: row.get("average_score"), average_score: row.get("average_score"),
submission_count: row.get("submission_count"), submission_count: row.get("submission_count"),
} })
}).collect(); .collect();
Ok(Json(CourseAnalytics { Ok(Json(CourseAnalytics {
course_id, course_id,
+4
View File
@@ -122,6 +122,8 @@ pub struct User {
pub password_hash: String, pub password_hash: String,
pub full_name: String, pub full_name: String,
pub role: String, // admin, instructor, student pub role: String, // admin, instructor, student
pub xp: i32,
pub level: i32,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
@@ -133,6 +135,8 @@ pub struct UserResponse {
pub full_name: String, pub full_name: String,
pub role: String, pub role: String,
pub organization_id: Uuid, pub organization_id: Uuid,
pub xp: i32,
pub level: i32,
} }
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]