use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, }; use common::models::{ CreateQuestionBankPayload, ImportQuestionFromMySQLPayload, QuestionBank, QuestionBankFilters, QuestionBankType, UpdateQuestionBankPayload, }; use common::{auth::Claims, middleware::Org}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; const QUESTION_BANK_SELECT_COLUMNS: &str = r#" id, organization_id, question_text, question_type, options, correct_answer, explanation, audio_url, audio_text, audio_status, audio_metadata, media_url, media_type, points, difficulty, tags, skill_assessed, source, source_metadata, imported_mysql_id, imported_mysql_course_id, usage_count, last_used_at, is_active, is_archived, created_by, created_at, updated_at, embedding::text AS embedding, embedding_updated_at, source_asset_id, unit_number "#; fn normalize_answer_keywords_value(value: serde_json::Value) -> serde_json::Value { match value { serde_json::Value::Array(items) => serde_json::Value::Array( items .into_iter() .map(normalize_answer_keywords_value) .collect(), ), serde_json::Value::Object(map) => { let answer_text = map .get("answer") .and_then(|v| v.as_str()) .map(|s| s.to_string()); if map.contains_key("keywords") { if let Some(answer) = answer_text { return serde_json::Value::String(answer); } } let normalized_map = map .into_iter() .map(|(k, v)| (k, normalize_answer_keywords_value(v))) .collect(); serde_json::Value::Object(normalized_map) } other => other, } } fn normalize_question_bank_payload_values( options: Option, correct_answer: Option, ) -> (Option, Option) { ( options.map(normalize_answer_keywords_value), correct_answer.map(normalize_answer_keywords_value), ) } async fn connect_mysql_pool(env_var: &str) -> Result { use sqlx::mysql::MySqlPoolOptions; let mysql_url = std::env::var(env_var) .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, format!("{} no configurada", env_var)))?; let mut last_error = String::new(); for attempt in 1..=3 { let result = MySqlPoolOptions::new() // Keep per-request pools small to avoid exhausting remote MySQL. .max_connections(2) .min_connections(0) .acquire_timeout(std::time::Duration::from_secs(15)) .idle_timeout(std::time::Duration::from_secs(30)) .max_lifetime(std::time::Duration::from_secs(300)) .connect(&mysql_url) .await; match result { Ok(pool) => return Ok(pool), Err(e) => { last_error = e.to_string(); tracing::warn!( "Intento de conexión a MySQL {}/3 fallido para {}: {}", attempt, env_var, last_error ); if attempt < 3 { tokio::time::sleep(std::time::Duration::from_secs(2 * attempt)).await; } } } } Err(( StatusCode::INTERNAL_SERVER_ERROR, format!( "Error al conectar a MySQL tras 3 intentos: {}", last_error ), )) } // ==================== Planes de Estudio y Cursos de MySQL ==================== #[derive(Debug, sqlx::FromRow, Serialize, Deserialize)] #[allow(dead_code)] pub struct MySqlStudyPlan { pub id: i32, pub mysql_id: i32, pub organization_id: Uuid, pub name: String, pub course_type: String, pub is_active: bool, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } #[derive(Debug, sqlx::FromRow, Serialize, Deserialize)] #[allow(dead_code)] pub struct MySqlCourse { pub id: i32, pub mysql_id: i32, pub organization_id: Uuid, pub study_plan_id: i32, pub name: String, pub level: Option, pub course_type: String, pub level_calculated: Option, pub is_active: bool, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } /// Guardar o actualizar planes de estudio y cursos de MySQL durante la importación pub async fn save_mysql_courses_and_plans( pool: &PgPool, org_id: Uuid, plans: Vec, courses: Vec, ) -> Result<(), String> { let plans_count = plans.len(); let courses_count = courses.len(); tracing::info!("Guardando {} planes de estudio y {} cursos de MySQL", plans_count, courses_count); // Guardar planes de estudio primero for plan in plans { let course_type = calculate_course_type(&plan.nombre_plan); tracing::debug!("Guardando plan de estudios: {} (ID: {})", plan.nombre_plan, plan.id_plan_de_estudios); // Reflejar la estructura SAM en PostgreSQL usando nombres de columna nativos de SAM. sqlx::query( r#" INSERT INTO sam_study_plans (organization_id, idPlanDeEstudios, Nombre, Activo) VALUES ($1, $2, $3, TRUE) ON CONFLICT (organization_id, idPlanDeEstudios) DO UPDATE SET Nombre = EXCLUDED.Nombre, Activo = EXCLUDED.Activo, updated_at = NOW() "# ) .bind(org_id) .bind(plan.id_plan_de_estudios) .bind(&plan.nombre_plan) .execute(pool) .await .map_err(|e| format!("Error al guardar el reflejo del plan de estudios SAM: {}", e))?; sqlx::query( r#" INSERT INTO mysql_study_plans (mysql_id, organization_id, name, course_type) VALUES ($1, $2, $3, $4) ON CONFLICT (mysql_id) DO UPDATE SET name = EXCLUDED.name, course_type = EXCLUDED.course_type, updated_at = NOW() "# ) .bind(plan.id_plan_de_estudios) .bind(org_id) .bind(&plan.nombre_plan) .bind(&course_type) .execute(pool) .await .map_err(|e| format!("Error al guardar el plan de estudios: {}", e))?; } // Guardar cursos for course in courses { // Determinar el course_type a partir de la duración (40h = regular, 80h = intensivo) let course_type = calculate_course_type_from_duration(course.duracion); let level_calculated = calculate_course_level(course.nivel_curso); tracing::debug!("Guardando curso: {} (ID: {}, ID de Plan: {})", course.nombre_curso, course.id_cursos, course.id_plan_de_estudios); sqlx::query( r#" INSERT INTO sam_courses ( organization_id, idCursos, idPlanDeEstudios, NombreCurso, NivelCurso, Duracion, Activo ) VALUES ($1, $2, $3, $4, $5, $6, TRUE) ON CONFLICT (organization_id, idCursos) DO UPDATE SET idPlanDeEstudios = EXCLUDED.idPlanDeEstudios, NombreCurso = EXCLUDED.NombreCurso, NivelCurso = EXCLUDED.NivelCurso, Duracion = EXCLUDED.Duracion, Activo = EXCLUDED.Activo, updated_at = NOW() "#, ) .bind(org_id) .bind(course.id_cursos) .bind(course.id_plan_de_estudios) .bind(&course.nombre_curso) .bind(course.nivel_curso) .bind(course.duracion) .execute(pool) .await .map_err(|e| format!("Error al guardar el reflejo del curso SAM: {}", e))?; // Obtener study_plan_id de mysql_study_plans let study_plan_id: i32 = sqlx::query_scalar( "SELECT id FROM mysql_study_plans WHERE mysql_id = $1 AND organization_id = $2" ) .bind(course.id_plan_de_estudios) .bind(org_id) .fetch_one(pool) .await .map_err(|e| format!("Error al encontrar el plan de estudios: {}", e))?; sqlx::query( r#" INSERT INTO mysql_courses ( mysql_id, organization_id, study_plan_id, name, level, duracion, course_type, level_calculated ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (mysql_id) DO UPDATE SET name = EXCLUDED.name, level = EXCLUDED.level, duracion = EXCLUDED.duracion, course_type = EXCLUDED.course_type, level_calculated = EXCLUDED.level_calculated, updated_at = NOW() "# ) .bind(course.id_cursos) .bind(org_id) .bind(study_plan_id) .bind(&course.nombre_curso) .bind(course.nivel_curso) .bind(course.duracion) .bind(&course_type) .bind(&level_calculated) .execute(pool) .await .map_err(|e| format!("Error al guardar el curso: {}", e))?; } tracing::info!("Se guardaron con éxito {} planes de estudio y {} cursos", plans_count, courses_count); Ok(()) } fn calculate_course_type(plan_name: &str) -> String { let plan_lower = plan_name.to_lowercase(); if plan_lower.contains("intensive") || plan_lower.contains("intensivo") { "intensive".to_string() } else { "regular".to_string() } } fn calculate_course_type_from_duration(duracion: Option) -> String { match duracion { Some(d) if d as i64 >= 70 => "intensive".to_string(), // 80h or more = intensive _ => "regular".to_string(), // 40h or less = regular } } fn calculate_course_level(nivel: Option) -> String { match nivel { None => "intermediate".to_string(), Some(n) if n <= 2 => "beginner".to_string(), Some(n) if n <= 4 => "beginner_1".to_string(), Some(n) if n <= 6 => "beginner_2".to_string(), Some(n) if n <= 8 => "intermediate".to_string(), Some(n) if n <= 10 => "intermediate_1".to_string(), Some(n) if n <= 12 => "intermediate_2".to_string(), Some(_) => "advanced".to_string(), } } // ==================== Crear ==================== /// POST /api/question-bank - Crear una nueva pregunta en el banco pub async fn create_question( Org(org_ctx): Org, claims: Claims, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { let CreateQuestionBankPayload { question_text, question_type, options, correct_answer, explanation, points, difficulty, tags, media_url, media_type, skill_assessed, } = payload; let (normalized_options, normalized_correct_answer) = normalize_question_bank_payload_values(options, correct_answer); let create_question_sql = format!( r#" INSERT INTO question_bank ( organization_id, created_by, question_text, question_type, options, correct_answer, explanation, points, difficulty, tags, skill_assessed, media_url, media_type, audio_status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 'pending') RETURNING {} "#, QUESTION_BANK_SELECT_COLUMNS ); let question: QuestionBank = sqlx::query_as( &create_question_sql ) .bind(org_ctx.id) .bind(claims.sub) .bind(&question_text) .bind(&question_type) .bind(&normalized_options) .bind(&normalized_correct_answer) .bind(&explanation) .bind(points.unwrap_or(1)) .bind(difficulty.as_deref().unwrap_or("medium")) .bind(tags.as_deref()) .bind(skill_assessed.as_deref()) .bind(media_url.as_deref()) .bind(media_type.as_deref()) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(question)) } // ==================== Listar ==================== /// GET /api/question-bank - Listar preguntas con filtros pub async fn list_questions( Org(org_ctx): Org, State(pool): State, Query(filters): Query, ) -> Result>, (StatusCode, String)> { let questions = if filters.question_type.is_none() && filters.difficulty.is_none() && filters.source.is_none() && filters.search.is_none() && filters.has_audio.is_none() { // Sin filtros - consulta simple sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false ORDER BY created_at DESC", QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .fetch_all(&pool) .await } else if filters.question_type.is_some() { sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false AND question_type = $2 ORDER BY created_at DESC", QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .bind(filters.question_type.unwrap()) .fetch_all(&pool) .await } else if filters.difficulty.is_some() { sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false AND difficulty = $2 ORDER BY created_at DESC", QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .bind(filters.difficulty.as_ref().unwrap()) .fetch_all(&pool) .await } else if filters.source.is_some() { let source_filter = filters.source.as_ref().unwrap().to_lowercase(); match source_filter.as_str() { "mysql" => { sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false AND source IN ('imported-mysql', 'sam-diagnostico') ORDER BY created_at DESC", QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .fetch_all(&pool) .await } "materials" | "materials-zip" => { sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false AND source = 'imported-material' ORDER BY created_at DESC", QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .fetch_all(&pool) .await } "ai" => { sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false AND source IN ('ai-generated', 'rag-ai') ORDER BY created_at DESC", QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .fetch_all(&pool) .await } _ => { sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false AND source = $2 ORDER BY created_at DESC", QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .bind(filters.source.as_ref().unwrap()) .fetch_all(&pool) .await } } } else if filters.has_audio == Some(true) { sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false AND audio_status = 'ready' ORDER BY created_at DESC", QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .fetch_all(&pool) .await } else { // Default fallback sqlx::query_as::<_, QuestionBank>( &format!( "SELECT {} FROM question_bank WHERE organization_id = $1 AND is_archived = false ORDER BY created_at DESC", QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .fetch_all(&pool) .await } .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(questions)) } // ==================== Obtener ==================== /// GET /api/question-bank/{id} - Obtener una sola pregunta pub async fn get_question( Org(org_ctx): Org, Path(id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { let get_question_sql = format!( r#" SELECT {} FROM question_bank WHERE id = $1 AND organization_id = $2 AND is_archived = false "#, QUESTION_BANK_SELECT_COLUMNS ); let question: QuestionBank = sqlx::query_as( &get_question_sql ) .bind(id) .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| match e { sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), })?; Ok(Json(question)) } // ==================== Actualizar ==================== /// PUT /api/question-bank/{id} - Actualizar una pregunta pub async fn update_question( Org(org_ctx): Org, Path(id): Path, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { let UpdateQuestionBankPayload { question_text, question_type, options, correct_answer, explanation, points, difficulty, tags, is_active, is_archived, } = payload; let (normalized_options, normalized_correct_answer) = normalize_question_bank_payload_values(options, correct_answer); let update_question_sql = format!( r#" UPDATE question_bank SET question_text = COALESCE($3, question_text), question_type = COALESCE($4, question_type), options = COALESCE($5, options), correct_answer = COALESCE($6, correct_answer), explanation = COALESCE($7, explanation), points = COALESCE($8, points), difficulty = COALESCE($9, difficulty), tags = COALESCE($10, tags), is_active = COALESCE($11, is_active), is_archived = COALESCE($12, is_archived), updated_at = NOW() WHERE id = $1 AND organization_id = $2 RETURNING {} "#, QUESTION_BANK_SELECT_COLUMNS ); let question: QuestionBank = sqlx::query_as( &update_question_sql ) .bind(id) .bind(org_ctx.id) .bind(question_text) .bind(question_type.map(|t| t.to_string())) .bind(&normalized_options) .bind(&normalized_correct_answer) .bind(&explanation) .bind(points) .bind(difficulty) .bind(tags.as_deref()) .bind(is_active) .bind(is_archived) .fetch_one(&pool) .await .map_err(|e| match e { sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()), _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), })?; Ok(Json(question)) } // ==================== Eliminar ==================== /// DELETE /api/question-bank/{id} - Eliminar (archivar) una pregunta pub async fn delete_question( Org(org_ctx): Org, Path(id): Path, State(pool): State, ) -> Result { let result = sqlx::query( r#" UPDATE question_bank SET is_archived = true, updated_at = NOW() 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, "Pregunta no encontrada".to_string())); } Ok(StatusCode::NO_CONTENT) } // ==================== Importar desde MySQL ==================== /// POST /api/question-bank/import-mysql - Importar preguntas desde el banco de preguntas de MySQL pub async fn import_from_mysql( Org(org_ctx): Org, claims: Claims, State(pool): State, Json(payload): Json, ) -> Result>, (StatusCode, String)> { use serde_json::json; // Conectar a MySQL let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?; // Obtener todos los planes de estudio y cursos de MySQL para sincronizarlos let mysql_plans: Vec = sqlx::query_as( r#" SELECT DISTINCT pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan FROM plandeestudios pe WHERE pe.Activo = 1 ORDER BY pe.Nombre "# ) .fetch_all(&mysql_pool) .await .map_err(|e| { let error_msg = format!("Error al obtener: {}", e); tracing::error!("Error de MySQL: {}", error_msg); tracing::error!("Verifique los nombres de las columnas en su base de datos MySQL (tablas plandeestudios, curso)"); (StatusCode::INTERNAL_SERVER_ERROR, error_msg) })?; tracing::info!("Se obtuvieron {} planes de estudio de MySQL", mysql_plans.len()); let mysql_courses: Vec = sqlx::query_as( r#" SELECT DISTINCT c.idCursos AS id_cursos, c.NombreCurso AS nombre_curso, c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan, c.Duracion AS duracion FROM curso c JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios WHERE c.Activo = 1 AND pe.Activo = 1 ORDER BY pe.Nombre, c.NivelCurso "# ) .fetch_all(&mysql_pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?; tracing::info!("Se obtuvieron {} cursos de MySQL", mysql_courses.len()); // Guardar planes y cursos en PostgreSQL tracing::info!("Guardando planes y cursos en PostgreSQL..."); save_mysql_courses_and_plans(&pool, org_ctx.id, mysql_plans, mysql_courses) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al guardar cursos/planes: {}", e)))?; // Obtener preguntas de MySQL let mysql_questions: Vec = if payload.import_all.unwrap_or(false) { // Importar TODAS las preguntas (sin límite) sqlx::query_as( r#" SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo, c.idCursos, c.NombreCurso, pe.idPlanDeEstudios, pe.Nombre as PlanNombre FROM bancopreguntas bp JOIN curso c ON bp.idCursos = c.idCursos JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios WHERE bp.activo = 1 AND c.Activo = 1 AND pe.Activo = 1 ORDER BY bp.idPregunta "# ) .fetch_all(&mysql_pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))? } else if let Some(course_id) = payload.mysql_course_id { // Importar todas las preguntas para un curso específico (sin límite) sqlx::query_as( r#" SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo, c.idCursos, c.NombreCurso, pe.idPlanDeEstudios, pe.Nombre as PlanNombre FROM bancopreguntas bp JOIN curso c ON bp.idCursos = c.idCursos JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios WHERE bp.idCursos = ? AND bp.activo = 1 AND c.Activo = 1 AND pe.Activo = 1 ORDER BY bp.idPregunta "# ) .bind(course_id) .fetch_all(&mysql_pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))? } else if let Some(question_ids) = payload.question_ids { // Obtener IDs de preguntas específicas - usar enfoque simple let mut imported_questions: Vec = vec![]; for q_id in question_ids { let mq: Option = sqlx::query_as( r#" SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo, c.idCursos, c.NombreCurso, pe.idPlanDeEstudios, pe.Nombre as PlanNombre FROM bancopreguntas bp JOIN curso c ON bp.idCursos = c.idCursos JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios WHERE bp.idPregunta = ? AND bp.activo = 1 AND c.Activo = 1 AND pe.Activo = 1 "# ) .bind(q_id) .fetch_optional(&mysql_pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener la pregunta {}: {}", q_id, e)))?; if let Some(question) = mq { // Mapear el tipo de pregunta de MySQL al tipo de pregunta de la plataforma let question_type = map_mysql_question_type(question.id_tipo_pregunta, None); let options = if question_type == QuestionBankType::MultipleChoice { Some(json!(["Opción A", "Opción B", "Opción C", "Opción D"])) } else if question_type == QuestionBankType::TrueFalse { Some(json!(["Verdadero", "Falso"])) } else { None }; let source_metadata = json!({ "mysql_table": "bancopreguntas", "idPregunta": question.id_pregunta, "idCursos": question.id_cursos, "nombre_curso": question.nombre_curso, "idPlanDeEstudios": question.id_plan_de_estudios, "plan_nombre": question.plan_nombre, "idTipoPregunta": question.id_tipo_pregunta, "imported_at": chrono::Utc::now().to_rfc3339(), }); let qb: QuestionBank = sqlx::query_as( &format!( r#" INSERT INTO question_bank ( organization_id, created_by, question_text, question_type, options, correct_answer, source, source_metadata, audio_status, is_active ) VALUES ($1, $2, $3, $4, $5, $6, 'imported-mysql', $7, 'pending', true) RETURNING {} "#, QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .bind(claims.sub) .bind(&question.descripcion) .bind(&question_type) .bind(&options) .bind(&serde_json::Value::Null) .bind(&source_metadata) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al importar la pregunta {}: {}", question.id_pregunta, e)))?; imported_questions.push(qb); } } mysql_pool.close().await; return Ok(Json(imported_questions)); } else { return Err((StatusCode::BAD_REQUEST, "Debe proporcionar course_id, question_ids o import_all".to_string())); }; mysql_pool.close().await; if mysql_questions.is_empty() { return Err((StatusCode::NOT_FOUND, "No se encontraron preguntas en MySQL para importar".to_string())); } // Importar preguntas a PostgreSQL let mut imported_questions: Vec = vec![]; let mut skipped_count = 0; for mq in mysql_questions { // Comprobar si la pregunta ya ha sido importada let exists: (bool,) = sqlx::query_as( "SELECT EXISTS(SELECT 1 FROM question_bank WHERE imported_mysql_id = $1 AND organization_id = $2)" ) .bind(mq.id_pregunta) .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al comprobar existencia: {}", e)))?; if exists.0 { skipped_count += 1; continue; // Omitir pregunta ya importada } // Mapear el tipo de pregunta de MySQL al tipo de pregunta de la plataforma let question_type = map_mysql_question_type(mq.id_tipo_pregunta, None); // Crear opciones para opción múltiple (si corresponde) let options = if question_type == QuestionBankType::MultipleChoice { Some(json!(["Opción A", "Opción B", "Opción C", "Opción D"])) } else if question_type == QuestionBankType::TrueFalse { Some(json!(["Verdadero", "Falso"])) } else { None }; let source_metadata = json!({ "mysql_table": "bancopreguntas", "idPregunta": mq.id_pregunta, "idCursos": mq.id_cursos, "nombre_curso": mq.nombre_curso, "idPlanDeEstudios": mq.id_plan_de_estudios, "plan_nombre": mq.plan_nombre, "idTipoPregunta": mq.id_tipo_pregunta, "imported_at": chrono::Utc::now().to_rfc3339(), }); let question: QuestionBank = sqlx::query_as( &format!( r#" INSERT INTO question_bank ( organization_id, created_by, question_text, question_type, options, correct_answer, source, source_metadata, imported_mysql_id, imported_mysql_course_id, audio_status, is_active ) VALUES ($1, $2, $3, $4, $5, $6, 'imported-mysql', $7, $8, $9, 'pending', true) RETURNING {} "#, QUESTION_BANK_SELECT_COLUMNS ) ) .bind(org_ctx.id) .bind(claims.sub) .bind(&mq.descripcion) .bind(&question_type) .bind(&options) .bind(&serde_json::Value::Null) // Respuesta correcta para que el usuario la complete .bind(&source_metadata) .bind(mq.id_pregunta) .bind(mq.id_cursos) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al importar la pregunta {}: {}", mq.id_pregunta, e)))?; imported_questions.push(question); } tracing::info!("Se importaron {} preguntas de MySQL (se omitieron {} ya importadas)", imported_questions.len(), skipped_count); Ok(Json(imported_questions)) } // ==================== Auxiliares ==================== fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> QuestionBankType { // Mapear tipos de preguntas de MySQL a tipos de plataforma // Primero intentar por nombre, luego por ID como respaldo if let Some(nombre) = tipo_nombre { let nombre_lower = nombre.to_lowercase(); if nombre_lower.contains("selecc") || nombre_lower.contains("múltiple") || nombre_lower.contains("multiple") || nombre_lower.contains("alternativa") { return QuestionBankType::MultipleChoice; } if nombre_lower.contains("verdadero") || nombre_lower.contains("falso") { return QuestionBankType::TrueFalse; } if nombre_lower.contains("emparej") || nombre_lower.contains("match") { return QuestionBankType::Matching; } if nombre_lower.contains("orden") { return QuestionBankType::Ordering; } if nombre_lower.contains("complet") { return QuestionBankType::FillInTheBlanks; } if nombre_lower.contains("ensayo") || nombre_lower.contains("essay") { return QuestionBankType::Essay; } if nombre_lower.contains("corta") || nombre_lower.contains("short") || nombre_lower.contains("texto") { return QuestionBankType::ShortAnswer; } // El tipo audio en MySQL suele ser comprensión auditiva con respuestas de opción múltiple if nombre_lower.contains("audio") || nombre_lower.contains("listening") { return QuestionBankType::MultipleChoice; } } // Respaldo al mapeo por ID match mysql_type { 1 => QuestionBankType::MultipleChoice, // Alternativa 2 => QuestionBankType::MultipleChoice, // Audio (listening comprehension) 3 => QuestionBankType::ShortAnswer, // Texto 4 => QuestionBankType::Matching, 5 => QuestionBankType::Ordering, 6 => QuestionBankType::FillInTheBlanks, 7 => QuestionBankType::Essay, _ => QuestionBankType::MultipleChoice, } } /// Analizar el JSON de respuestas de MySQL y extraer opciones y respuesta correcta fn parse_mysql_answers( answers_json: Option<&str>, question_type: QuestionBankType, ) -> (Option, Option) { if let Some(json_str) = answers_json { if let Ok(answers) = serde_json::from_str::>(json_str) { if !answers.is_empty() { // Extraer opciones (todos los textos de respuesta) let options: Vec = answers .iter() .filter_map(|a| a.get("texto").and_then(|t| t.as_str()).map(String::from)) .collect(); // Extraer respuesta(s) correcta(s) let correct_indices: Vec = answers .iter() .enumerate() .filter(|(_, a)| a.get("es_correcta").and_then(|c| c.as_bool()).unwrap_or(false)) .map(|(i, _)| i) .collect(); let options_json = if !options.is_empty() { Some(serde_json::json!(options)) } else { None }; let correct_answer = if question_type == QuestionBankType::TrueFalse || question_type == QuestionBankType::MultipleChoice { // Para opción múltiple, guardar índice/índices if correct_indices.len() == 1 { Some(serde_json::json!(correct_indices[0])) } else if correct_indices.len() > 1 { Some(serde_json::json!(correct_indices)) } else { Some(serde_json::json!(0)) // Por defecto a la primera } } else { // Para otros tipos, guardar el texto correct_indices.first() .and_then(|&i| answers.get(i)) .and_then(|a| a.get("texto").and_then(|t| t.as_str())) .map(|t| serde_json::json!(t)) }; return (options_json, correct_answer); } } } // Valores por defecto si no se proporcionan respuestas let default_options = match question_type { QuestionBankType::MultipleChoice => Some(serde_json::json!(["Opción A", "Opción B", "Opción C", "Opción D"])), QuestionBankType::TrueFalse => Some(serde_json::json!(["Verdadero", "Falso"])), _ => None, }; (default_options, Some(serde_json::json!(0))) } // ==================== Integración con MySQL ==================== /// GET /api/question-bank/mysql-courses - Listar cursos de MySQL para importación #[allow(dead_code)] pub async fn list_mysql_courses( Org(_org_ctx): Org, State(_pool): State, ) -> Result>, (StatusCode, String)> { // Conectar a MySQL let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?; // Obtener cursos con sus nombres de plan let courses: Vec = sqlx::query_as( r#" SELECT DISTINCT c.idCursos AS id_cursos, c.NombreCurso AS nombre_curso, c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan, c.Duracion AS duracion FROM curso c JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios WHERE c.Activo = 1 AND pe.Activo = 1 ORDER BY pe.Nombre, c.NivelCurso "# ) .fetch_all(&mysql_pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?; mysql_pool.close().await; Ok(Json(courses)) } /// GET /api/question-bank/mysql-plans - Obtener todos los planes de estudio de PostgreSQL (importados de MySQL) pub async fn get_mysql_plans( Org(org_ctx): Org, State(pool): State, ) -> Result>, (StatusCode, String)> { // Leer del reflejo SAM en PostgreSQL con campos nativos de SAM. let mut plans: Vec = sqlx::query_as( r#" SELECT idPlanDeEstudios AS id_plan_de_estudios, Nombre AS nombre_plan FROM sam_study_plans WHERE organization_id = $1 AND Activo = TRUE ORDER BY Nombre "# ) .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los planes: {}", e)))?; // Respaldo compatible con versiones anteriores: si el reflejo SAM está vacío, usar el reflejo de metadatos heredado. if plans.is_empty() { plans = sqlx::query_as( r#" SELECT mysql_id AS id_plan_de_estudios, name AS nombre_plan FROM mysql_study_plans WHERE organization_id = $1 AND is_active = TRUE ORDER BY name "#, ) .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los planes heredados: {}", e)))?; } // Auto-sincronización de último recurso: si sigue vacío, obtener metadatos de MySQL y persistirlos. if plans.is_empty() { match connect_mysql_pool("MYSQL_DATABASE_URL").await { Ok(mysql_pool) => { let mysql_plans: Result, sqlx::Error> = sqlx::query_as( r#" SELECT DISTINCT pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan FROM plandeestudios pe WHERE pe.Activo = 1 ORDER BY pe.Nombre "#, ) .fetch_all(&mysql_pool) .await; let mysql_courses: Result, sqlx::Error> = sqlx::query_as( r#" SELECT DISTINCT c.idCursos AS id_cursos, c.NombreCurso AS nombre_curso, c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan, c.Duracion AS duracion FROM curso c JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios WHERE c.Activo = 1 AND pe.Activo = 1 ORDER BY pe.Nombre, c.NivelCurso "#, ) .fetch_all(&mysql_pool) .await; match (mysql_plans, mysql_courses) { (Ok(p), Ok(c)) => { if let Err(err) = save_mysql_courses_and_plans(&pool, org_ctx.id, p, c).await { tracing::warn!("Fallo la auto-sincronización de metadatos de MySQL: {}", err); } } (Err(e), _) => tracing::warn!("Fallo la consulta de planes de auto-sincronización: {}", e), (_, Err(e)) => tracing::warn!("Fallo la consulta de cursos de auto-sincronización: {}", e), } mysql_pool.close().await; } Err(e) => { tracing::warn!("La auto-sincronización no pudo conectarse a MySQL: {:?}", e); } } // Recargar planes tras el intento de auto-sincronización. plans = sqlx::query_as( r#" SELECT idPlanDeEstudios AS id_plan_de_estudios, Nombre AS nombre_plan FROM sam_study_plans WHERE organization_id = $1 AND Activo = TRUE ORDER BY Nombre "#, ) .bind(org_ctx.id) .fetch_all(&pool) .await .unwrap_or_default(); if plans.is_empty() { plans = sqlx::query_as( r#" SELECT mysql_id AS id_plan_de_estudios, name AS nombre_plan FROM mysql_study_plans WHERE organization_id = $1 AND is_active = TRUE ORDER BY name "#, ) .bind(org_ctx.id) .fetch_all(&pool) .await .unwrap_or_default(); } } Ok(Json(plans)) } /// GET /api/question-bank/mysql-courses - Obtener cursos filtrados por plan de PostgreSQL pub async fn get_mysql_courses_by_plan( Org(org_ctx): Org, State(pool): State, Query(filters): Query, ) -> Result>, (StatusCode, String)> { // Leer del reflejo SAM en PostgreSQL con campos nativos de SAM. let mut courses: Vec = sqlx::query_as( r#" SELECT c.idCursos AS id_cursos, c.NombreCurso AS nombre_curso, c.NivelCurso AS nivel_curso, c.idPlanDeEstudios AS id_plan_de_estudios, p.Nombre AS nombre_plan, c.Duracion AS duracion FROM sam_courses c JOIN sam_study_plans p ON p.organization_id = c.organization_id AND p.idPlanDeEstudios = c.idPlanDeEstudios WHERE c.organization_id = $1 AND c.Activo = TRUE AND p.Activo = TRUE AND c.idPlanDeEstudios = $2 ORDER BY c.NivelCurso "# ) .bind(org_ctx.id) .bind(filters.plan_id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?; // Respaldo compatible con versiones anteriores: si el reflejo SAM está vacío, usar el reflejo de metadatos heredado. if courses.is_empty() { courses = sqlx::query_as( r#" SELECT c.mysql_id AS id_cursos, c.name AS nombre_curso, c.level AS nivel_curso, sp.mysql_id AS id_plan_de_estudios, sp.name AS nombre_plan, c.duracion::double precision AS duracion FROM mysql_courses c JOIN mysql_study_plans sp ON c.study_plan_id = sp.id WHERE c.organization_id = $1 AND c.is_active = TRUE AND sp.is_active = TRUE AND sp.mysql_id = $2 ORDER BY c.level "#, ) .bind(org_ctx.id) .bind(filters.plan_id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos heredados: {}", e)))?; } Ok(Json(courses)) } #[derive(Debug, Deserialize)] pub struct MySqlCoursesFilters { pub plan_id: i32, } #[derive(Debug, Clone, sqlx::FromRow, Serialize)] pub struct MySqlPlanInfo { pub id_plan_de_estudios: i32, pub nombre_plan: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct MySqlCourseInfo { pub id_cursos: i32, pub nombre_curso: String, pub nivel_curso: Option, pub id_plan_de_estudios: i32, pub nombre_plan: String, pub duracion: Option, // Duración en horas (40=regular, 80=intensivo) - Tipo float de MySQL } #[derive(Debug, Serialize, Deserialize)] pub struct ImportAllFromMySQLPayload { pub import_metadata_only: Option, // Solo importar metadatos (cursos/planes), no preguntas } /// POST /api/question-bank/import-mysql-all - Importar TODAS las preguntas de MySQL (importación masiva) pub async fn import_all_from_mysql( Org(org_ctx): Org, claims: Claims, State(pool): State, body: String, ) -> Result, (StatusCode, String)> { use serde_json::json; // Analizar el cuerpo JSON opcional let import_metadata_only = if body.trim().is_empty() { false } else { serde_json::from_str::(&body) .map(|p| p.import_metadata_only.unwrap_or(false)) .unwrap_or(false) }; // Conectar a MySQL let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?; // Obtener todos los planes de estudio y cursos de MySQL para sincronizarlos let mysql_plans: Vec = sqlx::query_as( r#" SELECT DISTINCT pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan FROM plandeestudios pe WHERE pe.Activo = 1 ORDER BY pe.Nombre "# ) .fetch_all(&mysql_pool) .await .map_err(|e| { let error_msg = format!("Error al obtener: {}", e); tracing::error!("Error de MySQL: {}", error_msg); tracing::error!("Verifique los nombres de las columnas en su base de datos MySQL (tablas plandeestudios, curso)"); (StatusCode::INTERNAL_SERVER_ERROR, error_msg) })?; tracing::info!("Se obtuvieron {} planes de estudio de MySQL", mysql_plans.len()); let mysql_courses: Vec = sqlx::query_as( r#" SELECT DISTINCT c.idCursos AS id_cursos, c.NombreCurso AS nombre_curso, c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan, c.Duracion AS duracion FROM curso c JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios WHERE c.Activo = 1 AND pe.Activo = 1 ORDER BY pe.Nombre, c.NivelCurso "# ) .fetch_all(&mysql_pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?; tracing::info!("Se obtuvieron {} cursos de MySQL", mysql_courses.len()); // Guardar planes y cursos en PostgreSQL tracing::info!("Guardando planes y cursos en PostgreSQL..."); save_mysql_courses_and_plans(&pool, org_ctx.id, mysql_plans.clone(), mysql_courses.clone()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al guardar cursos/planes: {}", e)))?; // Si solo se solicita la importación de metadatos, salir antes if import_metadata_only { return Ok(Json(json!({ "success": true, "message": "Metadatos importados exitosamente", "metadata": { "study_plans_imported": mysql_plans.len(), "courses_imported": mysql_courses.len(), "courses": mysql_courses.iter().map(|c| json!({ "id_cursos": c.id_cursos, "nombre_curso": c.nombre_curso, "nombre_plan": c.nombre_plan, "duracion": c.duracion, "nivel_curso": c.nivel_curso })).collect::>() } }))); } // Obtener TODAS las preguntas de MySQL con respuestas (usando agregación JSON para las respuestas) let mysql_questions: Vec = sqlx::query_as( r#" SELECT bp.idPregunta AS id_pregunta, bp.descripcion AS descripcion, bp.idTipoPregunta AS id_tipo_pregunta, bp.activo AS activo, c.idCursos AS id_cursos, c.NombreCurso AS nombre_curso, c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS plan_nombre, tp.descripcion AS tipo_pregunta_nombre, CAST( ( SELECT JSON_ARRAYAGG( JSON_OBJECT( 'idRespuesta', br.idRespuesta, 'texto', br.descripcion, 'es_correcta', br.resultado = 1 ) ) FROM bancorespuestas br WHERE br.idPregunta = bp.idPregunta AND br.activo = 1 ) AS CHAR ) AS respuestas_json FROM bancopreguntas bp JOIN curso c ON bp.idCursos = c.idCursos JOIN plandeestudios pe ON bp.idPlanDeEstudios = pe.idPlanDeEstudios JOIN tipopregunta tp ON bp.idTipoPregunta = tp.idTipoPregunta WHERE bp.activo = 1 AND pe.Activo = 1 AND c.Activo = 1 ORDER BY pe.Nombre, c.NombreCurso, bp.idPregunta "# ) .fetch_all(&mysql_pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?; mysql_pool.close().await; if mysql_questions.is_empty() { return Ok(Json(json!({ "success": false, "imported": 0, "skipped": 0, "updated": 0, "error": "No se encontraron preguntas en MySQL" }))); } // Importar preguntas a PostgreSQL let mut imported_count = 0; let mut skipped_count = 0; let mut updated_count = 0; for mq in mysql_questions { // Comprobar si la pregunta ya existe let existing: Option<(Uuid, bool)> = sqlx::query_as( "SELECT id, is_active FROM question_bank WHERE imported_mysql_id = $1 AND organization_id = $2" ) .bind(mq.id_pregunta) .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Fallo en la comprobación: {}", e)))?; match existing { Some((id, is_active)) => { // La pregunta existe - actualizar si estaba inactiva o tiene nuevos datos if !is_active { // Reactivar y actualizar let _ = sqlx::query( "UPDATE question_bank SET is_active = true, question_text = $3, updated_at = NOW() WHERE id = $1 AND organization_id = $2" ) .bind(id) .bind(org_ctx.id) .bind(&mq.descripcion) .execute(&pool) .await; updated_count += 1; } else { skipped_count += 1; // Ya existe y está activa, omitir } } None => { // Nueva pregunta - insertar con respuestas de MySQL let question_type = map_mysql_question_type(mq.id_tipo_pregunta, mq.tipo_pregunta_nombre.as_deref()); // Analizar respuestas de MySQL let (options, correct_answer) = parse_mysql_answers(mq.respuestas_json.as_deref(), question_type); let has_answers = mq.respuestas_json.is_some(); let question_type_name = mq.tipo_pregunta_nombre.clone(); let source_metadata = serde_json::json!({ "mysql_table": "bancopreguntas", "idPregunta": mq.id_pregunta, "idCursos": mq.id_cursos, "nombre_curso": mq.nombre_curso, "nivel_curso": mq.nivel_curso, "idPlanDeEstudios": mq.id_plan_de_estudios, "plan_nombre": mq.plan_nombre, "idTipoPregunta": mq.id_tipo_pregunta, "tipo_pregunta_nombre": question_type_name, "imported_at": chrono::Utc::now().to_rfc3339(), "import_method": "bulk_import_all", "has_answers": has_answers, }); let _ = sqlx::query( r#" INSERT INTO question_bank ( organization_id, created_by, question_text, question_type, options, correct_answer, source, source_metadata, imported_mysql_id, imported_mysql_course_id, audio_status, is_active ) VALUES ($1, $2, $3, $4, $5, $6, 'imported-mysql', $7, $8, $9, 'pending', true) "# ) .bind(org_ctx.id) .bind(claims.sub) .bind(&mq.descripcion) .bind(&question_type) .bind(&options) .bind(&correct_answer) .bind(&source_metadata) .bind(mq.id_pregunta) .bind(mq.id_cursos) .execute(&pool) .await; imported_count += 1; } } } tracing::info!( "Importación masiva desde MySQL: {} importadas, {} omitidas, {} actualizadas", imported_count, skipped_count, updated_count ); Ok(Json(json!({ "success": true, "imported": imported_count, "skipped": skipped_count, "updated": updated_count, "metadata": { "study_plans_imported": mysql_plans.len(), "courses_imported": mysql_courses.len() } }))) } #[derive(Debug, Serialize)] #[allow(dead_code)] pub struct ImportResult { pub imported: i32, pub skipped: i32, pub updated: i32, pub error: Option, } // Excel import - pendiente de fix // /// POST /api/question-bank/import-excel - Import questions from Excel file // pub async fn import_from_excel( // Org(org_ctx): Org, // claims: Claims, // State(pool): State, // multipart: axum::extract::Multipart, // ) -> Result, (StatusCode, String)> { // // Implementation pending // unimplemented!() // } #[derive(Debug, sqlx::FromRow, Serialize, Deserialize)] pub struct MySqlQuestionFull { pub id_pregunta: i32, pub descripcion: String, pub id_tipo_pregunta: i32, pub activo: bool, pub id_cursos: i32, pub nombre_curso: String, pub nivel_curso: Option, pub id_plan_de_estudios: i32, pub plan_nombre: String, pub respuestas_json: Option, // Array JSON de respuestas pub tipo_pregunta_nombre: Option, // Nombre del tipo de pregunta } #[derive(Debug, sqlx::FromRow)] #[allow(dead_code)] struct MySqlQuestion { id_pregunta: i32, descripcion: String, id_tipo_pregunta: i32, activo: bool, id_cursos: i32, nombre_curso: String, id_plan_de_estudios: i32, plan_nombre: String, } // ==================== Generación por IA ==================== #[derive(Debug, Deserialize)] pub struct AIGenerateQuestionPayload { pub question_text: Option, #[allow(dead_code)] pub question_type: Option, pub difficulty: Option, pub skill: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct AIQuestionResponse { pub question_text: String, pub options: Vec, pub correct_answer: serde_json::Value, pub explanation: String, } /// POST /question-bank/ai-generate - Generar opciones/respuestas de preguntas usando IA (solo Ollama) pub async fn ai_generate_question( _org_ctx: Org, _claims: Claims, Json(payload): Json, ) -> Result, (StatusCode, String)> { use std::env; use std::time::Duration; let question_text = payload.question_text.unwrap_or_else(|| "Pregunta de gramática inglesa".to_string()); let question_type = payload.question_type.unwrap_or_else(|| "multiple-choice".to_string()); let difficulty = payload.difficulty.unwrap_or_else(|| "medium".to_string()); let skill = payload.skill.unwrap_or_else(|| "grammar".to_string()); // Construir prompt para la IA let system_prompt = format!( r#"You are an expert English Teacher creating quiz questions. Create a multiple-choice question with the following parameters: - Topic/Context: {} - Question type: {} - Difficulty: {} - Skill assessed: {} Return ONLY a JSON object with this exact structure: {{ "question_text": "The question text here", "options": ["Option A", "Option B", "Option C", "Option D"], "correct_answer": 0, "explanation": "Detailed explanation of why this is correct" }}"#, question_text, question_type, difficulty, skill ); // Llamar a Ollama AI con tiempo de espera extendido 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.2:3b".to_string()); let url = format!("{}/api/chat", base_url); let client = reqwest::Client::builder() .timeout(Duration::from_secs(600)) // 10 minutes timeout for slower machines .build() .unwrap_or_else(|_| reqwest::Client::new()); tracing::info!("Calling Ollama at {} with model {}", url, model); let response = client .post(&url) .json(&serde_json::json!({ "model": model, "messages": [ { "role": "system", "content": system_prompt }, { "role": "user", "content": "Generar la pregunta en formato JSON" } ], "stream": false, "format": "json" })) .send() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La solicitud de IA falló: {}", e)))?; if !response.status().is_success() { return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Error de la API de IA: {}", response.status()))); } let result: serde_json::Value = response.json().await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?; // Extraer contenido de la respuesta de Ollama let content = result .get("message") .and_then(|m| m.get("content")) .and_then(|c| c.as_str()) .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Formato de respuesta de IA no válido".to_string()))?; // Analizar la respuesta de la IA como JSON let ai_question: AIQuestionResponse = serde_json::from_str(content) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar el JSON de la pregunta: {}", e)))?; Ok(Json(ai_question)) } // ==================== Importar Cursos de MySQL ==================== #[derive(Debug, Serialize, Deserialize)] pub struct ImportCourseFromMySQLPayload { pub mysql_course_id: i32, pub title: Option, // Optional custom title (defaults to MySQL course name) pub description: Option, // Optional description pub pacing_mode: Option, // self_paced or instructor_led } #[derive(Debug, Serialize)] pub struct ImportCourseResult { pub course_id: Uuid, pub course_title: String, pub mysql_course_id: i32, pub modules_created: i32, pub lessons_created: i32, pub message: String, } /// POST /api/question-bank/import-course-mysql - Importar un curso de MySQL con estructura básica pub async fn import_course_from_mysql( Org(org_ctx): Org, claims: Claims, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { use common::models::Course; // Conectar a MySQL let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?; // Obtener información del curso de MySQL let mysql_course: MySqlCourseInfo = sqlx::query_as( r#" SELECT c.idCursos AS id_cursos, c.NombreCurso AS nombre_curso, c.NivelCurso AS nivel_curso, pe.idPlanDeEstudios AS id_plan_de_estudios, pe.Nombre AS nombre_plan, c.Duracion AS duracion FROM curso c JOIN plandeestudios pe ON c.idPlanDeEstudios = pe.idPlanDeEstudios WHERE c.idCursos = ? AND c.Activo = 1 AND pe.Activo = 1 "# ) .bind(payload.mysql_course_id) .fetch_one(&mysql_pool) .await .map_err(|e| { if let sqlx::Error::RowNotFound = e { (StatusCode::NOT_FOUND, format!("Curso con ID {} no encontrado en MySQL", payload.mysql_course_id)) } else { (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el curso de MySQL: {}", e)) } })?; tracing::info!("Importando curso de MySQL: {} (ID: {})", mysql_course.nombre_curso, mysql_course.id_cursos); // Iniciar transacción let mut tx = pool.begin().await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al iniciar la transacción: {}", e)))?; // Determinar el tipo y nivel del curso para la generación de la estructura let course_type = calculate_course_type_from_duration(mysql_course.duracion); let level = calculate_course_level(mysql_course.nivel_curso); tracing::info!("Course type: {}, Level: {}", course_type, level); // Crear el curso en PostgreSQL let course_title = payload.title.unwrap_or_else(|| format!("{} ({})", mysql_course.nombre_curso, mysql_course.nombre_plan)); let pacing_mode = payload.pacing_mode.unwrap_or_else(|| "self_paced".to_string()); let description = payload.description.unwrap_or_else(|| format!("Curso importado desde MySQL - Plan: {}", mysql_course.nombre_plan)); let new_course: Course = sqlx::query_as( r#" INSERT INTO courses ( organization_id, instructor_id, title, pacing_mode, description, passing_percentage, certificate_template, imported_mysql_course_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * "# ) .bind(org_ctx.id) .bind(claims.sub) .bind(&course_title) .bind(&pacing_mode) .bind(&description) .bind(60.0) // Default passing percentage .bind(serde_json::json!({ "template": "default", "show_logo": true, "show_instructor": true })) .bind(mysql_course.id_cursos) .fetch_one(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al crear el curso: {}", e)))?; tracing::info!("Curso creado en PostgreSQL: {}", new_course.id); // Generar estructura básica del curso basada en su tipo y nivel let (modules_count, lessons_count) = generate_course_structure( &mut tx, new_course.id, org_ctx.id, &course_type, &level, &mysql_course.nombre_curso, ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; // Confirmar transacción tx.commit().await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al confirmar la transacción: {}", e)))?; tracing::info!( "Curso {} importado con éxito con {} módulos y {} lecciones", new_course.id, modules_count, lessons_count ); Ok(Json(ImportCourseResult { course_id: new_course.id, course_title: new_course.title.clone(), mysql_course_id: mysql_course.id_cursos, modules_created: modules_count, lessons_created: lessons_count, message: format!("Curso '{}' importado exitosamente con {} módulos y {} lecciones", new_course.title, modules_count, lessons_count), })) } /// Generar estructura básica del curso según tipo y nivel async fn generate_course_structure<'a>( tx: &mut sqlx::Transaction<'a, sqlx::Postgres>, course_id: Uuid, org_id: Uuid, course_type: &str, _level: &str, _course_name: &str, ) -> Result<(i32, i32), String> { // Definir la estructura de módulos basada en el tipo de curso // Regular (40h): 4 módulos // Intensivo (80h): 8 módulos let modules_config = match course_type { "intensive" => vec![ ("Fundamentos Básicos", 6), ("Gramática Esencial", 6), ("Vocabulario Intermedio", 6), ("Comprensión Auditiva", 6), ("Expresión Oral", 6), ("Lectura y Escritura", 6), ("Práctica Avanzada", 6), ("Proyecto Final", 4), ], _ => vec![ ("Introducción y Fundamentos", 5), ("Gramática Básica", 5), ("Vocabulario Esencial", 5), ("Práctica Integradora", 5), ], }; let mut total_modules = 0; let mut total_lessons = 0; for (module_idx, (module_name, lessons_count)) in modules_config.iter().enumerate() { // Crear módulo let module: common::models::Module = sqlx::query_as( r#" INSERT INTO modules (course_id, organization_id, title, position) VALUES ($1, $2, $3, $4) RETURNING * "# ) .bind(course_id) .bind(org_id) .bind(format!("Módulo {}: {}", module_idx + 1, module_name)) .bind(module_idx as i32) .fetch_one(&mut **tx) .await .map_err(|e| format!("Error al crear el módulo {}: {}", module_idx + 1, e))?; total_modules += 1; // Crear lecciones para este módulo for lesson_idx in 0..*lessons_count { let lesson_position = (module_idx * lessons_count + lesson_idx) as i32; let lesson_title = format!("Lección {}.{}", module_idx + 1, lesson_idx + 1); // Determinar el tipo de contenido basado en la posición (rotar por tipos) let content_types = ["video", "document", "interactive", "quiz"]; let content_type = content_types[lesson_idx % content_types.len()]; sqlx::query( r#" INSERT INTO lessons ( module_id, organization_id, title, content_type, content_url, position, is_graded, summary ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "# ) .bind(module.id) .bind(org_id) .bind(&lesson_title) .bind(content_type) .bind("") // URL de contenido vacía (para que el instructor la complete) .bind(lesson_position) .bind(lesson_idx % 4 == 3) // Cada 4ª lección se califica (cuestionario) .bind(format!("Contenido de la lección: {}", lesson_title)) .execute(&mut **tx) .await .map_err(|e| format!("Error al crear la lección {}: {}", lesson_position, e))?; total_lessons += 1; } } Ok((total_modules, total_lessons)) } // ==================== Importar desde SAM Diagnóstico ==================== /// Row retornada por GROUP BY sobre las tablas de SAM_diagnostico #[derive(Debug, sqlx::FromRow)] struct SamDiagnosticoQuestion { pub id_test: i32, pub id_curso: i32, pub id_pregunta: i32, pub pregunta_nombre: Option, #[allow(dead_code)] pub tipo_pregunta: Option, /// Opciones separadas por '|||' (GROUP_CONCAT) pub opciones: Option, /// Texto de la respuesta correcta (valorRespuesta = 1) pub respuesta_correcta: Option, } #[derive(Debug, serde::Deserialize)] pub struct ImportSamDiagnosticoPayload { /// "adultos", "kids", "teens" o null para importar las tres audiencias pub audience: Option, /// Filtra por idTest específico (opcional) pub test_id: Option, /// Filtra por idCurso específico (opcional) pub curso_id: Option, } /// POST /api/question-bank/import-sam-diagnostico /// Importa preguntas desde las tablas SAM_diagnostico (preguntasadultos, /// preguntaskid, preguntasteens) al banco de preguntas de PostgreSQL. pub async fn import_from_sam_diagnostico( Org(org_ctx): Org, claims: Claims, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { use serde_json::json; let mysql_pool = connect_mysql_pool("SAM_DIAGNOSTICO_DATABASE_URL").await?; // Determinar qué tablas procesar según la audiencia solicitada let tables: Vec<(&str, &str)> = match payload.audience.as_deref() { Some("adultos") => vec![("adultos", "preguntasadultos")], Some("kids") => vec![("kids", "preguntaskid")], Some("teens") => vec![("teens", "preguntasteens")], _ => vec![ ("adultos", "preguntasadultos"), ("kids", "preguntaskid"), ("teens", "preguntasteens"), ], }; let mut total_imported: i64 = 0; let mut total_skipped: i64 = 0; let mut errors: Vec = Vec::new(); for (audience_label, table_name) in &tables { // GROUP_CONCAT agrupa todas las opciones de cada pregunta en una sola fila. // El separador '|||' no puede aparecer en los textos de respuesta normales. let base_query = format!( r#" SELECT idTest AS id_test, idCurso AS id_curso, idPregunta AS id_pregunta, CAST(MAX(preguntaNombre) AS CHAR CHARACTER SET utf8mb4) AS pregunta_nombre, CAST(MAX(tipoPregunta) AS CHAR CHARACTER SET utf8mb4) AS tipo_pregunta, GROUP_CONCAT( CAST(respuestaNombre AS CHAR CHARACTER SET utf8mb4) ORDER BY idOpcion SEPARATOR '|||' ) AS opciones, CAST(MAX(CASE WHEN valorRespuesta = 1 THEN respuestaNombre ELSE NULL END) AS CHAR CHARACTER SET utf8mb4) AS respuesta_correcta FROM {} WHERE 1=1 {} {} GROUP BY idTest, idCurso, idPregunta ORDER BY idTest, idCurso, idPregunta "#, table_name, if payload.test_id.is_some() { "AND idTest = ?" } else { "" }, if payload.curso_id.is_some() { "AND idCurso = ?" } else { "" }, ); // Bind parámetros opcionales de forma dinámica let rows: Vec = { let mut q = sqlx::query_as::<_, SamDiagnosticoQuestion>(&base_query); if let Some(tid) = payload.test_id { q = q.bind(tid); } if let Some(cid) = payload.curso_id { q = q.bind(cid); } q.fetch_all(&mysql_pool) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch from {}: {}", table_name, e), ) })? }; tracing::info!( "SAM_diagnostico {}: {} preguntas encontradas", table_name, rows.len() ); for question in rows { let question_text = match &question.pregunta_nombre { Some(t) if !t.trim().is_empty() => t.clone(), _ => continue, // Saltar preguntas sin texto }; // Clave única para detectar duplicados let sam_id = format!( "{}-{}-{}-{}", audience_label, question.id_test, question.id_curso, question.id_pregunta ); let exists: (bool,) = sqlx::query_as( "SELECT EXISTS(SELECT 1 FROM question_bank \ WHERE source_metadata->>'sam_id' = $1 AND organization_id = $2)" ) .bind(&sam_id) .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Error al comprobar duplicado: {}", e), ) })?; if exists.0 { total_skipped += 1; continue; } // Convertir GROUP_CONCAT → Vec let options_vec: Vec = question .opciones .as_deref() .unwrap_or("") .split("|||") .map(|s| s.to_string()) .filter(|s| !s.is_empty()) .collect(); let options_json = if options_vec.is_empty() { serde_json::Value::Null } else { json!(options_vec) }; let correct_json = question .respuesta_correcta .as_ref() .map(|a| json!(a)) .unwrap_or(serde_json::Value::Null); let source_metadata = json!({ "sam_id": sam_id, "audience": audience_label, "tabla": table_name, "idTest": question.id_test, "idCurso": question.id_curso, "idPregunta": question.id_pregunta, "imported_at": chrono::Utc::now().to_rfc3339(), }); match sqlx::query( r#" INSERT INTO question_bank ( organization_id, created_by, question_text, question_type, options, correct_answer, source, source_metadata, audio_status, is_active ) VALUES ($1, $2, $3, 'multiple-choice', $4, $5, 'sam-diagnostico', $6, 'pending', true) "# ) .bind(org_ctx.id) .bind(claims.sub) .bind(&question_text) .bind(&options_json) .bind(&correct_json) .bind(&source_metadata) .execute(&pool) .await { Ok(_) => total_imported += 1, Err(e) => errors.push(format!( "Error importando pregunta {} ({}): {}", question.id_pregunta, table_name, e )), } } } mysql_pool.close().await; tracing::info!( "Importación de SAM_diagnostico finalizada: imported={} skipped={} errors={}", total_imported, total_skipped, errors.len() ); Ok(Json(json!({ "imported": total_imported, "skipped": total_skipped, "errors": errors, }))) }