use crate::exporter; use crate::webhooks::WebhookService; pub mod tasks; use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, }; use bcrypt::{DEFAULT_COST, hash, verify}; use chrono::{DateTime, Utc}; use common::auth::{Claims, create_jwt}; use common::middleware::Org; use common::models::{ AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse, PublishedModule, User, UserResponse, }; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; use std::env; use uuid::Uuid; use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType}; use openidconnect::reqwest::async_http_client; use openidconnect::{ AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, RedirectUrl, Scope, TokenResponse, }; #[derive(Deserialize)] pub struct SSOCallbackParams { pub code: String, pub state: String, } #[derive(Deserialize)] pub struct PublishPayload { pub target_organization_id: Option, } pub async fn publish_course( Org(org_ctx): Org, claims: Claims, State(pool): State, Path(id): Path, Json(payload_params): Json, ) -> Result { let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); // 1. Fetch Course (Super admin can publish any course, others only their org's) let course = if is_super_admin { sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") .bind(id) .fetch_one(&pool) .await } else { sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") .bind(id) .bind(org_ctx.id) .fetch_one(&pool) .await } .map_err(|_| StatusCode::NOT_FOUND)?; // Determine target organization let target_org_id = if is_super_admin && payload_params.target_organization_id.is_some() { payload_params.target_organization_id.unwrap() } else { course.organization_id }; // 2. Fetch Modules let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") .bind(id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let mut pub_modules = Vec::new(); // 3. Fetch Grading Categories let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>( "SELECT * FROM grading_categories WHERE course_id = $1", ) .bind(id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // 4. Fetch Target Organization let organization = sqlx::query_as::<_, common::models::Organization>( "SELECT * FROM organizations WHERE id = $1", ) .bind(target_org_id) .fetch_one(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // 5. Fetch Lessons for each Module for module in modules { let lessons = sqlx::query_as::<_, Lesson>( "SELECT * FROM lessons WHERE module_id = $1 ORDER BY position", ) .bind(module.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; pub_modules.push(PublishedModule { module, lessons }); } // Overwrite the course's organization_id in the payload if publishing to a different org let mut course_for_pub = course.clone(); course_for_pub.organization_id = target_org_id; let payload = PublishedCourse { course: course_for_pub, organization, grading_categories, modules: pub_modules, }; // 4. Send to LMS let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let client = reqwest::Client::new(); let res = client .post(format!("{}/ingest", lms_url)) .json(&payload) .send() .await .map_err(|e| { tracing::error!("Failed to reach LMS service: {}", e); StatusCode::BAD_GATEWAY })?; if !res.status().is_success() { tracing::error!("LMS ingestion failed with status: {}", res.status()); return Err(StatusCode::INTERNAL_SERVER_ERROR); } log_action( &pool, org_ctx.id, Uuid::new_v4(), "PUBLISH", "Course", id, json!({ "target_org": target_org_id }), ) .await; // 5. Trigger Webhook let webhook_service = WebhookService::new(pool.clone()); webhook_service .dispatch( org_ctx.id, "course.published", &json!({ "course_id": id, "title": payload.course.title, "pacing_mode": payload.course.pacing_mode, "target_org": target_org_id, "published_at": Utc::now() }), ) .await; Ok(StatusCode::NO_CONTENT) } #[derive(Deserialize)] pub struct ModuleQuery { pub course_id: Option, } #[derive(Deserialize)] pub struct LessonQuery { pub module_id: Option, } #[derive(Deserialize)] pub struct GradingPayload { pub course_id: Uuid, pub name: String, pub weight: i32, pub drop_count: i32, } pub async fn create_course( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result, StatusCode> { let title = payload .get("title") .and_then(|t| t.as_str()) .ok_or(StatusCode::BAD_REQUEST)?; let instructor_id = claims.sub; let pacing_mode = payload .get("pacing_mode") .and_then(|v| v.as_str()) .unwrap_or("self_paced"); let mut tx = pool.begin().await.map_err(|e| { tracing::error!("Failed to start transaction: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; // Set session context for the DB trigger to find let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) .map(|s| s.to_string()); let ua = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); crate::db_util::set_session_context( &mut *tx, Some(instructor_id), Some(org_ctx.id), ip, ua, Some("USER_EVENT".to_string()), ) .await .map_err(|e| { tracing::error!("Failed to set session context: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); let target_org_id = if is_super_admin { payload .get("organization_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) .unwrap_or(org_ctx.id) } else { org_ctx.id }; let course = sqlx::query_as::<_, Course>("SELECT * FROM fn_create_course($1, $2, $3, $4)") .bind(target_org_id) .bind(instructor_id) .bind(title) .bind(pacing_mode) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("Create course failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; tx.commit().await.map_err(|e| { tracing::error!("Failed to commit transaction: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(course)) } pub async fn get_courses( Org(org_ctx): Org, claims: Claims, State(pool): State, ) -> Result>, StatusCode> { let is_super_admin = claims.role == "admin" && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); let courses = if is_super_admin { sqlx::query_as::<_, Course>("SELECT * FROM courses") .fetch_all(&pool) .await } else { sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1") .bind(org_ctx.id) .fetch_all(&pool) .await } .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(courses)) } pub async fn update_course( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { let existing = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1 AND organization_id = $2") .bind(id) .bind(org_ctx.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())); } 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 certificate_template = payload .get("certificate_template") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .or(existing.certificate_template); let pacing_mode = payload .get("pacing_mode") .and_then(|v| v.as_str()) .unwrap_or(&existing.pacing_mode); let start_date = payload .get("start_date") .and_then(|v| v.as_str()) .and_then(|s| s.parse::>().ok()) .or(existing.start_date); let end_date = payload .get("end_date") .and_then(|v| v.as_str()) .and_then(|s| s.parse::>().ok()) .or(existing.end_date); // BEGIN TRANSACTION let mut tx = pool .begin() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Set auditing context sqlx::query( "SELECT set_config('app.current_user_id', $1, true), set_config('app.org_id', $2, true)", ) .bind(claims.sub.to_string()) .bind(org_ctx.id.to_string()) .execute(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let course = sqlx::query_as::<_, Course>( "SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9)", ) .bind(id) .bind(org_ctx.id) .bind(title) .bind(description) .bind(passing_percentage) .bind(pacing_mode) .bind(start_date) .bind(end_date) .bind(certificate_template) .fetch_one(&mut *tx) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update course: {}", e), ) })?; tx.commit() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(course)) } pub async fn create_module( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result, StatusCode> { let title = payload .get("title") .and_then(|t| t.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 position = payload .get("position") .and_then(|v| v.as_i64()) .unwrap_or(0) as i32; let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) .map(|s| s.to_string()); let ua = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); crate::db_util::set_session_context( &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, ua, Some("USER_EVENT".to_string()), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let module = sqlx::query_as::<_, Module>("SELECT * FROM fn_create_module($1, $2, $3, $4)") .bind(org_ctx.id) .bind(course_id) .bind(title) .bind(position) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("Create module failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(module)) } pub async fn create_lesson( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result, StatusCode> { let title = payload .get("title") .and_then(|t| t.as_str()) .ok_or(StatusCode::BAD_REQUEST)?; let module_id_str = payload .get("module_id") .and_then(|v| v.as_str()) .ok_or(StatusCode::BAD_REQUEST)?; let module_id = Uuid::parse_str(module_id_str).map_err(|_| StatusCode::BAD_REQUEST)?; let content_type = payload .get("content_type") .and_then(|t| t.as_str()) .ok_or(StatusCode::BAD_REQUEST)?; let content_url = payload.get("content_url").and_then(|v| v.as_str()); let position = payload .get("position") .and_then(|v| v.as_i64()) .unwrap_or(0) as i32; let transcription = payload.get("transcription").cloned(); let metadata = payload.get("metadata").cloned(); let is_graded = payload .get("is_graded") .and_then(|v| v.as_bool()) .unwrap_or(false); let grading_category_id = payload .get("grading_category_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()); let max_attempts = payload .get("max_attempts") .and_then(|v| v.as_i64()) .map(|v| v as i32); let allow_retry = payload .get("allow_retry") .and_then(|v| v.as_bool()) .unwrap_or(true); let due_date = payload .get("due_date") .and_then(|v| v.as_str()) .and_then(|s| s.parse::>().ok()); let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str()); let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) .map(|s| s.to_string()); let ua = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); crate::db_util::set_session_context( &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, ua, Some("USER_EVENT".to_string()), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let lesson = sqlx::query_as::<_, Lesson>( "SELECT * FROM fn_create_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)" ) .bind(org_ctx.id) .bind(module_id) .bind(title) .bind(content_type) .bind(content_url) .bind(position) .bind(transcription) .bind(metadata) .bind(is_graded) .bind(grading_category_id) .bind(max_attempts) .bind(allow_retry) .bind(due_date) .bind(important_date_type) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("Create lesson failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(lesson)) } pub async fn process_transcription( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { tracing::info!("Received transcription request for lesson: {}", id); // 1. Fetch lesson 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) .await .map_err(|e| { tracing::error!("Lesson fetch failed: {}", e); StatusCode::NOT_FOUND })?; if lesson.content_type != "video" && lesson.content_type != "audio" { return Err(StatusCode::BAD_REQUEST); } let url = lesson.content_url.ok_or(StatusCode::BAD_REQUEST)?; let filename = url.trim_start_matches("/assets/"); let file_path = format!("uploads/{}", filename); // 2. Read file to verify it exists if !tokio::fs::metadata(&file_path).await.is_ok() { tracing::error!("File not found: {}", file_path); return Err(StatusCode::INTERNAL_SERVER_ERROR); } // 3. Set status to queued let updated_lesson = sqlx::query_as::<_, Lesson>( "UPDATE lessons SET transcription_status = 'queued' WHERE id = $1 RETURNING *", ) .bind(id) .fetch_one(&pool) .await .map_err(|e| { tracing::error!("Database update failed (queued): {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; log_action( &pool, org_ctx.id, claims.sub, "TRANSCRIPTION_QUEUED", "Lesson", id, json!({ "status": "queued" }), ) .await; // 4. Spawn background task let pool_clone = pool.clone(); tokio::spawn(async move { if let Err(e) = run_transcription_task(pool_clone, id).await { tracing::error!("Transcription task failed for lesson {}: {}", id, e); } }); Ok(Json(updated_lesson)) } async fn translate_text(text: &str, target_lang: &str) -> Result { let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let client = reqwest::Client::new(); let (url, auth_header, model) = if provider == "local" { let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string()); ( format!("{}/v1/chat/completions", base_url), "".to_string(), model, ) } else { let api_key = env::var("OPENAI_API_KEY").map_err(|_| "Missing OPENAI_API_KEY")?; ( "https://api.openai.com/v1/chat/completions".to_string(), format!("Bearer {}", api_key), "gpt-4o".to_string(), ) }; let prompt = format!( "Translate the following transcription into {}. Maintain the same tone and context. Only return the translated text, nothing else.\n\nText: {}", if target_lang == "es" { "Spanish" } else { target_lang }, text ); let mut request = client.post(&url).json(&json!({ "model": model, "messages": [ { "role": "user", "content": prompt } ], "temperature": 0.3 })); if !auth_header.is_empty() { request = request.header("Authorization", auth_header); } let response = request .send() .await .map_err(|e| format!("Translation request failed: {}", e))?; if !response.status().is_success() { let err_body = response.text().await.unwrap_or_default(); return Err(format!("Translation API error: {}", err_body)); } let gpt_data: serde_json::Value = response .json() .await .map_err(|e| format!("Translation JSON parse failed: {}", e))?; let translated = gpt_data["choices"][0]["message"]["content"] .as_str() .unwrap_or("") .trim() .to_string(); Ok(translated) } pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), String> { // 1. Fetch lesson let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1") .bind(lesson_id) .fetch_one(&pool) .await .map_err(|e| format!("Lesson fetch failed: {}", e))?; let url = lesson.content_url.ok_or("No content URL")?; let filename = url.trim_start_matches("/assets/"); let file_path = format!("uploads/{}", filename); // 2. Set status to processing sqlx::query("UPDATE lessons SET transcription_status = 'processing' WHERE id = $1") .bind(lesson_id) .execute(&pool) .await .map_err(|e| format!("Update to processing failed: {}", e))?; // 3. Read file let file_data = tokio::fs::read(&file_path) .await .map_err(|e| format!("File read failed ({}): {}", file_path, e))?; // 4. Configuration let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let client = reqwest::Client::new(); let (url, auth_header, model) = if provider == "local" { let base_url = env::var("LOCAL_WHISPER_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); ( format!("{}/v1/audio/transcriptions", base_url), "".to_string(), "medium".to_string(), ) } else { let api_key = env::var("OPENAI_API_KEY").map_err(|_| "Missing OPENAI_API_KEY")?; ( "https://api.openai.com/v1/audio/transcriptions".to_string(), format!("Bearer {}", api_key), "whisper-1".to_string(), ) }; let part = reqwest::multipart::Part::bytes(file_data) .file_name(filename.to_string()) .mime_str("application/octet-stream") .map_err(|e| format!("Multipart part creation failed: {}", e))?; let form = reqwest::multipart::Form::new() .part("file", part) .text("model", model) .text("response_format", "verbose_json"); let mut request = client.post(&url).multipart(form); if !auth_header.is_empty() { request = request.header("Authorization", auth_header); } let response = request .send() .await .map_err(|e| format!("Transcription request failed: {}", e))?; if !response.status().is_success() { let err_body = response.text().await.unwrap_or_default(); return Err(format!("Transcription API error: {}", err_body)); } let whisper_data: serde_json::Value = response .json() .await .map_err(|e| format!("Whisper JSON parse failed: {}", e))?; // Extract text and segments (cues) let text = whisper_data["text"].as_str().unwrap_or_default(); let segments = whisper_data["segments"].as_array(); let mut cues = Vec::new(); if let Some(segments) = segments { for s in segments { cues.push(json!({ "start": s["start"], "end": s["end"], "text": s["text"] })); } } let transcription = json!({ "en": text, "es": "", "cues": cues }); // 5. Update initial transcription sqlx::query( "UPDATE lessons SET transcription = $1, transcription_status = 'processing' WHERE id = $2", ) .bind(&transcription) .bind(lesson_id) .execute(&pool) .await .map_err(|e| format!("Initial database update failed: {}", e))?; // 6. Translation (Optional/Background within the task) let es_text = match translate_text(text, "es").await { Ok(t) => t, Err(e) => { tracing::error!("Translation failed for lesson {}: {}", lesson_id, e); "".to_string() } }; let final_transcription = json!({ "en": text, "es": es_text, "cues": cues }); // 7. Final Update sqlx::query( "UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2", ) .bind(final_transcription) .bind(lesson_id) .execute(&pool) .await .map_err(|e| format!("Final database update failed: {}", e))?; Ok(()) } pub async fn get_lesson_vtt( Org(org_ctx): Org, State(pool): State, Path(id): Path, Query(params): Query, ) -> Result<(axum::http::HeaderMap, String), StatusCode> { 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) .await .map_err(|_| StatusCode::NOT_FOUND)?; let lang = params.get("lang").and_then(|v| v.as_str()).unwrap_or("en"); let transcription = lesson.transcription.ok_or(StatusCode::NOT_FOUND)?; let cues = transcription["cues"] .as_array() .ok_or(StatusCode::NOT_FOUND)?; let mut vtt = String::from("WEBVTT\n\n"); for (index, cue) in cues.iter().enumerate() { let start = cue["start"].as_f64().unwrap_or(0.0); let end = cue["end"].as_f64().unwrap_or(0.0); let text = if lang == "es" && !transcription["es"].as_str().unwrap_or("").is_empty() { // Simplified: in a real scenario we might want translated cues // For now, if we have a full translation we could try to split it, // but usually Whisper gives us segments. // If we only have English segments, we'll use them. cue["text"].as_str().unwrap_or("") } else { cue["text"].as_str().unwrap_or("") }; vtt.push_str(&format!("{}\n", index + 1)); vtt.push_str(&format!( "{} --> {}\n", format_vtt_timestamp(start), format_vtt_timestamp(end) )); vtt.push_str(&format!("{}\n\n", text.trim())); } let mut headers = axum::http::HeaderMap::new(); headers.insert( axum::http::header::CONTENT_TYPE, "text/vtt".parse().unwrap(), ); Ok((headers, vtt)) } fn format_vtt_timestamp(seconds: f64) -> String { let hours = (seconds / 3600.0).floor() as u32; let mins = ((seconds % 3600.0) / 60.0).floor() as u32; let secs = (seconds % 60.0).floor() as u32; let millis = ((seconds.fract() * 1000.0).round()) as u32; format!("{:02}:{:02}:{:02}.{:03}", hours, mins, secs, millis) } pub async fn summarize_lesson( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { tracing::info!("Received summarization request for lesson: {}", id); // 1. Fetch lesson 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) .await .map_err(|_| StatusCode::NOT_FOUND)?; let transcription_text = lesson .transcription .as_ref() .and_then(|t| t["en"].as_str()) .unwrap_or(""); if transcription_text.is_empty() { tracing::warn!("Cannot summarize lesson {}: No transcription found", id); return Err(StatusCode::BAD_REQUEST); } // 2. Configuration let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let client = reqwest::Client::new(); let (url, auth_header, model) = if provider == "local" { let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string()); ( format!("{}/v1/chat/completions", base_url), "".to_string(), model, ) } else { let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; ( "https://api.openai.com/v1/chat/completions".to_string(), format!("Bearer {}", api_key), "gpt-4o".to_string(), ) }; let mut request = client .post(&url) .json(&json!({ "model": model, "messages": [ { "role": "system", "content": "You are a professional educational assistant. Summarize the following lesson transcription into a high-quality summary suited for a course platform. Keep it concise but informative (max 150 words). Focus on the key learning objectives." }, { "role": "user", "content": transcription_text } ] })); if !auth_header.is_empty() { request = request.header("Authorization", auth_header); } let response = request.send().await.map_err(|e| { tracing::error!("Summarization request failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; if !response.status().is_success() { let err_body = response.text().await.unwrap_or_default(); tracing::error!("Summarization API error: {}", err_body); return Err(StatusCode::INTERNAL_SERVER_ERROR); } let gpt_data: serde_json::Value = response.json().await.map_err(|e| { tracing::error!("Summarization JSON parse failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let summary = gpt_data["choices"][0]["message"]["content"] .as_str() .unwrap_or("") .trim(); // 3. Update lesson let updated_lesson = sqlx::query_as::<_, Lesson>("UPDATE lessons SET summary = $1 WHERE id = $2 RETURNING *") .bind(summary) .bind(id) .fetch_one(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; log_action( &pool, org_ctx.id, claims.sub, "SUMMARY_GENERATED", "Lesson", id, json!({}), ) .await; Ok(Json(updated_lesson)) } pub async fn generate_quiz( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { tracing::info!("Received quiz generation request for lesson: {}", id); // 1. Fetch lesson 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) .await .map_err(|_| StatusCode::NOT_FOUND)?; let transcription_text = lesson .transcription .as_ref() .and_then(|t| t["en"].as_str()) .unwrap_or(""); if transcription_text.is_empty() { tracing::warn!( "Cannot generate quiz for lesson {}: No transcription found", id ); return Err(StatusCode::BAD_REQUEST); } // 2. Configuration let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let client = reqwest::Client::new(); let (url, auth_header, model) = if provider == "local" { let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string()); ( format!("{}/v1/chat/completions", base_url), "".to_string(), model, ) } else { let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; ( "https://api.openai.com/v1/chat/completions".to_string(), format!("Bearer {}", api_key), "gpt-4o".to_string(), ) }; let mut request = client .post(&url) .json(&json!({ "model": model, "messages": [ { "role": "system", "content": "You are an educational content designer. Generate 3 multiple-choice questions based on the lesson transcription. Return ONLY a JSON object with a field 'blocks' which is an array. Each block in the array must follow this exact structure: { \"id\": \"string-uuid\", \"type\": \"quiz\", \"title\": \"Quiz: Concept Check\", \"quiz_data\": { \"questions\": [ { \"id\": \"q-string\", \"type\": \"multiple-choice\", \"question\": \"String\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\", \"Option 4\"], \"correctAnswer\": 0, \"explanation\": \"Explain why the answer is correct.\" } ] } }" }, { "role": "user", "content": transcription_text } ], "response_format": { "type": "json_object" } })); if !auth_header.is_empty() { request = request.header("Authorization", auth_header); } let response = request.send().await.map_err(|e| { tracing::error!("Quiz generation request failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; if !response.status().is_success() { let err_body = response.text().await.unwrap_or_default(); tracing::error!("Quiz API error: {}", err_body); return Err(StatusCode::INTERNAL_SERVER_ERROR); } let quiz_data: serde_json::Value = response .json() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let quiz_json_str = quiz_data["choices"][0]["message"]["content"] .as_str() .unwrap_or("{}"); let mut quiz_data_parsed: serde_json::Value = serde_json::from_str(quiz_json_str).unwrap_or(json!({})); // Ensure we return just the blocks array as the frontend expects let quiz_blocks = quiz_data_parsed .get_mut("blocks") .cloned() .unwrap_or(json!([])); log_action( &pool, org_ctx.id, claims.sub, "QUIZ_GENERATED", "Lesson", id, json!({}), ) .await; Ok(Json(quiz_blocks)) } pub async fn get_lesson( Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { // Join to ensure lesson belongs to the organization let lesson = sqlx::query_as::<_, Lesson>("SELECT l.* FROM lessons l JOIN modules m ON l.module_id = m.id JOIN courses c ON m.course_id = c.id WHERE l.id = $1 AND c.organization_id = $2") .bind(id) .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(lesson)) } pub async fn update_lesson( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, headers: axum::http::HeaderMap, Path(id): Path, Json(payload): Json, ) -> Result, StatusCode> { let title = payload.get("title").and_then(|t| t.as_str()); let content_type = payload.get("content_type").and_then(|t| t.as_str()); let content_url = payload.get("content_url").and_then(|t| t.as_str()); let position = payload .get("position") .and_then(|v| v.as_i64()) .map(|v| v as i32); let is_graded = payload.get("is_graded").and_then(|v| v.as_bool()); let max_attempts = payload .get("max_attempts") .and_then(|v| v.as_i64()) .map(|v| v as i32); let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool()); let metadata = payload.get("metadata").cloned(); let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str()); let summary = payload.get("summary").and_then(|v| v.as_str()); let content_blocks = payload.get("content_blocks").cloned(); let transcription = payload.get("transcription").cloned(); let clear_grading_category = payload .get("grading_category_id") .map(|v| v.is_null()) .unwrap_or(false); let grading_category_id = payload .get("grading_category_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()); let clear_due_date = payload .get("due_date") .map(|v| v.is_null()) .unwrap_or(false); let due_date = payload .get("due_date") .and_then(|v| v.as_str()) .and_then(|s| s.parse::>().ok()); let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) .map(|s| s.to_string()); let ua = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); crate::db_util::set_session_context( &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, ua, Some("USER_EVENT".to_string()), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let lesson = sqlx::query_as::<_, Lesson>( "SELECT * FROM fn_update_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)" ) .bind(id) .bind(org_ctx.id) .bind(title) .bind(content_type) .bind(content_url) .bind(content_blocks) .bind(transcription) .bind(metadata) .bind(is_graded) .bind(grading_category_id) .bind(max_attempts) .bind(allow_retry) .bind(position) .bind(due_date) .bind(important_date_type) .bind(summary) .bind(clear_due_date) .bind(clear_grading_category) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("Update lesson failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(lesson)) } // Grading Policies pub async fn get_grading_categories( Org(org_ctx): Org, State(pool): State, Path(course_id): Path, ) -> Result>, (StatusCode, String)> { let categories = sqlx::query_as::<_, common::models::GradingCategory>( "SELECT * FROM grading_categories WHERE course_id = $1 AND course_id IN (SELECT id FROM courses WHERE organization_id = $2) ORDER BY created_at" ) .bind(course_id) .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(categories)) } pub async fn create_grading_category( State(pool): State, Org(org_ctx): Org, Json(payload): Json, ) -> Result, (StatusCode, String)> { let category = sqlx::query_as::<_, common::models::GradingCategory>( "INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count) VALUES ($1, $2, $3, $4, $5) RETURNING *", ) .bind(org_ctx.id) .bind(payload.course_id) .bind(payload.name) .bind(payload.weight) .bind(payload.drop_count) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(category)) } pub async fn delete_grading_category( Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result { sqlx::query("DELETE FROM grading_categories WHERE id = $1 AND organization_id = $2") .bind(id) .bind(org_ctx.id) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(StatusCode::OK) } pub async fn log_action( pool: &PgPool, organization_id: Uuid, user_id: Uuid, action: &str, entity_type: &str, entity_id: Uuid, changes: serde_json::Value, ) { let _ = sqlx::query( "INSERT INTO audit_logs (user_id, organization_id, action, entity_type, entity_id, changes) VALUES ($1, $2, $3, $4, $5, $6)" ) .bind(user_id) .bind(organization_id) .bind(action) .bind(entity_type) .bind(entity_id) .bind(changes) .execute(pool) .await; } pub async fn get_course( Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { 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) .await .map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(course)) } pub async fn get_modules( Org(org_ctx): Org, State(pool): State, Query(query): Query, ) -> Result>, StatusCode> { let modules = match query.course_id { Some(course_id) => { sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 AND course_id IN (SELECT id FROM courses WHERE organization_id = $2) ORDER BY position") .bind(course_id) .bind(org_ctx.id) .fetch_all(&pool) .await } None => { sqlx::query_as::<_, Module>("SELECT m.* FROM modules m JOIN courses c ON m.course_id = c.id WHERE c.organization_id = $1 ORDER BY m.position") .bind(org_ctx.id) .fetch_all(&pool) .await } } .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(modules)) } pub async fn get_lessons( Org(org_ctx): Org, State(pool): State, Query(query): Query, ) -> Result>, StatusCode> { let lessons = match query.module_id { Some(module_id) => { sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE module_id = $1 AND module_id IN (SELECT m.id FROM modules m JOIN courses c ON m.course_id = c.id WHERE c.organization_id = $2) ORDER BY position") .bind(module_id) .bind(org_ctx.id) .fetch_all(&pool) .await } None => { sqlx::query_as::<_, Lesson>("SELECT l.* FROM lessons l JOIN modules m ON l.module_id = m.id JOIN courses c ON m.course_id = c.id WHERE c.organization_id = $1 ORDER BY l.position") .bind(org_ctx.id) .fetch_all(&pool) .await } } .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(lessons)) } #[derive(Deserialize, Serialize)] pub struct ReorderPayload { pub items: Vec, } #[derive(Deserialize, Serialize)] pub struct ReorderItem { pub id: Uuid, pub position: i32, } pub async fn reorder_modules( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result { let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) .map(|s| s.to_string()); let ua = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); crate::db_util::set_session_context( &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, ua, Some("USER_EVENT".to_string()), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; sqlx::query("CALL pr_reorder_modules($1, $2)") .bind(org_ctx.id) .bind(serde_json::to_value(&payload.items).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?) .execute(&mut *tx) .await .map_err(|e| { tracing::error!("Reorder modules failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) } pub async fn reorder_lessons( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, headers: axum::http::HeaderMap, Json(payload): Json, ) -> Result { let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) .map(|s| s.to_string()); let ua = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); crate::db_util::set_session_context( &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, ua, Some("USER_EVENT".to_string()), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; sqlx::query("CALL pr_reorder_lessons($1, $2)") .bind(org_ctx.id) .bind(serde_json::to_value(&payload.items).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?) .execute(&mut *tx) .await .map_err(|e| { tracing::error!("Reorder lessons failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) } #[derive(Debug, Serialize)] pub struct UploadResponse { pub id: Uuid, pub filename: String, pub url: String, } pub async fn upload_asset( Org(org_ctx): Org, State(pool): State, mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, String)> { tracing::info!("Starting upload_asset for org: {}", org_ctx.id); let mut filename = String::new(); let mut data = Vec::new(); let mut mimetype = String::new(); while let Some(field) = multipart .next_field() .await .map_err(|e: axum::extract::multipart::MultipartError| { (StatusCode::BAD_REQUEST, e.to_string()) })? { let name = field.name().unwrap_or_default().to_string(); if name == "file" { filename = field.file_name().unwrap_or("unnamed").to_string(); mimetype = field .content_type() .unwrap_or("application/octet-stream") .to_string(); data = field .bytes() .await .map_err(|e: axum::extract::multipart::MultipartError| { (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) })? .to_vec(); } } if data.is_empty() { return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string())); } let asset_id = Uuid::new_v4(); let extension = std::path::Path::new(&filename) .extension() .and_then(|s| s.to_str()) .unwrap_or(""); let storage_filename = format!("{}.{}", asset_id, extension); let storage_path = format!("uploads/{}", storage_filename); // Ensure uploads directory exists tokio::fs::create_dir_all("uploads") .await .map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Write file tokio::fs::write(&storage_path, data) .await .map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Record in DB let size_bytes = tokio::fs::metadata(&storage_path) .await .map(|m| m.len() as i64) .unwrap_or(0); sqlx::query( "INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id) VALUES ($1, $2, $3, $4, $5, $6)" ) .bind(asset_id) .bind(&filename) .bind(storage_path) .bind(mimetype) .bind(size_bytes) .bind(org_ctx.id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let url = format!("/assets/{}", storage_filename); tracing::info!("Upload successful: {} -> {}", filename, url); Ok(Json(UploadResponse { id: asset_id, filename, url, })) } #[derive(Deserialize)] pub struct AuthPayload { pub email: String, pub password: String, pub full_name: Option, pub role: Option, pub organization_name: Option, } pub async fn register( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { 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("User") .to_string() }); let role = payload.role.unwrap_or_else(|| "instructor".to_string()); // Find or create organization based on email domain or use default let org_name = payload.organization_name.unwrap_or_default(); let mut tx = pool .begin() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let user = sqlx::query_as::<_, User>("SELECT * FROM fn_register_user($1, $2, $3, $4, $5)") .bind(&payload.email) .bind(password_hash) .bind(full_name) .bind(&role) .bind(&org_name) .fetch_one(&mut *tx) .await .map_err(|e: sqlx::Error| { tracing::error!("Failed to create user: {}", e); ( StatusCode::CONFLICT, format!("User already exists or DB error: {}", e), ) })?; tx.commit() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let token = create_jwt(user.id, user.organization_id, &user.role).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into(), ) })?; Ok(Json(AuthResponse { user: UserResponse { id: user.id, email: user.email, full_name: user.full_name, role: user.role, organization_id: user.organization_id, xp: user.xp, level: user.level, avatar_url: user.avatar_url, bio: user.bio, language: user.language, }, token, })) } pub async fn login( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { let user = sqlx::query_as::<_, User>("SELECT * FROM fn_get_user_by_email($1)") .bind(&payload.email) .fetch_one(&pool) .await .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid credentials".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, &user.role).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".into(), ) })?; Ok(Json(AuthResponse { user: UserResponse { id: user.id, email: user.email, full_name: user.full_name, role: user.role, organization_id: user.organization_id, xp: user.xp, level: user.level, avatar_url: user.avatar_url, bio: user.bio, language: user.language, }, token, })) } pub async fn get_course_analytics( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result, (StatusCode, String)> { // 1. Fetch Course to check ownership 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) .await .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; // 2. 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 lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let res = client .get(format!("{}/courses/{}/analytics", lms_url, 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::() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(analytics)) } pub async fn get_advanced_analytics( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result, (StatusCode, String)> { // 1. Fetch Course to check ownership 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) .await .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; // 2. 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 lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let res = client .get(format!("{}/courses/{}/analytics/advanced", lms_url, 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 advanced analytics from LMS".into(), )); } let analytics = res .json::() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(analytics)) } pub async fn get_lesson_heatmap( Org(org_ctx): Org, _claims: common::auth::Claims, State(_pool): State, Path(lesson_id): Path, ) -> Result>, (StatusCode, String)> { let client = reqwest::Client::new(); let lms_url = env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); let res = client .get(format!("{}/lessons/{}/heatmap", lms_url, lesson_id)) .header("X-Organization-Id", org_ctx.id.to_string()) .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 heatmap from LMS".into(), )); } let heatmap = res .json::>() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(heatmap)) } #[derive(Deserialize)] pub struct AuditQuery { pub page: Option, pub limit: Option, } pub async fn get_audit_logs( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Query(query): Query, ) -> Result>, (StatusCode, String)> { // 1. RBAC check if claims.role != "admin" { return Err(( StatusCode::FORBIDDEN, "Only admins can view audit logs".into(), )); } // 2. Query (filtered by organization) let limit = query.limit.unwrap_or(50); let offset = (query.page.unwrap_or(1) - 1) * limit; let logs = sqlx::query_as::<_, common::models::AuditLogResponse>( r#" SELECT a.id, a.user_id, u.full_name as user_full_name, a.action, a.entity_type, a.entity_id, a.changes, a.created_at FROM audit_logs a LEFT JOIN users u ON a.user_id = u.id WHERE a.organization_id = $3 ORDER BY a.created_at DESC LIMIT $1 OFFSET $2 "# ) .bind(limit) .bind(offset) .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(logs)) } pub async fn get_organization( Org(org_ctx): Org, State(pool): State, ) -> Result, StatusCode> { let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; Ok(Json(org)) } pub async fn get_me( claims: Claims, State(pool): State, ) -> Result, (StatusCode, String)> { let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await .map_err(|_| (StatusCode::NOT_FOUND, "Usuario no encontrado".to_string()))?; Ok(Json(UserResponse { id: user.id, email: user.email, full_name: user.full_name, role: user.role, organization_id: user.organization_id, xp: user.xp, level: user.level, avatar_url: user.avatar_url, bio: user.bio, language: user.language, })) } // SSO Configuration Management pub async fn get_sso_config( Org(org_ctx): Org, claims: Claims, State(pool): State, ) -> Result>, (StatusCode, String)> { if claims.role != "admin" { return Err(( StatusCode::FORBIDDEN, "Solo los administradores pueden ver la configuración de SSO".to_string(), )); } let config = sqlx::query_as::<_, common::models::OrganizationSSOConfig>( "SELECT * FROM organization_sso_configs WHERE organization_id = $1", ) .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(config)) } pub async fn update_sso_config( Org(org_ctx): Org, claims: Claims, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { if claims.role != "admin" { return Err(( StatusCode::FORBIDDEN, "Solo los administradores pueden configurar SSO".to_string(), )); } let issuer_url = payload.get("issuer_url").and_then(|v| v.as_str()).ok_or(( StatusCode::BAD_REQUEST, "issuer_url es requerido".to_string(), ))?; let client_id = payload.get("client_id").and_then(|v| v.as_str()).ok_or(( StatusCode::BAD_REQUEST, "client_id es requerido".to_string(), ))?; let client_secret = payload .get("client_secret") .and_then(|v| v.as_str()) .ok_or(( StatusCode::BAD_REQUEST, "client_secret es requerido".to_string(), ))?; let enabled = payload .get("enabled") .and_then(|v| v.as_bool()) .unwrap_or(false); let config = sqlx::query_as::<_, common::models::OrganizationSSOConfig>( "INSERT INTO organization_sso_configs (organization_id, issuer_url, client_id, client_secret, enabled, updated_at) VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT (organization_id) DO UPDATE SET issuer_url = EXCLUDED.issuer_url, client_id = EXCLUDED.client_id, client_secret = EXCLUDED.client_secret, enabled = EXCLUDED.enabled, updated_at = NOW() RETURNING *" ) .bind(org_ctx.id) .bind(issuer_url) .bind(client_id) .bind(client_secret) .bind(enabled) .fetch_all(&pool) .await; // We use fetch_all + next for slightly better error handling in this complex query let config = config .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .into_iter() .next() .ok_or(( StatusCode::INTERNAL_SERVER_ERROR, "Failed to update SSO config".to_string(), ))?; Ok(Json(config)) } pub async fn sso_login_init( Path(org_id): Path, State(pool): State, ) -> Result { let config = sqlx::query_as::<_, common::models::OrganizationSSOConfig>( "SELECT * FROM organization_sso_configs WHERE organization_id = $1 AND enabled = TRUE", ) .bind(org_id) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or(( StatusCode::NOT_FOUND, "SSO no configurado o deshabilitado para esta organización".to_string(), ))?; let issuer_url = IssuerUrl::new(config.issuer_url.clone()).map_err(|e| { ( StatusCode::BAD_REQUEST, format!("Invalid issuer URL: {}", e), ) })?; let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, async_http_client) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to discover OIDC provider: {}", e), ) })?; let client = CoreClient::from_provider_metadata( provider_metadata, ClientId::new(config.client_id.clone()), Some(ClientSecret::new(config.client_secret.clone())), ) .set_redirect_uri( RedirectUrl::new(format!( "{}/auth/sso/callback", env::var("CMS_API_URL").unwrap_or_else(|_| "http://localhost:3001".to_string()) )) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?, ); let (auth_url, csrf_token, nonce) = client .authorize_url( AuthenticationFlow::::AuthorizationCode, CsrfToken::new_random, Nonce::new_random, ) .add_scope(Scope::new("openid".to_string())) .add_scope(Scope::new("email".to_string())) .add_scope(Scope::new("profile".to_string())) .url(); // Store state and nonce sqlx::query("INSERT INTO sso_states (state_token, organization_id, nonce) VALUES ($1, $2, $3)") .bind(csrf_token.secret()) .bind(org_id) .bind(nonce.secret()) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(axum::response::Redirect::to(auth_url.as_str())) } pub async fn sso_callback( Query(params): Query, State(pool): State, ) -> Result { // 1. Verify state and get org_id/nonce let row: (Uuid, String) = sqlx::query_as( "DELETE FROM sso_states WHERE state_token = $1 RETURNING organization_id, nonce", ) .bind(¶ms.state) .fetch_one(&pool) .await .map_err(|_| { ( StatusCode::BAD_REQUEST, "Invalid state or timeout".to_string(), ) })?; let org_id = row.0; let nonce = Nonce::new(row.1); // 2. Fetch config let config = sqlx::query_as::<_, common::models::OrganizationSSOConfig>( "SELECT * FROM organization_sso_configs WHERE organization_id = $1", ) .bind(org_id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 3. Exchange code for token let issuer_url = IssuerUrl::new(config.issuer_url.clone()) .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, async_http_client) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let client = CoreClient::from_provider_metadata( provider_metadata, ClientId::new(config.client_id), Some(ClientSecret::new(config.client_secret)), ) .set_redirect_uri( RedirectUrl::new(format!( "{}/auth/sso/callback", env::var("CMS_API_URL").unwrap_or_else(|_| "http://localhost:3001".to_string()) )) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?, ); let token_response = client .exchange_code(AuthorizationCode::new(params.code)) .request_async(async_http_client) .await .map_err(|e| { ( StatusCode::UNAUTHORIZED, format!("Token exchange failed: {}", e), ) })?; // 4. Extract user info from ID Token let id_token = token_response .id_token() .ok_or((StatusCode::UNAUTHORIZED, "Missing ID token".to_string()))?; let claims = id_token .claims(&client.id_token_verifier(), &nonce) .map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid ID token: {}", e)))?; let email = claims .email() .ok_or(( StatusCode::UNAUTHORIZED, "Missing email in ID token".to_string(), ))? .to_string(); let name = claims .name() .and_then(|n| n.get(None)) .map(|n| n.to_string()) .unwrap_or_else(|| email.split('@').next().unwrap_or("User").to_string()); // 5. User Provisioning let mut tx = pool .begin() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let user = sqlx::query_as::<_, User>( "SELECT * FROM users WHERE organization_id = $1 AND lower(email) = lower($2)", ) .bind(org_id) .bind(&email) .fetch_optional(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let user = match user { Some(u) => u, None => { // Create user sqlx::query_as::<_, User>( "INSERT INTO users (organization_id, email, password_hash, full_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING *", ) .bind(org_id) .bind(&email) .bind("SSO_MANAGED") // No password for SSO users .bind(&name) .bind("student") // Default role .fetch_one(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? } }; tx.commit() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 6. Generate JWT let token = common::auth::create_jwt(user.id, user.organization_id, &user.role).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, "JWT generation failed".to_string(), ) })?; // Determine where to redirect based on user role let frontend_url = if user.role == "student" { env::var("EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3003".to_string()) } else { env::var("STUDIO_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) }; Ok(axum::response::Redirect::to(&format!( "{}/auth/callback?token={}", frontend_url, token ))) } #[derive(Serialize)] pub struct ModuleWithLessons { #[serde(flatten)] pub module: Module, pub lessons: Vec, } #[derive(Serialize)] pub struct CourseWithOutline { #[serde(flatten)] pub course: Course, pub modules: Vec, } pub async fn get_course_outline( Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { // 1. Fetch Course 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) .await .map_err(|_| StatusCode::NOT_FOUND)?; // 2. Fetch Modules let modules = sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") .bind(id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let mut modules_with_lessons = Vec::new(); // 3. Fetch Lessons (This could be optimized with a single query, but N+1 is acceptable for course editor scale) for module in modules { let lessons = sqlx::query_as::<_, Lesson>( "SELECT * FROM lessons WHERE module_id = $1 ORDER BY position", ) .bind(module.id) .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; modules_with_lessons.push(ModuleWithLessons { module, lessons }); } Ok(Json(CourseWithOutline { course, modules: modules_with_lessons, })) } pub async fn update_module( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, headers: axum::http::HeaderMap, Path(id): Path, Json(payload): Json, ) -> Result, StatusCode> { let title = payload.get("title").and_then(|t| t.as_str()); let position = payload .get("position") .and_then(|v| v.as_i64()) .map(|v| v as i32); let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) .map(|s| s.to_string()); let ua = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); crate::db_util::set_session_context( &mut *tx, Some(claims.sub), Some(org_ctx.id), ip, ua, Some("USER_EVENT".to_string()), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Fetch existing to handle COALESCE if not handled in function // For now, let's just use the function logic. // Wait, the DB function I wrote doesn't use COALESCE, it just sets values. // I should fix the DB function or handle COALESCE here. // Let's fix the DB function to use COALESCE for optional updates. let module = sqlx::query_as::<_, Module>("SELECT * FROM fn_update_module($1, $2, $3, $4)") .bind(id) .bind(org_ctx.id) .bind(title) .bind(position) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("Update module failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(module)) } pub async fn delete_module( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, headers: axum::http::HeaderMap, Path(id): Path, ) -> Result { let mut conn = pool .acquire() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) .map(|s| s.to_string()); let ua = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); crate::db_util::set_session_context( &mut conn, Some(claims.sub), Some(org_ctx.id), ip, ua, Some("USER_EVENT".to_string()), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_module($1, $2)") .bind(id) .bind(org_ctx.id) .fetch_one(&mut *conn) .await .map_err(|e| { tracing::error!("Delete module failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; if !success { return Err(StatusCode::NOT_FOUND); } Ok(StatusCode::NO_CONTENT) } pub async fn delete_lesson( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, headers: axum::http::HeaderMap, Path(id): Path, ) -> Result { if claims.role != "admin" { return Err(StatusCode::FORBIDDEN); } let mut conn = pool .acquire() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .or_else(|| headers.get("x-real-ip").and_then(|h| h.to_str().ok())) .map(|s| s.to_string()); let ua = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()); crate::db_util::set_session_context( &mut conn, Some(claims.sub), Some(org_ctx.id), ip, ua, Some("USER_EVENT".to_string()), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let success = sqlx::query_scalar::<_, bool>("SELECT fn_delete_lesson($1, $2)") .bind(id) .bind(org_ctx.id) .fetch_one(&mut *conn) .await .map_err(|e| { tracing::error!("Delete lesson failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; if !success { return Err(StatusCode::NOT_FOUND); } Ok(StatusCode::NO_CONTENT) } // User Management pub async fn get_all_users( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, ) -> Result>, StatusCode> { if claims.role != "admin" { return Err(StatusCode::FORBIDDEN); } let users = sqlx::query_as::<_, UserResponse>( "SELECT id, email, full_name, role, organization_id, xp, level, avatar_url, bio, language FROM users WHERE organization_id = $1", ) .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| { tracing::error!("Failed to fetch users: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(users)) } pub async fn update_user( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { if claims.role != "admin" && claims.sub != id { return Err((StatusCode::FORBIDDEN, "Not authorized".into())); } let role = payload.get("role").and_then(|r| r.as_str()); let full_name = payload.get("full_name").and_then(|f| f.as_str()); let avatar_url = payload.get("avatar_url").and_then(|v| v.as_str()); let bio = payload.get("bio").and_then(|v| v.as_str()); let language = payload.get("language").and_then(|v| v.as_str()); let organization_id = payload .get("organization_id") .and_then(|o| o.as_str()) .and_then(|o| Uuid::parse_str(o).ok()); let user = sqlx::query_as::<_, User>( "UPDATE users SET role = COALESCE($1, role), organization_id = COALESCE($2, organization_id), full_name = COALESCE($3, full_name), avatar_url = COALESCE($4, avatar_url), bio = COALESCE($5, bio), language = COALESCE($6, language) WHERE id = $7 AND organization_id = $8 RETURNING *" ) .bind(role) .bind(organization_id) .bind(full_name) .bind(avatar_url) .bind(bio) .bind(language) .bind(id) .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; log_action( &pool, org_ctx.id, claims.sub, "UPDATE_USER", "User", id, payload, ) .await; Ok(Json(UserResponse { id: user.id, email: user.email, full_name: user.full_name, role: user.role, organization_id: user.organization_id, xp: user.xp, level: user.level, avatar_url: user.avatar_url, bio: user.bio, language: user.language, })) } // Organizations Management (Plural/Admin) pub async fn get_organizations( claims: common::auth::Claims, State(pool): State, ) -> Result>, StatusCode> { if claims.role != "admin" { return Err(StatusCode::FORBIDDEN); } let orgs = sqlx::query_as::<_, Organization>("SELECT * FROM organizations ORDER BY created_at DESC") .fetch_all(&pool) .await .map_err(|e| { tracing::error!("Failed to fetch organizations: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(orgs)) } pub async fn create_organization( claims: common::auth::Claims, State(pool): State, Json(payload): Json, ) -> Result, StatusCode> { if claims.role != "admin" { return Err(StatusCode::FORBIDDEN); } let name = payload .get("name") .and_then(|v| v.as_str()) .ok_or(StatusCode::BAD_REQUEST)?; let domain = payload.get("domain").and_then(|v| v.as_str()); let org = sqlx::query_as::<_, Organization>( "INSERT INTO organizations (name, domain) VALUES ($1, $2) RETURNING *", ) .bind(name) .bind(domain) .fetch_one(&pool) .await .map_err(|e| { tracing::error!("Failed to create organization: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(org)) } #[derive(serde::Deserialize)] pub struct CreateWebhookPayload { pub url: String, pub events: Vec, pub secret: Option, } pub async fn get_webhooks( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, ) -> Result>, (StatusCode, String)> { if claims.role != "admin" { return Err((StatusCode::FORBIDDEN, "Admin access required".into())); } let webhooks = sqlx::query_as::<_, common::models::Webhook>( "SELECT * FROM webhooks WHERE organization_id = $1 ORDER BY created_at DESC", ) .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(webhooks)) } pub async fn create_webhook( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { if claims.role != "admin" { return Err((StatusCode::FORBIDDEN, "Admin access required".into())); } let webhook = sqlx::query_as::<_, common::models::Webhook>( r#" INSERT INTO webhooks (organization_id, url, events, secret) VALUES ($1, $2, $3, $4) RETURNING * "#, ) .bind(org_ctx.id) .bind(payload.url) .bind(payload.events) .bind(payload.secret) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; log_action( &pool, org_ctx.id, claims.sub, "CREATE_WEBHOOK", "Webhook", webhook.id, serde_json::to_value(&webhook).unwrap(), ) .await; Ok(Json(webhook)) } pub async fn delete_webhook( Org(org_ctx): Org, claims: common::auth::Claims, State(pool): State, Path(id): Path, ) -> Result { if claims.role != "admin" { return Err((StatusCode::FORBIDDEN, "Admin access required".into())); } let result = sqlx::query("DELETE FROM webhooks WHERE id = $1 AND organization_id = $2") .bind(id) .bind(org_ctx.id) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { return Err((StatusCode::NOT_FOUND, "Webhook not found".into())); } log_action( &pool, org_ctx.id, claims.sub, "DELETE_WEBHOOK", "Webhook", id, serde_json::json!({}), ) .await; Ok(StatusCode::OK) } // --- Course Portability --- pub async fn export_course( Org(org_ctx): Org, _claims: Claims, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { // 1. Verify access (ensure course belongs to org) let exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM courses WHERE id = $1 AND organization_id = $2)", ) .bind(id) .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if !exists { return Err(StatusCode::NOT_FOUND); } // 2. Export recursively let export = exporter::get_course_data(&pool, id).await.map_err(|e| { tracing::error!("Export failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(export)) } pub async fn import_course( Org(org_ctx): Org, claims: Claims, State(pool): State, Json(payload): Json, ) -> Result, StatusCode> { let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // 1. Create Course let new_course = sqlx::query_as::<_, Course>( "INSERT INTO courses ( organization_id, instructor_id, title, pacing_mode, description, passing_percentage, certificate_template, start_date, end_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *", ) .bind(org_ctx.id) .bind(claims.sub) .bind(format!("{} (Importado)", payload.course.title)) .bind(payload.course.pacing_mode) .bind(payload.course.description) .bind(payload.course.passing_percentage) .bind(payload.course.certificate_template) .bind(payload.course.start_date) .bind(payload.course.end_date) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("Failed to create imported course: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; // 2. Import Grading Categories and create mapping let mut cat_map = std::collections::HashMap::new(); for old_cat in payload.grading_categories { let new_cat = sqlx::query_as::<_, common::models::GradingCategory>( "INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count) VALUES ($1, $2, $3, $4, $5) RETURNING *", ) .bind(org_ctx.id) .bind(new_course.id) .bind(old_cat.name) .bind(old_cat.weight) .bind(old_cat.drop_count) .fetch_one(&mut *tx) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; cat_map.insert(old_cat.id, new_cat.id); } // 3. Import Modules & Lessons for module_data in payload.modules { let new_module = sqlx::query_as::<_, Module>( "INSERT INTO modules (course_id, organization_id, title, position) VALUES ($1, $2, $3, $4) RETURNING *", ) .bind(new_course.id) .bind(org_ctx.id) .bind(module_data.module.title) .bind(module_data.module.position) .fetch_one(&mut *tx) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; for lesson in module_data.lessons { let new_cat_id = lesson .grading_category_id .and_then(|id| cat_map.get(&id)) .cloned(); sqlx::query( "INSERT INTO lessons ( module_id, course_id, organization_id, title, content_type, content_url, position, is_graded, metadata, summary, transcription, grading_category_id, max_attempts ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", ) .bind(new_module.id) .bind(new_course.id) .bind(org_ctx.id) .bind(lesson.title) .bind(lesson.content_type) .bind(lesson.content_url) .bind(lesson.position) .bind(lesson.is_graded) .bind(lesson.metadata) .bind(lesson.summary) .bind(lesson.transcription) .bind(new_cat_id) .bind(lesson.max_attempts) .execute(&mut *tx) .await .map_err(|e| { tracing::error!("Failed to import lesson: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; } } tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; log_action( &pool, org_ctx.id, claims.sub, "COURSE_IMPORTED", "Course", new_course.id, serde_json::json!({ "original_title": payload.course.title }), ) .await; Ok(Json(new_course)) } // --- AI Course Generation --- #[derive(Deserialize)] pub struct GenerateCoursePayload { pub prompt: String, pub target_organization_id: Option, } pub async fn generate_course( Org(org_ctx): Org, claims: Claims, State(pool): State, Json(payload): Json, ) -> Result, StatusCode> { tracing::info!( "Starting AI course generation for prompt: {}", payload.prompt ); // 1. Determine target org let target_org_id = payload.target_organization_id.unwrap_or(org_ctx.id); // 2. AI Setup let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); let client = reqwest::Client::new(); let (url, auth_header, model) = if provider == "local" { let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string()); ( format!("{}/v1/chat/completions", base_url), "".to_string(), model, ) } else { let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; ( "https://api.openai.com/v1/chat/completions".to_string(), format!("Bearer {}", api_key), "gpt-4o".to_string(), ) }; let system_prompt = r#"You are a curriculum expert. Generate a course in JSON. Structure: { "title": "Course Title", "description": "Description", "modules": [ { "title": "Module Title", "lessons": [ { "title": "Lesson Title", "content_type": "text" } ] } ] } RULES: 1. content_type MUST be one of: text, video, quiz. 2. NO nested lessons. 3. Return ONLY the JSON object."#; let mut request = client.post(&url).json(&json!({ "model": model, "messages": [ { "role": "system", "content": system_prompt }, { "role": "user", "content": format!("Create a course about: {}", payload.prompt) } ], "response_format": { "type": "json_object" }, "temperature": 0.1 })); if !auth_header.is_empty() { request = request.header("Authorization", auth_header); } let response = request.send().await.map_err(|e| { tracing::error!("LLM request failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; if !response.status().is_success() { let err_body = response.text().await.unwrap_or_default(); tracing::error!("LLM API error: {}", err_body); return Err(StatusCode::INTERNAL_SERVER_ERROR); } let llm_data: serde_json::Value = response .json() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let mut content_str = llm_data["choices"][0]["message"]["content"] .as_str() .unwrap_or("{}") .trim() .to_string(); // Clean markdown code blocks if present if content_str.starts_with("```") { content_str = content_str .lines() .filter(|line| !line.starts_with("```")) .collect::>() .join("\n"); } let result_json: serde_json::Value = serde_json::from_str(&content_str).map_err(|e| { tracing::error!("Failed to parse AI JSON: {}. Content: {}", e, content_str); StatusCode::INTERNAL_SERVER_ERROR })?; // 3. Database Transaction let mut tx = pool .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Create Course let course_title = result_json["title"].as_str().unwrap_or("Untitled Course"); let course_desc = result_json["description"].as_str(); let course = sqlx::query_as::<_, Course>( "INSERT INTO courses (organization_id, instructor_id, title, description, pacing_mode) VALUES ($1, $2, $3, $4, 'self_paced') RETURNING *", ) .bind(target_org_id) .bind(claims.sub) .bind(course_title) .bind(course_desc) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::error!("DB Course creation failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; // Create Modules and Lessons if let Some(modules) = result_json["modules"].as_array() { for (m_idx, m_val) in modules.iter().enumerate() { let m_title = m_val["title"].as_str().unwrap_or("Module"); let module = sqlx::query_as::<_, Module>( "INSERT INTO modules (course_id, organization_id, title, position) VALUES ($1, $2, $3, $4) RETURNING *", ) .bind(course.id) .bind(target_org_id) .bind(m_title) .bind((m_idx + 1) as i32) .fetch_one(&mut *tx) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if let Some(lessons) = m_val["lessons"].as_array() { for (l_idx, l_val) in lessons.iter().enumerate() { let l_title = l_val["title"].as_str().unwrap_or("Lesson"); let l_type = l_val["content_type"].as_str().unwrap_or("text"); sqlx::query( "INSERT INTO lessons (module_id, course_id, organization_id, title, content_type, position) VALUES ($1, $2, $3, $4, $5, $6)" ) .bind(module.id) .bind(course.id) .bind(target_org_id) .bind(l_title) .bind(l_type) .bind((l_idx + 1) as i32) .execute(&mut *tx) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } } } } tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; log_action( &pool, target_org_id, claims.sub, "AI_COURSE_GENERATED", "Course", course.id, json!({ "prompt": payload.prompt }), ) .await; Ok(Json(course)) }