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:
2026-02-18 00:01:47 -03:00
parent 89b1d1353d
commit f365e585a2
13 changed files with 798 additions and 301 deletions
+161 -2
View File
@@ -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);
}
+12
View File
@@ -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",
+66 -20
View File
@@ -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)