use axum::{ Json, extract::{Path, State}, http::StatusCode, }; use common::auth::Claims; use common::middleware::Org; use common::models::{LessonRubric, Rubric, RubricCriterion, RubricLevel}; use serde::{Deserialize, Serialize}; use sqlx::{PgPool, Row}; use uuid::Uuid; // ==================== Payload Structs ==================== #[derive(Debug, Deserialize)] pub struct CreateRubricPayload { pub name: String, pub description: Option, pub course_id: Option, } #[derive(Debug, Deserialize)] pub struct UpdateRubricPayload { pub name: Option, pub description: Option, } #[derive(Debug, Deserialize)] pub struct CreateCriterionPayload { pub name: String, pub description: Option, pub max_points: i32, pub position: Option, } #[derive(Debug, Deserialize)] pub struct UpdateCriterionPayload { pub name: Option, pub description: Option, pub max_points: Option, pub position: Option, } #[derive(Debug, Deserialize)] pub struct CreateLevelPayload { pub name: String, pub description: Option, pub points: i32, pub position: Option, } #[derive(Debug, Deserialize)] pub struct UpdateLevelPayload { pub name: Option, pub description: Option, pub points: Option, pub position: Option, } #[allow(dead_code)] #[derive(Debug, Deserialize)] pub struct CreateAssessmentPayload { pub lesson_id: Uuid, pub rubric_id: Uuid, pub user_id: Uuid, pub submission_id: Option, } #[allow(dead_code)] #[derive(Debug, Deserialize)] pub struct UpdateAssessmentPayload { pub total_score: f32, pub feedback: Option, pub scores: Vec, } #[allow(dead_code)] #[derive(Debug, Deserialize)] pub struct CriterionScorePayload { pub criterion_id: Uuid, pub level_id: Option, pub points: f32, pub feedback: Option, } #[derive(Debug, Serialize)] pub struct RubricWithDetails { #[serde(flatten)] pub rubric: Rubric, pub criteria: Vec, } #[derive(Debug, Serialize)] pub struct CriterionWithLevels { #[serde(flatten)] pub criterion: RubricCriterion, pub levels: Vec, } // ==================== Rubric Management ==================== /// Create a new rubric pub async fn create_rubric( Org(org_ctx): Org, claims: Claims, State(pool): State, Path(course_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { let rubric: Rubric = sqlx::query_as( r#" INSERT INTO rubrics (organization_id, course_id, created_by, name, description) VALUES ($1, $2, $3, $4, $5) RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at "# ) .bind(org_ctx.id) .bind(payload.course_id.or(Some(course_id))) .bind(claims.sub) .bind(&payload.name) .bind(&payload.description) .fetch_one(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(rubric)) } /// List all rubrics for a course pub async fn list_course_rubrics( Org(org_ctx): Org, State(pool): State, Path(course_id): Path, ) -> Result>, (StatusCode, String)> { let rubrics: Vec = sqlx::query_as( r#" SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at FROM rubrics WHERE organization_id = $1 AND (course_id = $2 OR course_id IS NULL) ORDER BY created_at DESC "# ) .bind(org_ctx.id) .bind(course_id) .fetch_all(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(rubrics)) } /// Get a rubric with all criteria and levels pub async fn get_rubric_with_details( Org(org_ctx): Org, State(pool): State, Path(rubric_id): Path, ) -> Result, (StatusCode, String)> { // Get rubric let rubric: Rubric = sqlx::query_as( r#" SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at FROM rubrics WHERE id = $1 AND organization_id = $2 "# ) .bind(rubric_id) .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?; // Get criteria let criteria: Vec = sqlx::query_as( r#" SELECT id, rubric_id, name, description, max_points, position, created_at FROM rubric_criteria WHERE rubric_id = $1 ORDER BY position ASC "# ) .bind(rubric_id) .fetch_all(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Get levels for each criterion let mut criteria_with_levels = Vec::new(); for criterion in criteria { let levels: Vec = sqlx::query_as( r#" SELECT id, criterion_id, name, description, points, position, created_at FROM rubric_levels WHERE criterion_id = $1 ORDER BY position ASC "# ) .bind(criterion.id) .fetch_all(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; criteria_with_levels.push(CriterionWithLevels { criterion, levels }); } Ok(Json(RubricWithDetails { rubric, criteria: criteria_with_levels, })) } /// Update a rubric pub async fn update_rubric( Org(org_ctx): Org, State(pool): State, Path(rubric_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { let rubric: Rubric = sqlx::query_as( r#" UPDATE rubrics SET name = COALESCE($1, name), description = COALESCE($2, description), updated_at = NOW() WHERE id = $3 AND organization_id = $4 RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at "# ) .bind(payload.name) .bind(payload.description) .bind(rubric_id) .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?; Ok(Json(rubric)) } /// Delete a rubric pub async fn delete_rubric( Org(org_ctx): Org, State(pool): State, Path(rubric_id): Path, ) -> Result { let result = sqlx::query("DELETE FROM rubrics WHERE id = $1 AND organization_id = $2") .bind(rubric_id) .bind(org_ctx.id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { return Err((StatusCode::NOT_FOUND, "Rubric not found".to_string())); } Ok(StatusCode::NO_CONTENT) } // ==================== Criterion Management ==================== /// Add a criterion to a rubric pub async fn create_criterion( Org(org_ctx): Org, State(pool): State, Path(rubric_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { // Verify rubric exists and belongs to org let _rubric = sqlx::query("SELECT id FROM rubrics WHERE id = $1 AND organization_id = $2") .bind(rubric_id) .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?; let position = payload.position.unwrap_or(0); let criterion: RubricCriterion = sqlx::query_as( r#" INSERT INTO rubric_criteria (rubric_id, name, description, max_points, position) VALUES ($1, $2, $3, $4, $5) RETURNING id, rubric_id, name, description, max_points, position, created_at "# ) .bind(rubric_id) .bind(&payload.name) .bind(&payload.description) .bind(payload.max_points) .bind(position) .fetch_one(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Update rubric total_points let _= sqlx::query( r#" UPDATE rubrics SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), updated_at = NOW() WHERE id = $1 "# ) .bind(rubric_id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(criterion)) } /// Update a criterion pub async fn update_criterion( Org(org_ctx): Org, State(pool): State, Path(criterion_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { let criterion: RubricCriterion = sqlx::query_as( r#" UPDATE rubric_criteria SET name = COALESCE($1, name), description = COALESCE($2, description), max_points = COALESCE($3, max_points), position = COALESCE($4, position) WHERE id = $5 AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6) RETURNING id, rubric_id, name, description, max_points, position, created_at "# ) .bind(payload.name) .bind(payload.description) .bind(payload.max_points) .bind(payload.position) .bind(criterion_id) .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?; // Update rubric total_points if max_points changed if payload.max_points.is_some() { let _ = sqlx::query( r#" UPDATE rubrics SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), updated_at = NOW() WHERE id = $1 "# ) .bind(criterion.rubric_id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } Ok(Json(criterion)) } /// Delete a criterion pub async fn delete_criterion( Org(org_ctx): Org, State(pool): State, Path(criterion_id): Path, ) -> Result { // Get rubric_id before deleting let criterion_row = sqlx::query("SELECT rubric_id FROM rubric_criteria WHERE id = $1") .bind(criterion_id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?; let rubric_id: Uuid = criterion_row.get("rubric_id"); let result = sqlx::query( r#" DELETE FROM rubric_criteria WHERE id = $1 AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2) "# ) .bind(criterion_id) .bind(org_ctx.id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { return Err((StatusCode::NOT_FOUND, "Criterion not found".to_string())); } // Update rubric total_points let _ = sqlx::query( r#" UPDATE rubrics SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), updated_at = NOW() WHERE id = $1 "# ) .bind(rubric_id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(StatusCode::NO_CONTENT) } // ==================== Performance Level Management ==================== /// Add a performance level to a criterion pub async fn create_level( Org(org_ctx): Org, State(pool): State, Path(criterion_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { // Verify criterion exists and belongs to org let _criterion = sqlx::query("SELECT id FROM rubric_criteria WHERE id = $1 AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)") .bind(criterion_id) .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?; let position = payload.position.unwrap_or(0); let level: RubricLevel = sqlx::query_as( r#" INSERT INTO rubric_levels (criterion_id, name, description, points, position) VALUES ($1, $2, $3, $4, $5) RETURNING id, criterion_id, name, description, points, position, created_at "# ) .bind(criterion_id) .bind(&payload.name) .bind(&payload.description) .bind(payload.points) .bind(position) .fetch_one(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(level)) } /// Update a performance level pub async fn update_level( Org(org_ctx): Org, State(pool): State, Path(level_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { let level: RubricLevel = sqlx::query_as( r#" UPDATE rubric_levels SET name = COALESCE($1, name), description = COALESCE($2, description), points = COALESCE($3, points), position = COALESCE($4, position) WHERE id = $5 AND criterion_id IN ( SELECT id FROM rubric_criteria WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6) ) RETURNING id, criterion_id, name, description, points, position, created_at "# ) .bind(payload.name) .bind(payload.description) .bind(payload.points) .bind(payload.position) .bind(level_id) .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Level not found".to_string()))?; Ok(Json(level)) } /// Delete a performance level pub async fn delete_level( Org(org_ctx): Org, State(pool): State, Path(level_id): Path, ) -> Result { let result = sqlx::query( r#" DELETE FROM rubric_levels WHERE id = $1 AND criterion_id IN ( SELECT id FROM rubric_criteria WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2) ) "# ) .bind(level_id) .bind(org_ctx.id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { return Err((StatusCode::NOT_FOUND, "Level not found".to_string())); } Ok(StatusCode::NO_CONTENT) } // ==================== Lesson-Rubric Association ==================== /// Assign a rubric to a lesson pub async fn assign_rubric_to_lesson( Org(_org_ctx): Org, State(pool): State, Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>, ) -> Result, (StatusCode, String)> { let lesson_rubric: LessonRubric = sqlx::query_as( r#" INSERT INTO lesson_rubrics (lesson_id, rubric_id, is_active) VALUES ($1, $2, true) ON CONFLICT (lesson_id, rubric_id) DO UPDATE SET is_active = true RETURNING id, lesson_id, rubric_id, is_active, assigned_at "# ) .bind(lesson_id) .bind(rubric_id) .fetch_one(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(lesson_rubric)) } /// Unassign a rubric from a lesson pub async fn unassign_rubric_from_lesson( Org(_org_ctx): Org, State(pool): State, Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>, ) -> Result { let result = sqlx::query("DELETE FROM lesson_rubrics WHERE lesson_id = $1 AND rubric_id = $2") .bind(lesson_id) .bind(rubric_id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string())); } Ok(StatusCode::NO_CONTENT) } /// Get rubrics assigned to a lesson pub async fn get_lesson_rubrics( Org(org_ctx): Org, State(pool): State, Path(lesson_id): Path, ) -> Result>, (StatusCode, String)> { let rubrics: Vec = sqlx::query_as( r#" SELECT r.id, r.organization_id, r.course_id, r.created_by, r.name, r.description, r.total_points, r.created_at, r.updated_at FROM rubrics r INNER JOIN lesson_rubrics lr ON lr.rubric_id = r.id WHERE lr.lesson_id = $1 AND lr.is_active = true AND r.organization_id = $2 ORDER BY lr.assigned_at DESC "# ) .bind(lesson_id) .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(rubrics)) }