feat: Implement comprehensive course analytics, RBAC with roles and authentication, and dynamic passing thresholds.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
-- Add role column to users table for RBAC
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'instructor';
|
||||
|
||||
-- Add check constraint to ensure only valid roles are used
|
||||
ALTER TABLE users ADD CONSTRAINT check_valid_role CHECK (role IN ('admin', 'instructor', 'student'));
|
||||
|
||||
-- Note: In the Studio (CMS), we'll typically have admins and instructors.
|
||||
-- In the Experience (LMS), we'll have students, but also need to sync roles.
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add passing_percentage to courses for dynamic thresholds
|
||||
ALTER TABLE courses ADD COLUMN IF NOT EXISTS passing_percentage INTEGER NOT NULL DEFAULT 70;
|
||||
|
||||
-- Ensure valid percentage range
|
||||
ALTER TABLE courses ADD CONSTRAINT check_passing_percentage CHECK (passing_percentage >= 0 AND passing_percentage <= 100);
|
||||
@@ -3,13 +3,15 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse};
|
||||
use common::auth::create_jwt;
|
||||
use common::models::{Course, Module, Lesson, PublishedCourse, PublishedModule, User, UserResponse, AuthResponse, CourseAnalytics};
|
||||
use common::auth::{create_jwt, Claims};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use serde_json::json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
use axum::http::HeaderMap;
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
|
||||
pub async fn publish_course(
|
||||
State(pool): State<PgPool>,
|
||||
@@ -120,7 +122,6 @@ pub async fn create_course(
|
||||
|
||||
Ok(Json(course))
|
||||
}
|
||||
|
||||
pub async fn get_courses(
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<Course>>, StatusCode> {
|
||||
@@ -132,6 +133,55 @@ pub async fn get_courses(
|
||||
Ok(Json(courses))
|
||||
}
|
||||
|
||||
pub async fn update_course(
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> Result<Json<Course>, (StatusCode, String)> {
|
||||
// 1. RBAC check (simplified: must be owner or admin)
|
||||
let auth_header = headers.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".into()))?;
|
||||
|
||||
let token = &auth_header[7..];
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret("secret".as_ref()),
|
||||
&Validation::default(),
|
||||
).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token".into()))?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
|
||||
let existing = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
|
||||
|
||||
if claims.role != "admin" && existing.instructor_id != claims.sub {
|
||||
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
|
||||
}
|
||||
|
||||
// 2. Update fields
|
||||
let title = payload.get("title").and_then(|v| v.as_str()).unwrap_or(&existing.title);
|
||||
let description = payload.get("description").and_then(|v| v.as_str()).unwrap_or(existing.description.as_deref().unwrap_or(""));
|
||||
let passing_percentage = payload.get("passing_percentage").and_then(|v| v.as_i64()).unwrap_or(existing.passing_percentage as i64) as i32;
|
||||
|
||||
let course = sqlx::query_as::<_, Course>(
|
||||
"UPDATE courses SET title = $1, description = $2, passing_percentage = $3, updated_at = NOW() WHERE id = $4 RETURNING *"
|
||||
)
|
||||
.bind(title)
|
||||
.bind(description)
|
||||
.bind(passing_percentage)
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to update course".into()))?;
|
||||
|
||||
Ok(Json(course))
|
||||
}
|
||||
|
||||
pub async fn create_module(
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
@@ -504,6 +554,7 @@ pub struct AuthPayload {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub full_name: Option<String>,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
@@ -514,18 +565,20 @@ pub async fn register(
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?;
|
||||
|
||||
let full_name = payload.full_name.unwrap_or_else(|| payload.email.split('@').next().unwrap_or("User").to_string());
|
||||
let role = payload.role.unwrap_or_else(|| "instructor".to_string());
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, password_hash, full_name) VALUES ($1, $2, $3) RETURNING *"
|
||||
"INSERT INTO users (email, password_hash, full_name, role) VALUES ($1, $2, $3, $4) RETURNING *"
|
||||
)
|
||||
.bind(&payload.email)
|
||||
.bind(password_hash)
|
||||
.bind(full_name)
|
||||
.bind(&role)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::CONFLICT, format!("User already exists or DB error: {}", e)))?;
|
||||
|
||||
let token = create_jwt(user.id, "instructor")
|
||||
let token = create_jwt(user.id, &user.role)
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
@@ -533,6 +586,7 @@ pub async fn register(
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
role: user.role,
|
||||
},
|
||||
token,
|
||||
}))
|
||||
@@ -552,7 +606,7 @@ pub async fn login(
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into()));
|
||||
}
|
||||
|
||||
let token = create_jwt(user.id, "instructor")
|
||||
let token = create_jwt(user.id, &user.role)
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into()))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
@@ -560,7 +614,62 @@ pub async fn login(
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
role: user.role,
|
||||
},
|
||||
token,
|
||||
}))
|
||||
}
|
||||
pub async fn get_course_analytics(
|
||||
State(pool): State<PgPool>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<CourseAnalytics>, (StatusCode, String)> {
|
||||
// 1. Extract and verify token
|
||||
let auth_header = headers.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header".into()))?;
|
||||
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid Authorization header".into()));
|
||||
}
|
||||
|
||||
let token = &auth_header[7..];
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret("secret".as_ref()),
|
||||
&Validation::default(),
|
||||
).map_err(|e| {
|
||||
tracing::error!("JWT decode failed: {}", e);
|
||||
(StatusCode::UNAUTHORIZED, "Invalid token".into())
|
||||
})?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
|
||||
// 2. Fetch Course to check ownership
|
||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
|
||||
|
||||
// 3. Enforce RBAC
|
||||
if claims.role != "admin" && course.instructor_id != claims.sub {
|
||||
return Err((StatusCode::FORBIDDEN, "You do not have permission to view stats for a course you don't own".into()));
|
||||
}
|
||||
|
||||
// 4. Fetch from LMS
|
||||
let client = reqwest::Client::new();
|
||||
let res = client.get(format!("http://lms-service:3002/courses/{}/analytics", id))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch analytics from LMS".into()));
|
||||
}
|
||||
|
||||
let analytics = res.json::<CourseAnalytics>().await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(analytics))
|
||||
}
|
||||
|
||||
@@ -35,8 +35,9 @@ async fn main() {
|
||||
|
||||
let app = Router::new()
|
||||
.route("/courses", get(handlers::get_courses).post(handlers::create_course))
|
||||
.route("/courses/{id}", get(handlers::get_course))
|
||||
.route("/courses/{id}", get(handlers::get_course).put(handlers::update_course))
|
||||
.route("/courses/{id}/publish", post(handlers::publish_course))
|
||||
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
|
||||
.route("/modules", get(handlers::get_modules).post(handlers::create_module))
|
||||
.route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson))
|
||||
.route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson))
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add role column to users table for RBAC sync
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'student';
|
||||
|
||||
-- Add check constraint
|
||||
ALTER TABLE users ADD CONSTRAINT check_valid_role CHECK (role IN ('admin', 'instructor', 'student'));
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add passing_percentage to courses (synced from CMS)
|
||||
ALTER TABLE courses ADD COLUMN IF NOT EXISTS passing_percentage INTEGER NOT NULL DEFAULT 70;
|
||||
|
||||
-- Ensure valid range
|
||||
ALTER TABLE courses ADD CONSTRAINT check_passing_percentage CHECK (passing_percentage >= 0 AND passing_percentage <= 100);
|
||||
@@ -3,11 +3,11 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse};
|
||||
use common::models::{Course, Enrollment, Module, Lesson, User, UserResponse, AuthResponse, CourseAnalytics, LessonAnalytics};
|
||||
use common::auth::create_jwt;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||
|
||||
pub async fn enroll_user(
|
||||
@@ -77,6 +77,7 @@ pub async fn register(
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
role: user.role,
|
||||
},
|
||||
token,
|
||||
}))
|
||||
@@ -104,6 +105,7 @@ pub async fn login(
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
role: user.role,
|
||||
},
|
||||
token,
|
||||
}))
|
||||
@@ -128,14 +130,15 @@ pub async fn ingest_course(
|
||||
|
||||
// 1. Upsert Course
|
||||
sqlx::query(
|
||||
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
"INSERT INTO courses (id, title, description, instructor_id, start_date, end_date, passing_percentage, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
instructor_id = EXCLUDED.instructor_id,
|
||||
start_date = EXCLUDED.start_date,
|
||||
end_date = EXCLUDED.end_date,
|
||||
passing_percentage = EXCLUDED.passing_percentage,
|
||||
updated_at = EXCLUDED.updated_at"
|
||||
)
|
||||
.bind(payload.course.id)
|
||||
@@ -144,6 +147,7 @@ pub async fn ingest_course(
|
||||
.bind(payload.course.instructor_id)
|
||||
.bind(payload.course.start_date)
|
||||
.bind(payload.course.end_date)
|
||||
.bind(payload.course.passing_percentage)
|
||||
.bind(payload.course.updated_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
@@ -375,3 +379,58 @@ pub async fn get_user_course_grades(
|
||||
|
||||
Ok(Json(grades))
|
||||
}
|
||||
pub async fn get_course_analytics(
|
||||
State(pool): State<PgPool>,
|
||||
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")
|
||||
.bind(course_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.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")
|
||||
.bind(course_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
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
l.id,
|
||||
l.title,
|
||||
COALESCE(AVG(g.score), 0)::float4 as average_score,
|
||||
COUNT(g.id) as submission_count
|
||||
FROM lessons l
|
||||
LEFT JOIN user_grades g ON l.id = g.lesson_id
|
||||
WHERE l.module_id IN (SELECT id FROM modules WHERE course_id = $1)
|
||||
GROUP BY l.id, l.title, l.position
|
||||
ORDER BY l.position
|
||||
"#
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
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();
|
||||
|
||||
Ok(Json(CourseAnalytics {
|
||||
course_id,
|
||||
total_enrollments,
|
||||
average_score: average_score.unwrap_or(0.0),
|
||||
lessons,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ async fn main() {
|
||||
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
||||
.route("/grades", post(handlers::submit_lesson_score))
|
||||
.route("/users/{user_id}/courses/{course_id}/grades", get(handlers::get_user_course_grades))
|
||||
.route("/courses/{id}/analytics", get(handlers::get_course_analytics))
|
||||
.layer(cors)
|
||||
.with_state(pool);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user