feat: Implement course team management with dedicated UI and API, add course preview token generation, and refactor course settings UI.
This commit is contained in:
@@ -8,7 +8,7 @@ use axum::{
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||
use chrono::{DateTime, Utc};
|
||||
use common::auth::{Claims, create_jwt};
|
||||
use common::auth::{Claims, create_jwt, create_preview_token};
|
||||
use common::middleware::Org;
|
||||
use common::models::{
|
||||
AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse,
|
||||
@@ -120,6 +120,7 @@ pub async fn publish_course(
|
||||
organization,
|
||||
grading_categories,
|
||||
modules: pub_modules,
|
||||
dependencies: None,
|
||||
};
|
||||
|
||||
// 4. Send to LMS
|
||||
@@ -3288,6 +3289,164 @@ pub async fn import_course(
|
||||
Ok(Json(new_course))
|
||||
}
|
||||
|
||||
pub async fn check_course_access(
|
||||
pool: &PgPool,
|
||||
course_id: Uuid,
|
||||
user_id: Uuid,
|
||||
role: &str,
|
||||
) -> Result<bool, (StatusCode, String)> {
|
||||
if role == "admin" {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let exists = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM course_instructors WHERE course_id = $1 AND user_id = $2)"
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct CourseInstructor {
|
||||
pub id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub role: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub email: String,
|
||||
pub full_name: String,
|
||||
}
|
||||
|
||||
pub async fn get_course_team(
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<CourseInstructor>>, (StatusCode, String)> {
|
||||
if !check_course_access(&pool, id, claims.sub, &claims.role).await? {
|
||||
return Err((StatusCode::FORBIDDEN, "No access to this course team".into()));
|
||||
}
|
||||
|
||||
let team = sqlx::query_as::<_, CourseInstructor>(
|
||||
"SELECT ci.*, u.email, u.full_name FROM course_instructors ci
|
||||
JOIN users u ON ci.user_id = u.id
|
||||
WHERE ci.course_id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(team))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddTeamMemberPayload {
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
pub async fn add_team_member(
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<AddTeamMemberPayload>,
|
||||
) -> Result<Json<CourseInstructor>, (StatusCode, String)> {
|
||||
// Only primary instructors or admins can add members
|
||||
let is_authorized = if claims.role == "admin" {
|
||||
true
|
||||
} else {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM course_instructors WHERE course_id = $1 AND user_id = $2 AND role = 'primary')"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
};
|
||||
|
||||
if !is_authorized {
|
||||
return Err((StatusCode::FORBIDDEN, "Only primary instructors can add team members".into()));
|
||||
}
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||
.bind(&payload.email)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "User not found".into()))?;
|
||||
|
||||
let instructor = sqlx::query_as::<_, CourseInstructor>(
|
||||
"INSERT INTO course_instructors (course_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *, (SELECT email FROM users WHERE id = $2) as email, (SELECT full_name FROM users WHERE id = $2) as full_name"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user.id)
|
||||
.bind(&payload.role)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
|
||||
Ok(Json(instructor))
|
||||
}
|
||||
|
||||
pub async fn remove_team_member(
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path((course_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let is_authorized = if claims.role == "admin" {
|
||||
true
|
||||
} else {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM course_instructors WHERE course_id = $1 AND user_id = $2 AND role = 'primary')"
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
};
|
||||
|
||||
if !is_authorized && claims.sub != user_id {
|
||||
return Err((StatusCode::FORBIDDEN, "Unauthorized to remove this member".into()));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM course_instructors WHERE course_id = $1 AND user_id = $2")
|
||||
.bind(course_id)
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn create_course_preview_token(
|
||||
Org(_org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// Verify user has access to this course (must be an instructor/admin)
|
||||
if !check_course_access(&pool, id, claims.sub, &claims.role).await? {
|
||||
return Err((StatusCode::FORBIDDEN, "No access to this course preview".into()));
|
||||
}
|
||||
|
||||
let token = create_preview_token(claims.sub, claims.org, id)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(json!({ "token": token })))
|
||||
}
|
||||
|
||||
// --- AI Course Generation ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -3540,7 +3699,7 @@ pub async fn delete_course(
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// 2. Additional permission check for instructors
|
||||
if !is_super_admin && claims.role == "instructor" && course.instructor_id != claims.sub {
|
||||
if !is_super_admin && !check_course_access(&pool, course.id, claims.sub, &claims.role).await? {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,18 @@ async fn main() {
|
||||
"/courses/{id}/analytics/advanced",
|
||||
get(handlers::get_advanced_analytics),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/team",
|
||||
get(handlers::get_course_team).post(handlers::add_team_member),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/team/{user_id}",
|
||||
delete(handlers::remove_team_member),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/preview-token",
|
||||
post(handlers::create_course_preview_token),
|
||||
)
|
||||
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
|
||||
.route(
|
||||
"/modules",
|
||||
|
||||
@@ -734,10 +734,32 @@ pub async fn ingest_course(
|
||||
|
||||
pub async fn get_course_outline(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<common::models::PublishedCourse>, StatusCode> {
|
||||
tracing::info!("get_course_outline: id={}, caller_org={}", id, org_ctx.id);
|
||||
tracing::info!(
|
||||
"get_course_outline: id={}, user={}, caller_org={}",
|
||||
id,
|
||||
claims.sub,
|
||||
org_ctx.id
|
||||
);
|
||||
|
||||
// If it's a preview token, ensure it's for the correct course
|
||||
if claims.token_type.as_deref() == Some("preview") {
|
||||
if claims.course_id != Some(id) {
|
||||
tracing::warn!(
|
||||
"get_course_outline: Preview token course_id mismatch. Token for {:?}, requested {}",
|
||||
claims.course_id,
|
||||
id
|
||||
);
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
tracing::info!(
|
||||
"get_course_outline: Authorized via preview token for course {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
// 1. Fetch Course
|
||||
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
|
||||
.bind(id)
|
||||
@@ -857,35 +879,59 @@ pub async fn get_lesson_content(
|
||||
claims.sub
|
||||
);
|
||||
|
||||
// 1. Check if user is enrolled in the course this lesson belongs to
|
||||
let lesson = sqlx::query_as::<_, Lesson>(
|
||||
"SELECT l.* FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
JOIN enrollments e ON m.course_id = e.course_id
|
||||
WHERE l.id = $1 AND e.user_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(claims.sub)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_lesson_content: DB error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
// 1. Check for preview token override
|
||||
let is_preview = claims.token_type.as_deref() == Some("preview");
|
||||
|
||||
let lesson = if is_preview {
|
||||
tracing::info!("get_lesson_content: Using preview token for lesson {}", id);
|
||||
// Ensure the preview token is for the correct course (if we want to be strict)
|
||||
// or just fetch the lesson and verify it belongs to the same org.
|
||||
sqlx::query_as::<_, Lesson>(
|
||||
"SELECT l.* FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
WHERE l.id = $1 AND l.organization_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(claims.org)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_lesson_content: DB error (preview): {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
} else {
|
||||
sqlx::query_as::<_, Lesson>(
|
||||
"SELECT l.* FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
JOIN enrollments e ON m.course_id = e.course_id
|
||||
WHERE l.id = $1 AND e.user_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(claims.sub)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_lesson_content: DB error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
};
|
||||
|
||||
let lesson = match lesson {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"get_lesson_content: User {} not enrolled or lesson {} not found",
|
||||
claims.sub,
|
||||
id
|
||||
"get_lesson_content: Access denied or lesson {} not found (is_preview={})",
|
||||
id,
|
||||
is_preview
|
||||
);
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Enforce Prerequisites
|
||||
// 2. Enforce Prerequisites (Skip for previews)
|
||||
if is_preview {
|
||||
return Ok(Json(lesson));
|
||||
}
|
||||
// We check if there are any prerequisites that the user hasn't completed yet.
|
||||
// A prerequisite is completed if:
|
||||
// a) It's graded and the user has a grade >= min_score_percentage (default 0)
|
||||
|
||||
Reference in New Issue
Block a user