feat: add gamification features for users, including XP and leveling, with corresponding database schema and API response updates.
This commit is contained in:
+2
-2
@@ -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;
|
||||||
@@ -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,13 +1488,14 @@ 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 =
|
||||||
.fetch_all(&pool)
|
sqlx::query_as::<_, Organization>("SELECT * FROM organizations ORDER BY created_at DESC")
|
||||||
.await
|
.fetch_all(&pool)
|
||||||
.map_err(|e| {
|
.await
|
||||||
tracing::error!("Failed to fetch organizations: {}", e);
|
.map_err(|e| {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
tracing::error!("Failed to fetch organizations: {}", e);
|
||||||
})?;
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(orgs))
|
Ok(Json(orgs))
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,24 +312,27 @@ 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 =
|
||||||
.bind(id)
|
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(org_ctx.id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.bind(org_ctx.id)
|
||||||
.await
|
.fetch_one(&pool)
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.await
|
||||||
|
.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>(
|
||||||
.bind(id)
|
"SELECT * FROM modules WHERE course_id = $1 AND organization_id = $2 ORDER BY position",
|
||||||
.bind(org_ctx.id)
|
)
|
||||||
.fetch_all(&pool)
|
.bind(id)
|
||||||
.await
|
.bind(org_ctx.id)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// 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>(
|
||||||
.bind(module.id)
|
"SELECT * FROM lessons WHERE module_id = $1 AND organization_id = $2 ORDER BY position",
|
||||||
.bind(org_ctx.id)
|
)
|
||||||
.fetch_all(&pool)
|
.bind(module.id)
|
||||||
.await
|
.bind(org_ctx.id)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.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,12 +367,13 @@ 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 =
|
||||||
.bind(id)
|
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
|
||||||
.bind(org_ctx.id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.bind(org_ctx.id)
|
||||||
.await
|
.fetch_one(&pool)
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.await
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
Ok(Json(lesson))
|
Ok(Json(lesson))
|
||||||
}
|
}
|
||||||
@@ -340,12 +383,14 @@ 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>(
|
||||||
.bind(user_id)
|
"SELECT * FROM enrollments WHERE user_id = $1 AND organization_id = $2",
|
||||||
.bind(org_ctx.id)
|
)
|
||||||
.fetch_all(&pool)
|
.bind(user_id)
|
||||||
.await
|
.bind(org_ctx.id)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
Ok(Json(enrollments))
|
Ok(Json(enrollments))
|
||||||
}
|
}
|
||||||
@@ -356,12 +401,14 @@ 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(
|
||||||
.bind(payload.lesson_id)
|
"SELECT max_attempts FROM lessons WHERE id = $1 AND organization_id = $2",
|
||||||
.bind(org_ctx.id)
|
)
|
||||||
.fetch_optional(&pool)
|
.bind(payload.lesson_id)
|
||||||
.await
|
.bind(org_ctx.id)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if max_attempts.is_none() {
|
if max_attempts.is_none() {
|
||||||
return Err((StatusCode::NOT_FOUND, "Lesson not found".into()));
|
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(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,11 +478,12 @@ 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 =
|
||||||
.bind(payload.user_id)
|
sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1")
|
||||||
.fetch_one(&pool)
|
.bind(payload.user_id)
|
||||||
.await
|
.fetch_one(&pool)
|
||||||
.unwrap_or(0);
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
let eligible_badges = sqlx::query(
|
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)"
|
"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<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 =
|
||||||
.bind(user_id)
|
sqlx::query_scalar("SELECT COALESCE(SUM(amount), 0) FROM points_log WHERE user_id = $1")
|
||||||
.fetch_one(&pool)
|
.bind(user_id)
|
||||||
.await
|
.fetch_one(&pool)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
let badges = sqlx::query_as::<_, BadgeResponse>(
|
let badges = sqlx::query_as::<_, BadgeResponse>(
|
||||||
"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,20 +572,24 @@ 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(
|
||||||
.bind(course_id)
|
"SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2",
|
||||||
.bind(org_ctx.id)
|
)
|
||||||
.fetch_one(&pool)
|
.bind(course_id)
|
||||||
.await
|
.bind(org_ctx.id)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.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(
|
||||||
.bind(course_id)
|
"SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1 AND organization_id = $2",
|
||||||
.bind(org_ctx.id)
|
)
|
||||||
.fetch_one(&pool)
|
.bind(course_id)
|
||||||
.await
|
.bind(org_ctx.id)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 3. Per-Lesson Analytics
|
// 3. Per-Lesson Analytics
|
||||||
// Note: We cast AVG to float4 for PostgreSQL compatibility
|
// Note: We cast AVG to float4 for PostgreSQL compatibility
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user