|
|
|
@@ -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<PgPool>,
|
|
|
|
@@ -17,7 +20,10 @@ pub async fn enroll_user(
|
|
|
|
|
claims: Claims,
|
|
|
|
|
Json(payload): Json<serde_json::Value>,
|
|
|
|
|
) -> 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 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<PgPool>,
|
|
|
|
|
Json(payload): Json<common::models::PublishedCourse>,
|
|
|
|
|
) -> 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
|
|
|
|
|
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,7 +312,8 @@ pub async fn get_course_outline(
|
|
|
|
|
Path(id): Path<Uuid>,
|
|
|
|
|
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
|
|
|
|
|
// 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(org_ctx.id)
|
|
|
|
|
.fetch_one(&pool)
|
|
|
|
@@ -280,7 +321,9 @@ pub async fn get_course_outline(
|
|
|
|
|
.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")
|
|
|
|
|
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)
|
|
|
|
@@ -289,7 +332,7 @@ pub async fn get_course_outline(
|
|
|
|
|
|
|
|
|
|
// 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")
|
|
|
|
|
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,7 +367,8 @@ pub async fn get_lesson_content(
|
|
|
|
|
State(pool): State<PgPool>,
|
|
|
|
|
Path(id): Path<Uuid>,
|
|
|
|
|
) -> 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(org_ctx.id)
|
|
|
|
|
.fetch_one(&pool)
|
|
|
|
@@ -340,7 +383,9 @@ pub async fn get_user_enrollments(
|
|
|
|
|
State(pool): State<PgPool>,
|
|
|
|
|
Path(user_id): Path<Uuid>,
|
|
|
|
|
) -> 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(org_ctx.id)
|
|
|
|
|
.fetch_all(&pool)
|
|
|
|
@@ -356,7 +401,9 @@ pub async fn submit_lesson_score(
|
|
|
|
|
Json(payload): Json<GradeSubmissionPayload>,
|
|
|
|
|
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
|
|
|
|
|
// 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(org_ctx.id)
|
|
|
|
|
.fetch_optional(&pool)
|
|
|
|
@@ -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,7 +478,8 @@ 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")
|
|
|
|
|
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
|
|
|
|
@@ -472,7 +528,8 @@ pub async fn get_user_gamification(
|
|
|
|
|
State(pool): State<PgPool>,
|
|
|
|
|
Path(user_id): Path<Uuid>,
|
|
|
|
|
) -> 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)
|
|
|
|
|
.fetch_one(&pool)
|
|
|
|
|
.await
|
|
|
|
@@ -482,7 +539,7 @@ pub async fn get_user_gamification(
|
|
|
|
|
"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<Json<Vec<common::models::UserGrade>>, 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,7 +572,9 @@ pub async fn get_course_analytics(
|
|
|
|
|
Path(course_id): Path<Uuid>,
|
|
|
|
|
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
|
|
|
|
|
// 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(org_ctx.id)
|
|
|
|
|
.fetch_one(&pool)
|
|
|
|
@@ -523,7 +582,9 @@ pub async fn get_course_analytics(
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// 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(org_ctx.id)
|
|
|
|
|
.fetch_one(&pool)
|
|
|
|
@@ -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,
|
|
|
|
|