feat: Translate various strings and comments to Spanish for better localization

- Updated error messages and comments in main.rs, openapi.rs, portfolio.rs, predictive.rs, ai.rs, health.rs, middleware.rs, models.rs, token_limits.rs, and webhooks.rs to Spanish.
- Enhanced user experience by providing localized content for Spanish-speaking users.
This commit is contained in:
2026-04-10 10:26:26 -04:00
parent 7c48b3b1a9
commit 53e5ef4d0b
35 changed files with 1135 additions and 1144 deletions
@@ -31,9 +31,9 @@ pub async fn create_course_external(
) -> Result<Json<serde_json::Value>, StatusCode> {
let org_id = validate_api_key(&headers, &pool).await?;
// We reuse the internal logic but with the org_id from the API key
// We need to provide a mock claims for handlers::create_course or refactor it.
// Simplifying for now: direct DB call or calling handlers with constructed context.
// Reutilizamos la lógica interna pero con el org_id de la clave API
// Necesitamos proporcionar un reclamo ficticio (mock claims) para handlers::create_course o refactorizarlo.
// Simplificando por ahora: llamada directa a la BD o llamando a manejadores con contexto construido.
let title = payload.title.trim();
if title.is_empty() {
@@ -52,7 +52,7 @@ pub async fn create_course_external(
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("External course creation failed: {}", e);
tracing::error!("La creación del curso externo falló: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -94,9 +94,9 @@ pub struct ExternalCreateCoursePayload {
pub title: String,
pub description: Option<String>,
pub pacing_mode: Option<String>,
// Optional direct template selection
// Selección directa de plantilla opcional
pub template_id: Option<Uuid>,
// Optional fallback selection by level/course type/test type
// Selección de respaldo (fallback) opcional por nivel/tipo de curso/tipo de test
pub template_level: Option<String>,
pub template_course_type: Option<String>,
pub template_test_type: Option<String>,
@@ -298,7 +298,7 @@ pub async fn trigger_transcription_external(
) -> Result<StatusCode, StatusCode> {
let org_id = validate_api_key(&headers, &pool).await?;
// Verify lesson belongs to org
// Verificar que la lección pertenece a la organización
let _ = sqlx::query("SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(id)
.bind(org_id)
@@ -306,7 +306,7 @@ pub async fn trigger_transcription_external(
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// Queue transcription
// Encolar transcripción
sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1")
.bind(id)
.execute(&pool)
+43 -43
View File
@@ -54,12 +54,12 @@ fn get_ai_url(var_base: &str, default: &str) -> String {
}
}
/// Simple token counter (approximate: 1 token ≈ 4 characters in English)
/// Contador de tokens simple (aproximado: 1 token ≈ 4 caracteres en inglés)
fn count_tokens(text: &str) -> i32 {
// More accurate for English: split by whitespace and count words * 1.3
// For Spanish/other languages, character-based is more reliable
// Más preciso para inglés: dividir por espacios en blanco y contar palabras * 1.3
// Para el español y otros idiomas, el conteo basado en caracteres es más fiable
let char_count = text.len();
// OpenAI estimate: ~4 chars per token
// Estimación de OpenAI: ~4 caracteres por token
((char_count as f64) / 4.0).ceil() as i32
}
@@ -73,7 +73,7 @@ pub async fn publish_course(
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)
// 1. Obtener curso (El superadministrador puede publicar cualquier curso, otros solo los de su organización)
let course = if is_super_admin {
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(id)
@@ -88,14 +88,14 @@ pub async fn publish_course(
}
.map_err(|_| StatusCode::NOT_FOUND)?;
// Determine target organization
// Determinar la organización de destino
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
// 2. Obtener módulos
let modules =
sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position")
.bind(id)
@@ -105,7 +105,7 @@ pub async fn publish_course(
let mut pub_modules = Vec::new();
// 3. Fetch Grading Categories
// 3. Obtener categorías de calificación
let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>(
"SELECT * FROM grading_categories WHERE course_id = $1",
)
@@ -114,7 +114,7 @@ pub async fn publish_course(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 4. Fetch Target Organization
// 4. Obtener organización de destino
let organization = sqlx::query_as::<_, common::models::Organization>(
"SELECT * FROM organizations WHERE id = $1",
)
@@ -123,7 +123,7 @@ pub async fn publish_course(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 5. Fetch Lessons for each Module
// 5. Obtener lecciones de cada módulo
for module in modules {
let lessons = sqlx::query_as::<_, Lesson>(
"SELECT * FROM lessons WHERE module_id = $1 ORDER BY position",
@@ -136,11 +136,11 @@ pub async fn publish_course(
pub_modules.push(PublishedModule { module, lessons });
}
// Overwrite the course's organization_id in the payload if publishing to a different org
// Sobrescribir el organization_id del curso en la carga útil si se publica en una organización diferente
let mut course_for_pub = course.clone();
course_for_pub.organization_id = target_org_id;
// 5. Fetch Course Team
// 5. Obtener equipo del curso
let instructors = sqlx::query_as::<_, CourseInstructor>(
"SELECT ci.*, u.email, u.full_name FROM course_instructors ci
JOIN users u ON ci.user_id = u.id
@@ -160,7 +160,7 @@ pub async fn publish_course(
dependencies: None,
};
// 4. Send to LMS
// 4. Enviar al LMS
let lms_url =
env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string());
let client = reqwest::Client::new();
@@ -170,12 +170,12 @@ pub async fn publish_course(
.send()
.await
.map_err(|e| {
tracing::error!("Failed to reach LMS service: {}", e);
tracing::error!("Error al contactar con el servicio LMS: {}", e);
StatusCode::BAD_GATEWAY
})?;
if !res.status().is_success() {
tracing::error!("LMS ingestion failed with status: {}", res.status());
tracing::error!("La ingesta del LMS falló con estado: {}", res.status());
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
@@ -190,7 +190,7 @@ pub async fn publish_course(
)
.await;
// 5. Trigger Webhook
// 5. Ejecutar webhook
let webhook_service = WebhookService::new(pool.clone());
webhook_service
.dispatch(
@@ -225,7 +225,7 @@ pub struct GradingPayload {
pub name: String,
pub weight: i32,
pub drop_count: i32,
pub tipo_nota_id: Option<i32>, // idTipoNota from tiponota table
pub tipo_nota_id: Option<i32>, // idTipoNota de la tabla tiponota
}
#[derive(Deserialize)]
@@ -258,11 +258,11 @@ pub async fn create_course(
.unwrap_or("self_paced");
let mut tx = pool.begin().await.map_err(|e| {
tracing::error!("Failed to start transaction: {}", e);
tracing::error!("Error al iniciar la transacción: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Set session context for the DB trigger to find
// Establecer el contexto de la sesión para que el disparador de la BD lo encuentre
let ip = headers
.get("x-forwarded-for")
.and_then(|h| h.to_str().ok())
@@ -284,7 +284,7 @@ pub async fn create_course(
)
.await
.map_err(|e| {
tracing::error!("Failed to set session context: {}", e);
tracing::error!("Error al establecer el contexto de la sesión: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -320,12 +320,12 @@ pub async fn create_course(
.fetch_one(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Create course failed: {}", e);
tracing::error!("Error al crear el curso: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
tx.commit().await.map_err(|e| {
tracing::error!("Failed to commit transaction: {}", e);
tracing::error!("Error al confirmar la transacción: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -339,7 +339,7 @@ pub async fn get_courses(
let is_super_admin = claims.role == "admin"
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
// Check if user is a SAM student
// Comprobar si el usuario es un estudiante de SAM
let is_sam_student: bool = sqlx::query_scalar(
"SELECT COALESCE(is_sam_student, false) FROM users WHERE id = $1"
)
@@ -349,18 +349,18 @@ pub async fn get_courses(
.unwrap_or(false);
let courses: Vec<Course> = if is_super_admin {
// Super admin sees all courses
// El superadministrador ve todos los cursos
sqlx::query_as::<_, Course>("SELECT * FROM courses")
.fetch_all(&pool)
.await
} else if claims.role == "admin" || claims.role == "instructor" {
// Admins and instructors see all courses in their organization
// Los administradores e instructores ven todos los cursos de su organización
sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1")
.bind(org_ctx.id)
.fetch_all(&pool)
.await
} else if is_sam_student {
// SAM students only see courses they are enrolled in via SAM
// Los estudiantes de SAM solo ven los cursos en los que están matriculados a través de SAM
sqlx::query_as::<_, Course>(
"SELECT c.* FROM courses c
INNER JOIN sam_course_assignments sca ON c.id = sca.course_id
@@ -373,7 +373,7 @@ pub async fn get_courses(
.fetch_all(&pool)
.await
} else {
// Non-SAM students see NO courses (empty list)
// Los estudiantes que no son de SAM no ven NINGÚN curso (lista vacía)
return Ok(Json(Vec::new()));
}
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -464,7 +464,7 @@ pub async fn update_course(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Set auditing context
// Establecer contexto de auditoría
sqlx::query(
"SELECT set_config('app.current_user_id', $1, true), set_config('app.org_id', $2, true)",
)
@@ -562,7 +562,7 @@ pub async fn create_module(
.fetch_one(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Create module failed: {}", e);
tracing::error!("Error al crear el módulo: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -678,7 +678,7 @@ pub async fn create_lesson(
.fetch_one(&mut *tx)
.await
.map_err(|e| {
tracing::error!("Create lesson failed: {}", e);
tracing::error!("Error al crear la lección: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -686,7 +686,7 @@ pub async fn create_lesson(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Trigger auto-transcription if it's a video or audio lesson with a URL
// Activar la autotranscripción si es una lección de vídeo o audio con una URL
if (content_type == "video" || content_type == "audio") && content_url.is_some() {
trigger_transcription(pool, lesson.id).await;
}
@@ -695,16 +695,16 @@ pub async fn create_lesson(
}
async fn trigger_transcription(pool: PgPool, lesson_id: Uuid) {
// Set status to queued
// Establecer estado como en cola
let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1")
.bind(lesson_id)
.execute(&pool)
.await;
// Spawn background task
// Iniciar tarea en segundo plano
tokio::spawn(async move {
if let Err(e) = run_transcription_task(pool, lesson_id).await {
tracing::error!("Auto-transcription task failed for lesson {}: {}", lesson_id, e);
tracing::error!("La tarea de autotranscripción falló para la lección {}: {}", lesson_id, e);
}
});
}
@@ -715,8 +715,8 @@ pub async fn process_transcription(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Lesson>, StatusCode> {
tracing::info!("Received transcription request for lesson: {}", id);
// 1. Fetch lesson
tracing::info!("Recibida solicitud de transcripción para la lección: {}", id);
// 1. Obtener lección
let lesson =
sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2")
.bind(id)
@@ -724,7 +724,7 @@ pub async fn process_transcription(
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Lesson fetch failed: {}", e);
tracing::error!("Error al obtener la lección: {}", e);
StatusCode::NOT_FOUND
})?;
@@ -734,13 +734,13 @@ pub async fn process_transcription(
let url = lesson.content_url.ok_or(StatusCode::BAD_REQUEST)?;
// 2. Validate media is reachable (local /assets or absolute URL)
// 2. Validar que el medio sea accesible (assets locales o URL absoluta)
if read_lesson_media_bytes(&url).await.is_err() {
tracing::error!("Media not accessible for transcription: {}", url);
tracing::error!("Medio no accesible para transcripción: {}", url);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
// 3. Set status to queued
// 3. Establecer estado como en cola
let updated_lesson = sqlx::query_as::<_, Lesson>(
"UPDATE lessons SET transcription_status = 'queued' WHERE id = $1 RETURNING *",
)
@@ -748,7 +748,7 @@ pub async fn process_transcription(
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Database update failed (queued): {}", e);
tracing::error!("Error en la actualización de la base de datos (en cola): {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -763,11 +763,11 @@ pub async fn process_transcription(
)
.await;
// 4. Spawn background task
// 4. Iniciar tarea en segundo plano
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);
tracing::error!("La tarea de transcripción falló para la lección {}: {}", id, e);
}
});
+50 -50
View File
@@ -9,15 +9,15 @@ use sqlx::PgPool;
use chrono::{DateTime, Utc};
use uuid::Uuid;
// ==================== Token Usage Tracking ====================
// ==================== Seguimiento del Uso de Tokens ====================
/// GET /api/admin/token-usage - Get token usage statistics for all users
/// GET /api/admin/token-usage - Obtener estasticas de uso de tokens para todos los usuarios
pub async fn get_token_usage(
_org_ctx: Org,
_claims: Claims,
State(pool): State<PgPool>,
) -> Result<Json<TokenUsageResponse>, (StatusCode, String)> {
// Get user token usage from database
// Obtener el uso de tokens de usuario de la base de datos
let usage: Vec<TokenUsageRecord> = sqlx::query_as(
r#"
SELECT
@@ -38,9 +38,9 @@ pub async fn get_token_usage(
)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch usage: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso: {}", e)))?;
// Calculate stats
// Calcular estadísticas
let total_tokens: i64 = usage.iter().map(|u| u.total_tokens).sum();
let total_input: i64 = usage.iter().map(|u| u.input_tokens).sum();
let total_output: i64 = usage.iter().map(|u| u.output_tokens).sum();
@@ -48,7 +48,7 @@ pub async fn get_token_usage(
let top_user_tokens = usage.first().map(|u| u.total_tokens).unwrap_or(0);
let avg_tokens = if !usage.is_empty() { total_tokens / usage.len() as i64 } else { 0 };
// Estimate cost (using approximate OpenAI pricing: $0.001/1K input, $0.003/1K output)
// Estimación de coste (usando precios aproximados de OpenAI: $0.001/1K entrada, $0.003/1K salida)
let estimated_cost = (total_input as f64 * 0.000001) + (total_output as f64 * 0.000003);
let stats = TokenUsageStats {
@@ -61,7 +61,7 @@ pub async fn get_token_usage(
avg_tokens_per_user: avg_tokens,
};
// Convert to response format with USD estimation per user
// Convertir al formato de respuesta con estimación en USD por usuario
let usage_with_cost: Vec<TokenUsage> = usage
.into_iter()
.map(|u| {
@@ -78,7 +78,7 @@ pub async fn get_token_usage(
last_used: u
.last_used
.map(|ts| ts.to_rfc3339())
.unwrap_or_else(|| "Never".to_string()),
.unwrap_or_else(|| "Nunca".to_string()),
estimated_cost_usd: user_cost,
}
})
@@ -90,14 +90,14 @@ pub async fn get_token_usage(
}))
}
/// GET /api/admin/ai-usage-dashboard - Comprehensive AI usage dashboard data
/// GET /api/admin/ai-usage-dashboard - Datos exhaustivos del panel de uso de IA
pub async fn get_ai_usage_dashboard(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Query(filters): Query<DashboardFilters>,
) -> Result<Json<DashboardResponse>, (StatusCode, String)> {
// Get daily usage for charts
// Obtener el uso diario para gráficos
let daily_usage: Vec<DailyUsage> = sqlx::query_as(
r#"
SELECT
@@ -121,9 +121,9 @@ pub async fn get_ai_usage_dashboard(
.bind(filters.end_date.clone())
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso diario: {}", e)))?;
// Get usage by endpoint/feature
// Obtener uso por punto de conexión/función
let by_endpoint: Vec<UsageByEndpoint> = sqlx::query_as(
r#"
SELECT
@@ -147,9 +147,9 @@ pub async fn get_ai_usage_dashboard(
.bind(filters.end_date.clone())
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso del punto de conexión: {}", e)))?;
// Get top users
// Obtener usuarios principales
let top_users: Vec<TopUserUsage> = sqlx::query_as(
r#"
SELECT
@@ -177,18 +177,18 @@ pub async fn get_ai_usage_dashboard(
.bind(filters.end_date.clone())
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los usuarios principales: {}", e)))?;
// Calculate summary stats
// Calcular estadísticas de resumen
let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum();
let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum();
let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum();
let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum();
let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum();
// Calculate savings estimate (vs OpenAI GPT-4 pricing)
// Calcular estimación de ahorros (frente a precios de OpenAI GPT-4)
// GPT-4: ~$0.03/1K input, ~$0.06/1K output
// Our local AI: ~$0.001/1K input, ~$0.003/1K output
// Nuestra IA local: ~$0.001/1K entrada, ~$0.003/1K salida
let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006);
let savings_vs_openai = openai_equivalent_cost - total_cost;
@@ -208,7 +208,7 @@ pub async fn get_ai_usage_dashboard(
}))
}
/// GET /api/admin/ai-usage/logs - Get detailed AI usage logs with pagination
/// GET /api/admin/ai-usage/logs - Obtener logs detallados de uso de IA con paginación
pub async fn get_ai_usage_logs(
Org(org_ctx): Org,
_claims: Claims,
@@ -254,9 +254,9 @@ pub async fn get_ai_usage_logs(
.bind(offset as i64)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch logs: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los logs: {}", e)))?;
// Get total count for pagination
// Obtener el conteo total para la paginación
let count: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM ai_usage_logs
@@ -272,7 +272,7 @@ pub async fn get_ai_usage_logs(
.bind(filters.user_id.clone())
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count logs: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al contar los logs: {}", e)))?;
Ok(Json(UsageLogsResponse {
logs,
@@ -427,15 +427,15 @@ pub struct TokenUsageResponse {
pub stats: TokenUsageStats,
}
// ==================== Global AI Usage Dashboard (Root Only) ====================
// ==================== Panel de Uso Global de IA (Solo Root) ====================
/// GET /api/admin/ai-usage/global - Global AI usage dashboard for root users
/// GET /api/admin/ai-usage/global - Panel de uso global de IA para usuarios root
pub async fn get_ai_usage_global(
_claims: Claims,
State(pool): State<PgPool>,
Query(filters): Query<DashboardFilters>,
) -> Result<Json<GlobalAiUsageResponse>, (StatusCode, String)> {
// Get daily usage for charts (all organizations)
// Obtener el uso diario para gráficos (todas las organizaciones)
let daily_usage: Vec<DailyUsage> = sqlx::query_as(
r#"
SELECT
@@ -457,9 +457,9 @@ pub async fn get_ai_usage_global(
.bind(filters.end_date.clone())
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso diario: {}", e)))?;
// Get usage by endpoint/feature
// Obtener uso por punto de conexión/función
let by_endpoint: Vec<UsageByEndpoint> = sqlx::query_as(
r#"
SELECT
@@ -481,9 +481,9 @@ pub async fn get_ai_usage_global(
.bind(filters.end_date.clone())
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso del punto de conexión: {}", e)))?;
// Get usage by organization
// Obtener uso por organización
let by_organization: Vec<UsageByOrganization> = sqlx::query_as(
r#"
SELECT
@@ -507,9 +507,9 @@ pub async fn get_ai_usage_global(
.bind(filters.end_date.clone())
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch org usage: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso de la organización: {}", e)))?;
// Get top users across all organizations
// Obtener usuarios principales en todas las organizaciones
let top_users: Vec<TopUserUsage> = sqlx::query_as(
r#"
SELECT
@@ -537,9 +537,9 @@ pub async fn get_ai_usage_global(
.bind(filters.end_date.clone())
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los usuarios principales: {}", e)))?;
// Get usage by request type (for pie chart)
// Obtener uso por tipo de solicitud (para gráfico circular)
let by_request_type: Vec<UsageByRequestType> = sqlx::query_as(
r#"
SELECT
@@ -558,18 +558,18 @@ pub async fn get_ai_usage_global(
.bind(filters.end_date.clone())
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch request type usage: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso del tipo de solicitud: {}", e)))?;
// Calculate summary stats
// Calcular estadísticas de resumen
let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum();
let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum();
let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum();
let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum();
let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum();
// Calculate savings estimate (vs OpenAI GPT-4 pricing)
// Calcular estimación de ahorros (frente a precios de OpenAI GPT-4)
// GPT-4: ~$0.03/1K input, ~$0.06/1K output
// Our local AI: ~$0.001/1K input, ~$0.003/1K output
// Nuestra IA local: ~$0.001/1K entrada, ~$0.003/1K salida
let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006);
let savings_vs_openai = openai_equivalent_cost - total_cost;
let savings_percentage = if openai_equivalent_cost > 0.0 {
@@ -578,7 +578,7 @@ pub async fn get_ai_usage_global(
0.0
};
// Get total active users count
// Obtener el conteo total de usuarios activos
let total_active_users: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(DISTINCT au.user_id)
@@ -591,9 +591,9 @@ pub async fn get_ai_usage_global(
.bind(filters.end_date.clone())
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count users: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al contar los usuarios: {}", e)))?;
// Calculate student-specific usage (chat interactions)
// Calcular uso específico por estudiante (interacciones de chat)
let student_chat_usage: Vec<StudentChatUsage> = sqlx::query_as(
r#"
SELECT
@@ -621,9 +621,9 @@ pub async fn get_ai_usage_global(
.bind(filters.end_date.clone())
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch student chat usage: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso de chat de estudiantes: {}", e)))?;
// Calculate student chat totals
// Calcular totales de chat de estudiantes
let total_student_chat_tokens: i64 = student_chat_usage.iter().map(|s| s.total_tokens).sum();
let total_student_chat_cost: f64 = student_chat_usage.iter().map(|s| s.cost_usd).sum();
let total_student_chat_requests: i64 = student_chat_usage.iter().map(|s| s.chat_requests).sum();
@@ -722,7 +722,7 @@ pub struct GlobalAiUsageResponse {
pub student_chat_usage: Vec<StudentChatUsage>,
}
// ==================== User Token Limits ====================
// ==================== Límites de Tokens de Usuario ====================
#[derive(Debug, Deserialize)]
pub struct SetTokenLimitPayload {
@@ -730,7 +730,7 @@ pub struct SetTokenLimitPayload {
pub token_limit_reset_day: Option<i32>,
}
/// PUT /admin/users/{user_id}/token-limit - Set monthly token limit for user
/// PUT /admin/users/{user_id}/token-limit - Establecer límite mensual de tokens para el usuario
pub async fn set_user_token_limit(
_org: Org,
_claims: Claims,
@@ -738,7 +738,7 @@ pub async fn set_user_token_limit(
Path(user_id): Path<Uuid>,
Json(payload): Json<SetTokenLimitPayload>,
) -> Result<StatusCode, (StatusCode, String)> {
// Validate reset day (1-28 to avoid month-end issues)
// Validar día de reinicio (1-28 para evitar problemas a fin de mes)
let reset_day = payload.token_limit_reset_day.unwrap_or(1).clamp(1, 28);
sqlx::query(
@@ -755,19 +755,19 @@ pub async fn set_user_token_limit(
.bind(user_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to set limit: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al establecer el límite: {}", e)))?;
Ok(StatusCode::OK)
}
/// GET /admin/users/{user_id}/token-usage - Get token usage for specific user
/// GET /admin/users/{user_id}/token-usage - Obtener uso de tokens para un usuario específico
pub async fn get_user_token_usage(
_org: Org,
_claims: Claims,
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<UserTokenUsageDetail>, (StatusCode, String)> {
// Get current month usage
// Obtener uso del mes actual
let usage: UserTokenUsageDetail = sqlx::query_as(
r#"
SELECT
@@ -790,12 +790,12 @@ pub async fn get_user_token_usage(
.bind(user_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch usage: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el uso: {}", e)))?;
Ok(Json(usage))
}
/// GET /admin/users/{user_id}/token-limit/check - Check if user has available tokens
/// GET /admin/users/{user_id}/token-limit/check - Comprobar si el usuario tiene tokens disponibles
pub async fn check_user_token_limit(
_org: Org,
_claims: Claims,
+25 -25
View File
@@ -158,7 +158,7 @@ async fn maybe_push_local_file_to_s3(
let bytes = tokio::fs::read(local_path)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read local file failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al leer el archivo local: {}", e)))?;
let client = build_s3_client(&settings).await?;
let key = build_s3_object_key(org_id, course_id, storage_filename);
@@ -189,7 +189,7 @@ async fn push_bytes_to_s3(
.body(bytes.into())
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 upload failed: {}", e)))?;
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al subir a S3: {}", e)))?;
let storage_path = format!("s3://{}/{}", settings.bucket, key);
let public_url = build_s3_public_url(settings, key);
@@ -200,7 +200,7 @@ async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, Stri
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
let settings = get_s3_settings().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"S3 storage path found but S3 is not configured".to_string(),
"Se encontró una ruta de almacenamiento S3 pero S3 no está configurado".to_string(),
))?;
let client = build_s3_client(&settings).await?;
client
@@ -209,7 +209,7 @@ async fn delete_storage_path(storage_path: &str) -> Result<(), (StatusCode, Stri
.key(key)
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 delete failed: {}", e)))?;
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al eliminar de S3: {}", e)))?;
return Ok(());
}
@@ -227,26 +227,26 @@ fn parse_s3_storage_path(path: &str) -> Option<(&str, &str)> {
}
/// GET /api/assets/s3-proxy/{bucket}/{*key}
/// Proxies private S3 objects through CMS so frontend URLs do not depend on public-read ACLs.
/// Realiza un proxy de objetos privados de S3 a través del CMS para que las URLs del frontend no dependan de ACLs de lectura pública.
pub async fn public_s3_proxy(
Path(params): Path<HashMap<String, String>>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let bucket = params
.get("bucket")
.cloned()
.ok_or((StatusCode::BAD_REQUEST, "Missing bucket".to_string()))?;
.ok_or((StatusCode::BAD_REQUEST, "Falta el cubo (bucket)".to_string()))?;
let key = params
.get("key")
.cloned()
.ok_or((StatusCode::BAD_REQUEST, "Missing key".to_string()))?;
.ok_or((StatusCode::BAD_REQUEST, "Falta la clave (key)".to_string()))?;
let settings = get_s3_settings().ok_or((
StatusCode::NOT_FOUND,
"S3 storage is not configured".to_string(),
"El almacenamiento S3 no está configurado".to_string(),
))?;
if bucket != settings.bucket {
return Err((StatusCode::FORBIDDEN, "Bucket not allowed".to_string()));
return Err((StatusCode::FORBIDDEN, "Cubo (bucket) no permitido".to_string()));
}
let storage_path = format!("s3://{}/{}", bucket, key);
@@ -257,13 +257,13 @@ pub async fn public_s3_proxy(
header::CONTENT_TYPE,
"application/octet-stream"
.parse()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?,
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Cabecera inválida: {}", e)))?,
);
headers.insert(
header::CACHE_CONTROL,
"public, max-age=3600"
.parse()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?,
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Cabecera inválida: {}", e)))?,
);
Ok((headers, bytes))
@@ -282,18 +282,18 @@ async fn read_storage_bytes(storage_path: &str) -> Result<Vec<u8>, (StatusCode,
.key(key)
.send()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 read failed: {}", e)))?;
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al leer de S3: {}", e)))?;
let data = output
.body
.collect()
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("S3 stream read failed: {}", e)))?;
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Error al leer el flujo de S3: {}", e)))?;
return Ok(data.into_bytes().to_vec());
}
tokio::fs::read(storage_path)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)))
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error de lectura: {}", e)))
}
/// POST /api/assets/upload - Subir un archivo a la biblioteca global
@@ -357,12 +357,12 @@ pub async fn upload_asset(
}
if data.is_empty() {
return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string()));
return Err((StatusCode::BAD_REQUEST, "No se subió ningún archivo".to_string()));
}
let asset_id = Uuid::new_v4();
// Ensure uploads directory exists
// Asegurar que el directorio de subidas existe
tokio::fs::create_dir_all("uploads")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -543,7 +543,7 @@ pub async fn delete_asset(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
// 1. Get asset metadata to find file path
// 1. Obtener metadatos del activo para encontrar la ruta del archivo
let asset: Asset = sqlx::query_as(
"SELECT * FROM assets WHERE id = $1 AND organization_id = $2"
)
@@ -552,16 +552,16 @@ pub async fn delete_asset(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Activo no encontrado".to_string()))?;
// 2. Delete from DB
// 2. Eliminar de la base de datos
sqlx::query("DELETE FROM assets WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Delete physical file or S3 object
// 3. Eliminar archivo físico u objeto de S3
let _ = delete_storage_path(&asset.storage_path).await;
Ok(StatusCode::NO_CONTENT)
@@ -582,7 +582,7 @@ pub async fn ingest_asset_for_rag(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Activo no encontrado".to_string()))?;
let extracted = extract_asset_text(&asset).await?;
let content = extracted.trim();
@@ -668,7 +668,7 @@ pub async fn ingest_asset_for_rag(
/// - file: ZIP requerido
/// - course_id: UUID opcional
/// - ingest_rag: true/false opcional (default false)
/// Extracts a unit number from a ZIP entry path using the top-level folder name.
/// Extrae un número de unidad de la ruta de una entrada ZIP usando el nombre de la carpeta de nivel superior.
/// Supports: "Unit 1/...", "Unidad 1/...", "unit-01/...", "01/...", "1/..."
fn extract_unit_number(entry_name: &str) -> Option<i32> {
let parts: Vec<&str> = entry_name.splitn(2, '/').collect();
@@ -680,7 +680,7 @@ fn extract_unit_number(entry_name: &str) -> Option<i32> {
return None;
}
let lower = folder.to_lowercase();
// Strip common textual prefixes, then parse leading digits
// Eliminar prefijos textuales comunes, luego analizar los dígitos iniciales
let stripped = lower
.trim_start_matches("unidad")
.trim_start_matches("unit")
@@ -739,13 +739,13 @@ async fn process_zip_entry_without_rag(
let temp_storage_path = format!("uploads/{}", temp_storage_filename);
tokio::fs::write(&temp_storage_path, &content)
.await
.map_err(|e| format!("{}: local write failed ({})", entry_name, e))?;
.map_err(|e| format!("{}: Error en la escritura local ({})", entry_name, e))?;
let final_storage_filename = format!("{}.mp4", asset_id);
let final_storage_path = format!("uploads/{}", final_storage_filename);
if let Err((_, msg)) = transcode_flv_to_mp4(&temp_storage_path, &final_storage_path).await {
let _ = tokio::fs::remove_file(&temp_storage_path).await;
return Err(format!("{}: flv transcode failed ({})", entry_name, msg));
return Err(format!("{}: la transcodificación de flv falló ({})", entry_name, msg));
}
let _ = tokio::fs::remove_file(&temp_storage_path).await;
+54 -54
View File
@@ -1,4 +1,4 @@
// Organization Branding Handlers
// Manejadores de Marca de la Organización
use axum::{
Json,
@@ -32,84 +32,84 @@ pub struct BrandingResponse {
pub secondary_color: String,
}
// Upload organization logo
// Cargar logo de la organización
pub async fn upload_organization_logo(
claims: common::auth::Claims,
Org(org_ctx): Org,
State(pool): State<PgPool>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Only admins can upload logos
// Solo los administradores pueden cargar logos
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
}
// Verify organization exists and user has access
// Verificar que la organización existe y el usuario tiene acceso
let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?;
.map_err(|_| (StatusCode::NOT_FOUND, "Organización no encontrada".into()))?;
// Process multipart form
// Procesar formulario multipart
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))?
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Error en multipart: {}", e)))?
{
let name = field.name().unwrap_or("").to_string();
if name == "file" {
let filename = field
.file_name()
.ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))?
.ok_or((StatusCode::BAD_REQUEST, "Faltan datos del nombre del archivo".into()))?
.to_string();
// Validate file extension
// Validar extensión del archivo
let ext = filename.split('.').last().unwrap_or("");
if !["png", "jpg", "jpeg", "svg"].contains(&ext.to_lowercase().as_str()) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid file type. Only PNG, JPG, and SVG allowed".into(),
"Tipo de archivo inválido. Solo se permiten PNG, JPG y SVG".into(),
));
}
let data = field.bytes().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read file: {}", e),
format!("Error al leer el archivo: {}", e),
)
})?;
// Validate file size (max 2MB)
// Validar tamaño del archivo (máx. 2MB)
if data.len() > 2 * 1024 * 1024 {
return Err((
StatusCode::BAD_REQUEST,
"File too large. Maximum 2MB allowed".into(),
"Archivo demasiado grande. Se permite un máximo de 2MB".into(),
));
}
// Create uploads directory if it doesn't exist
// Crear el directorio de subidas si no existe
std::fs::create_dir_all("uploads/org-logos").map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create directory: {}", e),
format!("Error al crear el directorio: {}", e),
)
})?;
// Generate unique filename
// Generar nombre de archivo único
let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext);
let filepath = format!("uploads/org-logos/{}", unique_filename);
// Save file
// Guardar archivo
std::fs::write(&filepath, &data).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to save file: {}", e),
format!("Error al guardar el archivo: {}", e),
)
})?;
// Update organization in database
// Actualizar organización en la base de datos
let logo_url = format!("/{}", filepath);
sqlx::query("UPDATE organizations SET logo_url = $1 WHERE id = $2")
.bind(&logo_url)
@@ -119,7 +119,7 @@ pub async fn upload_organization_logo(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
format!("Error de base de datos: {}", e),
)
})?;
@@ -136,92 +136,92 @@ pub async fn upload_organization_logo(
return Ok(Json(json!({
"logo_url": logo_url,
"message": "Logo uploaded successfully"
"message": "Logo cargado con éxito"
})));
}
}
Err((StatusCode::BAD_REQUEST, "No file provided".into()))
Err((StatusCode::BAD_REQUEST, "No se proporcionó ningún archivo".into()))
}
// Upload organization favicon
// Cargar favicon de la organización
pub async fn upload_organization_favicon(
claims: common::auth::Claims,
Org(org_ctx): Org,
State(pool): State<PgPool>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Only admins can upload favicons
// Solo los administradores pueden cargar favicons
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
}
// Verify organization exists and user has access
// Verificar que la organización existe y el usuario tiene acceso
let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1")
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?;
.map_err(|_| (StatusCode::NOT_FOUND, "Organización no encontrada".into()))?;
// Process multipart form
// Procesar formulario multipart
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Multipart error: {}", e)))?
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Error en multipart: {}", e)))?
{
let name = field.name().unwrap_or("").to_string();
if name == "file" {
let filename = field
.file_name()
.ok_or((StatusCode::BAD_REQUEST, "Missing filename".into()))?
.ok_or((StatusCode::BAD_REQUEST, "Falta el nombre del archivo".into()))?
.to_string();
// Validate file extension
// Validar extensión del archivo
let ext = filename.split('.').last().unwrap_or("");
if !["png", "jpg", "jpeg", "svg", "ico"].contains(&ext.to_lowercase().as_str()) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid file type. Only PNG, JPG, SVG, and ICO allowed".into(),
"Tipo de archivo inválido. Solo se permiten PNG, JPG, SVG e ICO".into(),
));
}
let data = field.bytes().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read file: {}", e),
format!("Error al leer el archivo: {}", e),
)
})?;
// Validate file size (max 512KB for favicons seems reasonable, but sticking to 1MB to be safe)
// Validar tamaño del archivo (el máx. razonable para favicons es 512KB, pero se mantiene en 1MB por seguridad)
if data.len() > 1024 * 1024 {
return Err((
StatusCode::BAD_REQUEST,
"File too large. Maximum 1MB allowed".into(),
"Archivo demasiado grande. Se permite un máximo de 1MB".into(),
));
}
// Create uploads directory if it doesn't exist
// Crear el directorio de subidas si no existe
std::fs::create_dir_all("uploads/org-favicons").map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create directory: {}", e),
format!("Error al crear el directorio: {}", e),
)
})?;
// Generate unique filename
// Generar nombre de archivo único
let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext);
let filepath = format!("uploads/org-favicons/{}", unique_filename);
// Save file
// Guardar archivo
std::fs::write(&filepath, &data).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to save file: {}", e),
format!("Error al guardar el archivo: {}", e),
)
})?;
// Update organization in database
// Actualizar organización en la base de datos
let favicon_url = format!("/{}", filepath);
sqlx::query("UPDATE organizations SET favicon_url = $1 WHERE id = $2")
.bind(&favicon_url)
@@ -231,7 +231,7 @@ pub async fn upload_organization_favicon(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
format!("Error de base de datos: {}", e),
)
})?;
@@ -248,27 +248,27 @@ pub async fn upload_organization_favicon(
return Ok(Json(json!({
"favicon_url": favicon_url,
"message": "Favicon uploaded successfully"
"message": "Favicon cargado con éxito"
})));
}
}
Err((StatusCode::BAD_REQUEST, "No file provided".into()))
Err((StatusCode::BAD_REQUEST, "No se proporcionó ningún archivo".into()))
}
// Update organization branding colors
// Actualizar colores de marca de la organización
pub async fn update_organization_branding(
claims: common::auth::Claims,
Org(org_ctx): Org,
State(pool): State<PgPool>,
Json(payload): Json<BrandingPayload>,
) -> Result<Json<Organization>, (StatusCode, String)> {
// Only admins can update branding
// Solo los administradores pueden actualizar la marca
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin access required".into()));
return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into()));
}
// Validate hex color format
// Validar formato de color hexadecimal
let validate_color = |color: &str| -> bool {
color.len() == 7
&& color.starts_with('#')
@@ -279,7 +279,7 @@ pub async fn update_organization_branding(
if !validate_color(primary) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid primary_color format. Use #RRGGBB".into(),
"Formato de primary_color inválido. Use #RRGGBB".into(),
));
}
}
@@ -288,12 +288,12 @@ pub async fn update_organization_branding(
if !validate_color(secondary) {
return Err((
StatusCode::BAD_REQUEST,
"Invalid secondary_color format. Use #RRGGBB".into(),
"Formato de secondary_color inválido. Use #RRGGBB".into(),
));
}
}
// Update organization
// Actualizar organización
let org = sqlx::query_as::<_, Organization>(
"UPDATE organizations
SET name = COALESCE($1, name),
@@ -316,7 +316,7 @@ pub async fn update_organization_branding(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
format!("Error de base de datos: {}", e),
)
})?;
@@ -334,7 +334,7 @@ pub async fn update_organization_branding(
Ok(Json(org))
}
// Get organization branding (public endpoint)
// Obtener marca de la organización (punto de conexión público)
pub async fn get_organization_branding(
State(pool): State<PgPool>,
) -> Result<Json<BrandingResponse>, StatusCode> {
+32 -32
View File
@@ -1,5 +1,5 @@
//! Handlers for PGVector embeddings in Question Bank
//! Enables semantic search and RAG with AI-powered embeddings
//! Manejadores para incrustaciones (embeddings) de PGVector en el Banco de Preguntas
//! Habilita la búsqueda semántica y RAG con incrustaciones impulsadas por IA
use axum::{
Json,
@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
// ==================== Query Parameters ====================
// ==================== Parámetros de Consulta ====================
#[derive(Debug, Deserialize)]
pub struct SemanticSearchFilters {
@@ -43,26 +43,26 @@ pub struct GenerateEmbeddingsResult {
pub duration_ms: u64,
}
// ==================== Generate Embeddings ====================
// ==================== Generar Incrustaciones (Embeddings) ====================
/// POST /api/question-bank/embeddings/generate - Generate embeddings for all questions without them
/// POST /api/question-bank/embeddings/generate - Generar incrustaciones para todas las preguntas que no las tengan
pub async fn generate_question_embeddings(
Org(org_ctx): Org,
State(pool): State<PgPool>,
) -> Result<Json<GenerateEmbeddingsResult>, (StatusCode, String)> {
let start = std::time::Instant::now();
// Create client that accepts invalid certificates (for dev with self-signed certs)
// Crear cliente que acepte certificados inválidos (para desarrollo con certificados autofirmados)
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?;
let ollama_url = ai::get_ollama_url();
let model = ai::get_embedding_model();
// Get questions without embeddings
// Obtener preguntas sin incrustaciones
let questions: Vec<QuestionBank> = sqlx::query_as(
r#"
SELECT * FROM question_bank
@@ -82,7 +82,7 @@ pub async fn generate_question_embeddings(
let mut failed = 0;
for question in questions {
// Generate embedding text (combine question + options + explanation)
// Generar el texto de la incrustación (combinar pregunta + opciones + explicación)
let mut embedding_text = question.question_text.clone();
if let Some(options) = &question.options {
@@ -104,12 +104,12 @@ pub async fn generate_question_embeddings(
embedding_text.push_str(explanation);
}
// Generate embedding
// Generar incrustación
match generate_embedding(&client, &ollama_url, &model, &embedding_text).await {
Ok(response) => {
let pgvector = ai::embedding_to_pgvector(&response.embedding);
// Update question with embedding
// Actualizar pregunta con la incrustación
let result: Result<(i64,), sqlx::Error> = sqlx::query_as(
r#"
UPDATE question_bank
@@ -127,16 +127,16 @@ pub async fn generate_question_embeddings(
match result {
Ok(_) => {
processed += 1;
tracing::debug!("Generated embedding for question {}", question.id);
tracing::debug!("Incrustación generada para la pregunta {}", question.id);
}
Err(e) => {
failed += 1;
tracing::error!("Failed to update embedding for question {}: {}", question.id, e);
tracing::error!("Error al actualizar la incrustación para la pregunta {}: {}", question.id, e);
}
}
}
Err(e) => {
tracing::error!("Failed to generate embedding for question {}: {}", question.id, e);
tracing::error!("Error al generar la incrustación para la pregunta {}: {}", question.id, e);
failed += 1;
}
}
@@ -145,7 +145,7 @@ pub async fn generate_question_embeddings(
let duration_ms = start.elapsed().as_millis() as u64;
tracing::info!(
"Generated embeddings: {} processed, {} failed in {}ms",
"Incrustaciones generadas: {} procesadas, {} fallidas en {}ms",
processed,
failed,
duration_ms
@@ -158,23 +158,23 @@ pub async fn generate_question_embeddings(
}))
}
/// POST /api/question-bank/:id/embedding/regenerate - Regenerate embedding for a specific question
/// POST /api/question-bank/:id/embedding/regenerate - Regenerar incrustación para una pregunta específica
pub async fn regenerate_question_embedding(
Org(org_ctx): Org,
Path(question_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Create client that accepts invalid certificates
// Crear cliente que acepte certificados inválidos
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?;
let ollama_url = ai::get_ollama_url();
let model = ai::get_embedding_model();
// Get question
// Obtener pregunta
let question: QuestionBank = sqlx::query_as(
"SELECT * FROM question_bank WHERE id = $1 AND organization_id = $2"
)
@@ -183,9 +183,9 @@ pub async fn regenerate_question_embedding(
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Question not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()))?;
// Generate embedding text
// Generar texto de la incrustación
let mut embedding_text = question.question_text.clone();
if let Some(options) = &question.options {
@@ -207,14 +207,14 @@ pub async fn regenerate_question_embedding(
embedding_text.push_str(explanation);
}
// Generate embedding
// Generar incrustación
let response = generate_embedding(&client, &ollama_url, &model, &embedding_text)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error de IA: {}", e)))?;
let pgvector = ai::embedding_to_pgvector(&response.embedding);
// Update question
// Actualizar pregunta
sqlx::query(
r#"
UPDATE question_bank
@@ -232,35 +232,35 @@ pub async fn regenerate_question_embedding(
Ok(StatusCode::OK)
}
// ==================== Semantic Search ====================
// ==================== Búsqueda Semántica ====================
/// GET /api/question-bank/semantic-search - Search questions by semantic similarity
/// GET /api/question-bank/semantic-search - Buscar preguntas por similitud semántica
pub async fn semantic_search(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Query(filters): Query<SemanticSearchFilters>,
) -> Result<Json<Vec<SemanticSearchResult>>, (StatusCode, String)> {
// Create client that accepts invalid certificates
// Crear cliente que acepte certificados inválidos
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?;
let ollama_url = ai::get_ollama_url();
let model = ai::get_embedding_model();
// Generate embedding for query
// Generar incrustación para la consulta
let embedding_response = generate_embedding(&client, &ollama_url, &model, &filters.query)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error de IA: {}", e)))?;
let pgvector = ai::embedding_to_pgvector(&embedding_response.embedding);
let limit = filters.limit.unwrap_or(20);
let threshold = filters.threshold.unwrap_or(0.5);
// Build query with optional filters
// Construir consulta con filtros opcionales
let mut query = String::from(
r#"
SELECT
@@ -316,7 +316,7 @@ pub async fn semantic_search(
Ok(Json(results))
}
/// GET /api/question-bank/similar/:id - Find questions similar to a given question
/// GET /api/question-bank/similar/:id - Encontrar preguntas similares a una pregunta dada
pub async fn find_similar_questions(
Org(org_ctx): Org,
Path(question_id): Path<Uuid>,
+7 -7
View File
@@ -13,7 +13,7 @@ use uuid::Uuid;
pub struct LibraryBlockFilters {
#[serde(rename = "type")]
pub block_type: Option<String>,
pub tags: Option<String>, // Comma-separated list
pub tags: Option<String>, // Lista separada por comas
pub search: Option<String>,
}
@@ -51,7 +51,7 @@ pub async fn list_library_blocks(
State(pool): State<PgPool>,
Query(filters): Query<LibraryBlockFilters>,
) -> Result<Json<Vec<LibraryBlock>>, (StatusCode, String)> {
// Base query
// Consulta base
let mut query = String::from("SELECT * FROM library_blocks WHERE organization_id = $1");
let mut param_count = 1;
@@ -78,7 +78,7 @@ pub async fn list_library_blocks(
query.push_str(" ORDER BY created_at DESC");
// Build query con bind dinámico
// Construir consulta con bind dinámico
let mut sql_query = sqlx::query_as::<_, LibraryBlock>(&query).bind(org_ctx.id);
if let Some(block_type) = &filters.block_type {
@@ -120,7 +120,7 @@ pub async fn get_library_block(
match block {
Some(b) => Ok(Json(b)),
None => Err((StatusCode::NOT_FOUND, "Block not found".to_string())),
None => Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string())),
}
}
@@ -140,7 +140,7 @@ pub async fn update_library_block(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if existing.is_none() {
return Err((StatusCode::NOT_FOUND, "Block not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string()));
}
// Update dinámico basado en campos provistos
@@ -201,7 +201,7 @@ pub async fn delete_library_block(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Block not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string()));
}
Ok(StatusCode::NO_CONTENT)
@@ -221,7 +221,7 @@ pub async fn increment_block_usage(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Block not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Bloque no encontrado".to_string()));
}
Ok(StatusCode::OK)
+172 -172
View File
@@ -92,7 +92,7 @@ async fn connect_mysql_pool(env_var: &str) -> Result<sqlx::MySqlPool, (StatusCod
use sqlx::mysql::MySqlPoolOptions;
let mysql_url = std::env::var(env_var)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, format!("{} not configured", env_var)))?;
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, format!("{} no configurada", env_var)))?;
let mut last_error = String::new();
@@ -112,7 +112,7 @@ async fn connect_mysql_pool(env_var: &str) -> Result<sqlx::MySqlPool, (StatusCod
Err(e) => {
last_error = e.to_string();
tracing::warn!(
"MySQL connection attempt {}/3 failed for {}: {}",
"Intento de conexión a MySQL {}/3 fallido para {}: {}",
attempt,
env_var,
last_error
@@ -128,13 +128,13 @@ async fn connect_mysql_pool(env_var: &str) -> Result<sqlx::MySqlPool, (StatusCod
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!(
"Failed to connect to MySQL after 3 attempts: {}",
"Error al conectar a MySQL tras 3 intentos: {}",
last_error
),
))
}
// ==================== MySQL Study Plans & Courses ====================
// ==================== Planes de Estudio y Cursos de MySQL ====================
#[derive(Debug, sqlx::FromRow, Serialize, Deserialize)]
pub struct MySqlStudyPlan {
@@ -163,7 +163,7 @@ pub struct MySqlCourse {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
/// Save or update study plans and courses from MySQL during import
/// 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,
@@ -172,14 +172,14 @@ pub async fn save_mysql_courses_and_plans(
) -> Result<(), String> {
let plans_count = plans.len();
let courses_count = courses.len();
tracing::info!("Saving {} study plans and {} courses from MySQL", plans_count, courses_count);
tracing::info!("Guardando {} planes de estudio y {} cursos de MySQL", plans_count, courses_count);
// Save study plans first
// Guardar planes de estudio primero
for plan in plans {
let course_type = calculate_course_type(&plan.nombre_plan);
tracing::debug!("Saving study plan: {} (ID: {})", plan.nombre_plan, plan.id_plan_de_estudios);
tracing::debug!("Guardando plan de estudios: {} (ID: {})", plan.nombre_plan, plan.id_plan_de_estudios);
// Mirror SAM structure in PostgreSQL using SAM-native column names.
// 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)
@@ -195,7 +195,7 @@ pub async fn save_mysql_courses_and_plans(
.bind(&plan.nombre_plan)
.execute(pool)
.await
.map_err(|e| format!("Failed to save SAM study plan mirror: {}", e))?;
.map_err(|e| format!("Error al guardar el reflejo del plan de estudios SAM: {}", e))?;
sqlx::query(
r#"
@@ -213,15 +213,15 @@ pub async fn save_mysql_courses_and_plans(
.bind(&course_type)
.execute(pool)
.await
.map_err(|e| format!("Failed to save study plan: {}", e))?;
.map_err(|e| format!("Error al guardar el plan de estudios: {}", e))?;
}
// Save courses
// Guardar cursos
for course in courses {
// Determine course_type from duration (40h = regular, 80h = intensive)
// 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!("Saving course: {} (ID: {}, Plan ID: {})", course.nombre_curso, course.id_cursos, course.id_plan_de_estudios);
tracing::debug!("Guardando curso: {} (ID: {}, ID de Plan: {})", course.nombre_curso, course.id_cursos, course.id_plan_de_estudios);
sqlx::query(
r#"
@@ -246,9 +246,9 @@ pub async fn save_mysql_courses_and_plans(
.bind(course.duracion)
.execute(pool)
.await
.map_err(|e| format!("Failed to save SAM course mirror: {}", e))?;
.map_err(|e| format!("Error al guardar el reflejo del curso SAM: {}", e))?;
// Get study_plan_id from mysql_study_plans
// 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"
)
@@ -256,7 +256,7 @@ pub async fn save_mysql_courses_and_plans(
.bind(org_id)
.fetch_one(pool)
.await
.map_err(|e| format!("Failed to find study plan: {}", e))?;
.map_err(|e| format!("Error al encontrar el plan de estudios: {}", e))?;
sqlx::query(
r#"
@@ -284,10 +284,10 @@ pub async fn save_mysql_courses_and_plans(
.bind(&level_calculated)
.execute(pool)
.await
.map_err(|e| format!("Failed to save course: {}", e))?;
.map_err(|e| format!("Error al guardar el curso: {}", e))?;
}
tracing::info!("Successfully saved {} study plans and {} courses", plans_count, courses_count);
tracing::info!("Se guardaron con éxito {} planes de estudio y {} cursos", plans_count, courses_count);
Ok(())
}
@@ -320,9 +320,9 @@ fn calculate_course_level(nivel: Option<i32>) -> String {
}
}
// ==================== Create ====================
// ==================== Crear ====================
/// POST /api/question-bank - Create a new question in the bank
/// POST /api/question-bank - Crear una nueva pregunta en el banco
pub async fn create_question(
Org(org_ctx): Org,
claims: Claims,
@@ -382,9 +382,9 @@ pub async fn create_question(
Ok(Json(question))
}
// ==================== List ====================
// ==================== Listar ====================
/// GET /api/question-bank - List questions with filters
/// GET /api/question-bank - Listar preguntas con filtros
pub async fn list_questions(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -396,7 +396,7 @@ pub async fn list_questions(
&& filters.search.is_none()
&& filters.has_audio.is_none()
{
// No filters - simple query
// 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",
@@ -504,9 +504,9 @@ pub async fn list_questions(
Ok(Json(questions))
}
// ==================== Get ====================
// ==================== Obtener ====================
/// GET /api/question-bank/{id} - Get a single question
/// GET /api/question-bank/{id} - Obtener una sola pregunta
pub async fn get_question(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
@@ -528,16 +528,16 @@ pub async fn get_question(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Question not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
Ok(Json(question))
}
// ==================== Update ====================
// ==================== Actualizar ====================
/// PUT /api/question-bank/{id} - Update a question
/// PUT /api/question-bank/{id} - Actualizar una pregunta
pub async fn update_question(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
@@ -599,16 +599,16 @@ pub async fn update_question(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Question not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
Ok(Json(question))
}
// ==================== Delete ====================
// ==================== Eliminar ====================
/// DELETE /api/question-bank/{id} - Delete (archive) a question
/// DELETE /api/question-bank/{id} - Eliminar (archivar) una pregunta
pub async fn delete_question(
Org(org_ctx): Org,
Path(id): Path<Uuid>,
@@ -628,15 +628,15 @@ pub async fn delete_question(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Question not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Import from MySQL ====================
// ==================== Importar desde MySQL ====================
/// POST /api/question-bank/import-mysql - Import questions from MySQL question bank
/// 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,
@@ -645,10 +645,10 @@ pub async fn import_from_mysql(
) -> Result<Json<Vec<QuestionBank>>, (StatusCode, String)> {
use serde_json::json;
// Connect to MySQL
// Conectar a MySQL
let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?;
// Fetch all study plans and courses from MySQL to sync them
// Obtener todos los planes de estudio y cursos de MySQL para sincronizarlos
let mysql_plans: Vec<MySqlPlanInfo> = sqlx::query_as(
r#"
SELECT DISTINCT
@@ -662,13 +662,13 @@ pub async fn import_from_mysql(
.fetch_all(&mysql_pool)
.await
.map_err(|e| {
let error_msg = format!("Failed to fetch: {}", e);
tracing::error!("MySQL Error: {}", error_msg);
tracing::error!("Check column names in your MySQL database (plandeestudios, curso tables)");
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!("Fetched {} study plans from MySQL", mysql_plans.len());
tracing::info!("Se obtuvieron {} planes de estudio de MySQL", mysql_plans.len());
let mysql_courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
@@ -688,19 +688,19 @@ pub async fn import_from_mysql(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?;
tracing::info!("Fetched {} courses from MySQL", mysql_courses.len());
tracing::info!("Se obtuvieron {} cursos de MySQL", mysql_courses.len());
// Save plans and courses to PostgreSQL
tracing::info!("Saving plans and courses to PostgreSQL...");
// 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!("Failed to save courses/plans: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al guardar cursos/planes: {}", e)))?;
// Fetch questions from MySQL
// Obtener preguntas de MySQL
let mysql_questions: Vec<MySqlQuestion> = if payload.import_all.unwrap_or(false) {
// Import ALL questions (no limit)
// Importar TODAS las preguntas (sin límite)
sqlx::query_as(
r#"
SELECT bp.idPregunta, bp.descripcion, bp.idTipoPregunta, bp.activo,
@@ -716,9 +716,9 @@ pub async fn import_from_mysql(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?
} else if let Some(course_id) = payload.mysql_course_id {
// Import all questions for a specific course (no limit)
// 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,
@@ -735,9 +735,9 @@ pub async fn import_from_mysql(
.bind(course_id)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?
} else if let Some(question_ids) = payload.question_ids {
// Fetch specific question IDs - use simple approach
// Obtener IDs de preguntas específicas - usar enfoque simple
let mut imported_questions: Vec<QuestionBank> = vec![];
for q_id in question_ids {
@@ -756,10 +756,10 @@ pub async fn import_from_mysql(
.bind(q_id)
.fetch_optional(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch question {}: {}", q_id, e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener la pregunta {}: {}", q_id, e)))?;
if let Some(question) = mq {
// Map MySQL question type to platform question type
// 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 {
@@ -804,7 +804,7 @@ pub async fn import_from_mysql(
.bind(&source_metadata)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to import question {}: {}", question.id_pregunta, e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al importar la pregunta {}: {}", question.id_pregunta, e)))?;
imported_questions.push(qb);
}
@@ -813,21 +813,21 @@ pub async fn import_from_mysql(
mysql_pool.close().await;
return Ok(Json(imported_questions));
} else {
return Err((StatusCode::BAD_REQUEST, "Must provide course_id, question_ids, or import_all".to_string()));
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 questions found in MySQL to import".to_string()));
return Err((StatusCode::NOT_FOUND, "No se encontraron preguntas en MySQL para importar".to_string()));
}
// Import questions into PostgreSQL
// Importar preguntas a PostgreSQL
let mut imported_questions: Vec<QuestionBank> = vec![];
let mut skipped_count = 0;
for mq in mysql_questions {
// Check if question already imported
// 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)"
)
@@ -835,17 +835,17 @@ pub async fn import_from_mysql(
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to check existing: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al comprobar existencia: {}", e)))?;
if exists.0 {
skipped_count += 1;
continue; // Skip already imported question
continue; // Omitir pregunta ya importada
}
// Map MySQL question type to platform question type
// 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);
// Create options for multiple choice (if applicable)
// 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 {
@@ -885,27 +885,27 @@ pub async fn import_from_mysql(
.bind(&mq.descripcion)
.bind(&question_type)
.bind(&options)
.bind(&serde_json::Value::Null) // Correct answer to be filled by user
.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!("Failed to import question {}: {}", mq.id_pregunta, e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al importar la pregunta {}: {}", mq.id_pregunta, e)))?;
imported_questions.push(question);
}
tracing::info!("Imported {} questions from MySQL (skipped {} already imported)", imported_questions.len(), skipped_count);
tracing::info!("Se importaron {} preguntas de MySQL (se omitieron {} ya importadas)", imported_questions.len(), skipped_count);
Ok(Json(imported_questions))
}
// ==================== Helpers ====================
// ==================== Auxiliares ====================
fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> QuestionBankType {
// Map MySQL question types to platform types
// First try by name, then by ID as fallback
// 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") {
@@ -929,13 +929,13 @@ fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> Questi
if nombre_lower.contains("corta") || nombre_lower.contains("short") || nombre_lower.contains("texto") {
return QuestionBankType::ShortAnswer;
}
// Audio type in MySQL is typically listening comprehension with multiple choice answers
// 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;
}
}
// Fallback to ID mapping
// Respaldo al mapeo por ID
match mysql_type {
1 => QuestionBankType::MultipleChoice, // Alternativa
2 => QuestionBankType::MultipleChoice, // Audio (listening comprehension)
@@ -948,7 +948,7 @@ fn map_mysql_question_type(mysql_type: i32, tipo_nombre: Option<&str>) -> Questi
}
}
/// Parse MySQL answers JSON and extract options and correct answer
/// Analizar el JSON de respuestas de MySQL y extraer opciones y respuesta correcta
fn parse_mysql_answers(
answers_json: Option<&str>,
question_type: QuestionBankType,
@@ -956,13 +956,13 @@ fn parse_mysql_answers(
if let Some(json_str) = answers_json {
if let Ok(answers) = serde_json::from_str::<Vec<serde_json::Value>>(json_str) {
if !answers.is_empty() {
// Extract options (all answer texts)
// Extraer opciones (todos los textos de respuesta)
let options: Vec<String> = answers
.iter()
.filter_map(|a| a.get("texto").and_then(|t| t.as_str()).map(String::from))
.collect();
// Extract correct answer(s)
// Extraer respuesta(s) correcta(s)
let correct_indices: Vec<usize> = answers
.iter()
.enumerate()
@@ -978,16 +978,16 @@ fn parse_mysql_answers(
let correct_answer = if question_type == QuestionBankType::TrueFalse ||
question_type == QuestionBankType::MultipleChoice {
// For multiple choice, store index/indices
// 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)) // Default to first
Some(serde_json::json!(0)) // Por defecto a la primera
}
} else {
// For other types, store the text
// 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()))
@@ -999,7 +999,7 @@ fn parse_mysql_answers(
}
}
// Default values if no answers provided
// 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"])),
@@ -1009,17 +1009,17 @@ fn parse_mysql_answers(
(default_options, Some(serde_json::json!(0)))
}
// ==================== MySQL Integration ====================
// ==================== Integración con MySQL ====================
/// GET /api/question-bank/mysql-courses - List courses from MySQL for import
/// GET /api/question-bank/mysql-courses - Listar cursos de MySQL para importación
pub async fn list_mysql_courses(
Org(_org_ctx): Org,
State(_pool): State<PgPool>,
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
// Connect to MySQL
// Conectar a MySQL
let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?;
// Fetch courses with their plan names
// Obtener cursos con sus nombres de plan
let courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
SELECT DISTINCT
@@ -1038,19 +1038,19 @@ pub async fn list_mysql_courses(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
.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 - Get all study plans from PostgreSQL (imported from MySQL)
/// 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<PgPool>,
) -> Result<Json<Vec<MySqlPlanInfo>>, (StatusCode, String)> {
// Read from SAM mirror in PostgreSQL with SAM-native fields.
// Leer del reflejo SAM en PostgreSQL con campos nativos de SAM.
let mut plans: Vec<MySqlPlanInfo> = sqlx::query_as(
r#"
SELECT
@@ -1064,9 +1064,9 @@ pub async fn get_mysql_plans(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch plans: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los planes: {}", e)))?;
// Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror.
// 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#"
@@ -1081,10 +1081,10 @@ pub async fn get_mysql_plans(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy plans: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los planes heredados: {}", e)))?;
}
// Last-resort auto-sync: if still empty, pull metadata from MySQL and persist it.
// 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) => {
@@ -1123,21 +1123,21 @@ pub async fn get_mysql_plans(
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!("Auto-sync MySQL metadata failed: {}", err);
tracing::warn!("Fallo la auto-sincronización de metadatos de MySQL: {}", err);
}
}
(Err(e), _) => tracing::warn!("Auto-sync plans query failed: {}", e),
(_, Err(e)) => tracing::warn!("Auto-sync courses query failed: {}", e),
(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!("Auto-sync could not connect to MySQL: {:?}", e);
tracing::warn!("La auto-sincronización no pudo conectarse a MySQL: {:?}", e);
}
}
// Reload plans after auto-sync attempt.
// Recargar planes tras el intento de auto-sincronización.
plans = sqlx::query_as(
r#"
SELECT
@@ -1174,13 +1174,13 @@ pub async fn get_mysql_plans(
Ok(Json(plans))
}
/// GET /api/question-bank/mysql-courses - Get courses filtered by plan from PostgreSQL
/// 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<PgPool>,
Query(filters): Query<MySqlCoursesFilters>,
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
// Read from SAM mirror in PostgreSQL with SAM-native fields.
// Leer del reflejo SAM en PostgreSQL con campos nativos de SAM.
let mut courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
SELECT
@@ -1205,9 +1205,9 @@ pub async fn get_mysql_courses_by_plan(
.bind(filters.plan_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?;
// Backward-compatible fallback: if SAM mirror is empty, use legacy metadata mirror.
// 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#"
@@ -1231,7 +1231,7 @@ pub async fn get_mysql_courses_by_plan(
.bind(filters.plan_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch legacy courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos heredados: {}", e)))?;
}
Ok(Json(courses))
@@ -1255,15 +1255,15 @@ pub struct MySqlCourseInfo {
pub nivel_curso: Option<i32>,
pub id_plan_de_estudios: i32,
pub nombre_plan: String,
pub duracion: Option<f64>, // Duration in hours (40=regular, 80=intensive) - MySQL float type
pub duracion: Option<f64>, // Duración en horas (40=regular, 80=intensivo) - Tipo float de MySQL
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportAllFromMySQLPayload {
pub import_metadata_only: Option<bool>, // Only import metadata (courses/plans), not questions
pub import_metadata_only: Option<bool>, // Solo importar metadatos (cursos/planes), no preguntas
}
/// POST /api/question-bank/import-mysql-all - Import ALL questions from MySQL (bulk import)
/// 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,
@@ -1272,7 +1272,7 @@ pub async fn import_all_from_mysql(
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
use serde_json::json;
// Parse optional JSON body
// Analizar el cuerpo JSON opcional
let import_metadata_only = if body.trim().is_empty() {
false
} else {
@@ -1281,10 +1281,10 @@ pub async fn import_all_from_mysql(
.unwrap_or(false)
};
// Connect to MySQL
// Conectar a MySQL
let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?;
// Fetch all study plans and courses from MySQL to sync them
// Obtener todos los planes de estudio y cursos de MySQL para sincronizarlos
let mysql_plans: Vec<MySqlPlanInfo> = sqlx::query_as(
r#"
SELECT DISTINCT
@@ -1298,13 +1298,13 @@ pub async fn import_all_from_mysql(
.fetch_all(&mysql_pool)
.await
.map_err(|e| {
let error_msg = format!("Failed to fetch: {}", e);
tracing::error!("MySQL Error: {}", error_msg);
tracing::error!("Check column names in your MySQL database (plandeestudios, curso tables)");
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!("Fetched {} study plans from MySQL", mysql_plans.len());
tracing::info!("Se obtuvieron {} planes de estudio de MySQL", mysql_plans.len());
let mysql_courses: Vec<MySqlCourseInfo> = sqlx::query_as(
r#"
@@ -1324,17 +1324,17 @@ pub async fn import_all_from_mysql(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?;
tracing::info!("Fetched {} courses from MySQL", mysql_courses.len());
tracing::info!("Se obtuvieron {} cursos de MySQL", mysql_courses.len());
// Save plans and courses to PostgreSQL
tracing::info!("Saving plans and courses to PostgreSQL...");
// 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!("Failed to save courses/plans: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al guardar cursos/planes: {}", e)))?;
// If only metadata import is requested, return early
// Si solo se solicita la importación de metadatos, salir antes
if import_metadata_only {
return Ok(Json(json!({
"success": true,
@@ -1353,7 +1353,7 @@ pub async fn import_all_from_mysql(
})));
}
// Fetch ALL questions from MySQL with answers (using JSON aggregation for answers)
// Obtener TODAS las preguntas de MySQL con respuestas (usando agregación JSON para las respuestas)
let mysql_questions: Vec<MySqlQuestionFull> = sqlx::query_as(
r#"
SELECT
@@ -1393,7 +1393,7 @@ pub async fn import_all_from_mysql(
)
.fetch_all(&mysql_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?;
mysql_pool.close().await;
@@ -1403,17 +1403,17 @@ pub async fn import_all_from_mysql(
"imported": 0,
"skipped": 0,
"updated": 0,
"error": "No questions found in MySQL"
"error": "No se encontraron preguntas en MySQL"
})));
}
// Import questions into PostgreSQL
// Importar preguntas a PostgreSQL
let mut imported_count = 0;
let mut skipped_count = 0;
let mut updated_count = 0;
for mq in mysql_questions {
// Check if question already exists
// 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"
)
@@ -1421,13 +1421,13 @@ pub async fn import_all_from_mysql(
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Check failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Fallo en la comprobación: {}", e)))?;
match existing {
Some((id, is_active)) => {
// Question exists - update if it was inactive or has new data
// La pregunta existe - actualizar si estaba inactiva o tiene nuevos datos
if !is_active {
// Reactivate and update
// 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"
)
@@ -1438,14 +1438,14 @@ pub async fn import_all_from_mysql(
.await;
updated_count += 1;
} else {
skipped_count += 1; // Already exists and active, skip
skipped_count += 1; // Ya existe y está activa, omitir
}
}
None => {
// New question - insert with answers from MySQL
// Nueva pregunta - insertar con respuestas de MySQL
let question_type = map_mysql_question_type(mq.id_tipo_pregunta, mq.tipo_pregunta_nombre.as_deref());
// Parse answers from MySQL
// 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();
@@ -1495,7 +1495,7 @@ pub async fn import_all_from_mysql(
}
tracing::info!(
"Bulk import from MySQL: {} imported, {} skipped, {} updated",
"Importación masiva desde MySQL: {} importadas, {} omitidas, {} actualizadas",
imported_count,
skipped_count,
updated_count
@@ -1544,7 +1544,7 @@ pub struct MySqlQuestionFull {
pub nivel_curso: Option<i32>,
pub id_plan_de_estudios: i32,
pub plan_nombre: String,
pub respuestas_json: Option<String>, // JSON array de respuestas
pub respuestas_json: Option<String>, // Array JSON de respuestas
pub tipo_pregunta_nombre: Option<String>, // Nombre del tipo de pregunta
}
@@ -1560,7 +1560,7 @@ struct MySqlQuestion {
plan_nombre: String,
}
// ==================== AI Generation ====================
// ==================== Generación por IA ====================
#[derive(Debug, Deserialize)]
pub struct AIGenerateQuestionPayload {
@@ -1578,7 +1578,7 @@ pub struct AIQuestionResponse {
pub explanation: String,
}
/// POST /question-bank/ai-generate - Generate question options/answers using AI (Ollama only)
/// 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,
@@ -1587,11 +1587,11 @@ pub async fn ai_generate_question(
use std::env;
use std::time::Duration;
let question_text = payload.question_text.unwrap_or_else(|| "English grammar question".to_string());
let question_text = payload.question_text.unwrap_or_else(|| "Pregunta de gramática inglesa".to_string());
let difficulty = payload.difficulty.unwrap_or_else(|| "medium".to_string());
let skill = payload.skill.unwrap_or_else(|| "grammar".to_string());
// Build prompt for AI
// Construir prompt para la IA
let system_prompt = format!(
r#"You are an expert English Teacher creating quiz questions.
@@ -1610,7 +1610,7 @@ pub async fn ai_generate_question(
question_text, difficulty, skill
);
// Call Ollama AI with extended timeout
// 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);
@@ -1628,37 +1628,37 @@ pub async fn ai_generate_question(
"model": model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": "Generate the question in JSON format" }
{ "role": "user", "content": "Generar la pregunta en formato JSON" }
],
"stream": false,
"format": "json"
}))
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI request failed: {}", e)))?;
.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!("AI API error: {}", response.status())));
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!("Failed to parse AI response: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar la respuesta de la IA: {}", e)))?;
// Extract content from Ollama response
// 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, "Invalid AI response format".to_string()))?;
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Formato de respuesta de IA no válido".to_string()))?;
// Parse AI response as JSON
// 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!("Failed to parse question JSON: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al analizar el JSON de la pregunta: {}", e)))?;
Ok(Json(ai_question))
}
// ==================== Import Courses from MySQL ====================
// ==================== Importar Cursos de MySQL ====================
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportCourseFromMySQLPayload {
@@ -1678,7 +1678,7 @@ pub struct ImportCourseResult {
pub message: String,
}
/// POST /api/question-bank/import-course-mysql - Import a course from MySQL with basic structure
/// 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,
@@ -1687,10 +1687,10 @@ pub async fn import_course_from_mysql(
) -> Result<Json<ImportCourseResult>, (StatusCode, String)> {
use common::models::Course;
// Connect to MySQL
// Conectar a MySQL
let mysql_pool = connect_mysql_pool("MYSQL_DATABASE_URL").await?;
// Fetch course info from MySQL
// Obtener información del curso de MySQL
let mysql_course: MySqlCourseInfo = sqlx::query_as(
r#"
SELECT
@@ -1710,25 +1710,25 @@ pub async fn import_course_from_mysql(
.await
.map_err(|e| {
if let sqlx::Error::RowNotFound = e {
(StatusCode::NOT_FOUND, format!("Course with ID {} not found in MySQL", payload.mysql_course_id))
(StatusCode::NOT_FOUND, format!("Curso con ID {} no encontrado en MySQL", payload.mysql_course_id))
} else {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch course from MySQL: {}", e))
(StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener el curso de MySQL: {}", e))
}
})?;
tracing::info!("Importing course from MySQL: {} (ID: {})", mysql_course.nombre_curso, mysql_course.id_cursos);
tracing::info!("Importando curso de MySQL: {} (ID: {})", mysql_course.nombre_curso, mysql_course.id_cursos);
// Start transaction
// Iniciar transacción
let mut tx = pool.begin().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to start transaction: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al iniciar la transacción: {}", e)))?;
// Determine course type and level for structure generation
// 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);
// Create course in PostgreSQL
// 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));
@@ -1756,11 +1756,11 @@ pub async fn import_course_from_mysql(
.bind(mysql_course.id_cursos)
.fetch_one(&mut *tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create course: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al crear el curso: {}", e)))?;
tracing::info!("Created course in PostgreSQL: {}", new_course.id);
tracing::info!("Curso creado en PostgreSQL: {}", new_course.id);
// Generate basic course structure based on course type and level
// 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,
@@ -1772,12 +1772,12 @@ pub async fn import_course_from_mysql(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
// Commit transaction
// Confirmar transacción
tx.commit().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to commit transaction: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al confirmar la transacción: {}", e)))?;
tracing::info!(
"Successfully imported course {} with {} modules and {} lessons",
"Curso {} importado con éxito con {} módulos y {} lecciones",
new_course.id,
modules_count,
lessons_count
@@ -1794,7 +1794,7 @@ pub async fn import_course_from_mysql(
}))
}
/// Generate basic course structure based on type and level
/// 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,
@@ -1803,9 +1803,9 @@ async fn generate_course_structure<'a>(
level: &str,
course_name: &str,
) -> Result<(i32, i32), String> {
// Define module structure based on course type
// Regular (40h): 4 modules
// Intensive (80h): 8 modules
// 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),
@@ -1829,7 +1829,7 @@ async fn generate_course_structure<'a>(
let mut total_lessons = 0;
for (module_idx, (module_name, lessons_count)) in modules_config.iter().enumerate() {
// Create module
// Crear módulo
let module: common::models::Module = sqlx::query_as(
r#"
INSERT INTO modules (course_id, organization_id, title, position)
@@ -1843,16 +1843,16 @@ async fn generate_course_structure<'a>(
.bind(module_idx as i32)
.fetch_one(&mut **tx)
.await
.map_err(|e| format!("Failed to create module {}: {}", module_idx + 1, e))?;
.map_err(|e| format!("Error al crear el módulo {}: {}", module_idx + 1, e))?;
total_modules += 1;
// Create lessons for this module
// 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);
// Determine content type based on position (rotate through types)
// 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()];
@@ -1868,13 +1868,13 @@ async fn generate_course_structure<'a>(
.bind(org_id)
.bind(&lesson_title)
.bind(content_type)
.bind("") // Empty content URL (to be filled by instructor)
.bind("") // URL de contenido vacía (para que el instructor la complete)
.bind(lesson_position)
.bind(lesson_idx % 4 == 3) // Every 4th lesson is graded (quiz)
.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!("Failed to create lesson {}: {}", lesson_position, e))?;
.map_err(|e| format!("Error al crear la lección {}: {}", lesson_position, e))?;
total_lessons += 1;
}
@@ -1883,7 +1883,7 @@ async fn generate_course_structure<'a>(
Ok((total_modules, total_lessons))
}
// ==================== Import from SAM Diagnostico ====================
// ==================== Importar desde SAM Diagnóstico ====================
/// Row retornada por GROUP BY sobre las tablas de SAM_diagnostico
#[derive(Debug, sqlx::FromRow)]
@@ -2011,7 +2011,7 @@ pub async fn import_from_sam_diagnostico(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to check duplicate: {}", e),
format!("Error al comprobar duplicado: {}", e),
)
})?;
@@ -2083,7 +2083,7 @@ pub async fn import_from_sam_diagnostico(
mysql_pool.close().await;
tracing::info!(
"SAM_diagnostico import done: imported={} skipped={} errors={}",
"Importación de SAM_diagnostico finalizada: imported={} skipped={} errors={}",
total_imported, total_skipped, errors.len()
);
+30 -30
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
use uuid::Uuid;
// ==================== Payload Structs ====================
// ==================== Estructuras de Carga Útil (Payload) ====================
#[derive(Debug, Deserialize)]
pub struct CreateRubricPayload {
@@ -97,9 +97,9 @@ pub struct CriterionWithLevels {
pub levels: Vec<RubricLevel>,
}
// ==================== Rubric Management ====================
// ==================== Gestión de Rúbricas ====================
/// Create a new rubric
/// Crear una nueva rúbrica
pub async fn create_rubric(
Org(org_ctx): Org,
claims: Claims,
@@ -126,7 +126,7 @@ pub async fn create_rubric(
Ok(Json(rubric))
}
/// List all rubrics for a course
/// Listar todas las rúbricas de un curso
pub async fn list_course_rubrics(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -149,7 +149,7 @@ pub async fn list_course_rubrics(
Ok(Json(rubrics))
}
/// Get a rubric with all criteria and levels
/// Obtener una rúbrica con todos los criterios y niveles
pub async fn get_rubric_with_details(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -168,7 +168,7 @@ pub async fn get_rubric_with_details(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()))?;
// Get criteria
let criteria: Vec<RubricCriterion> = sqlx::query_as(
@@ -209,7 +209,7 @@ pub async fn get_rubric_with_details(
}))
}
/// Update a rubric
/// Actualizar una rúbrica
pub async fn update_rubric(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -233,12 +233,12 @@ pub async fn update_rubric(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()))?;
Ok(Json(rubric))
}
/// Delete a rubric
/// Eliminar una rúbrica
pub async fn delete_rubric(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -252,15 +252,15 @@ pub async fn delete_rubric(
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Rubric not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Criterion Management ====================
// ==================== Gestión de Criterios ====================
/// Add a criterion to a rubric
/// Añadir un criterio a una rúbrica
pub async fn create_criterion(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -274,7 +274,7 @@ pub async fn create_criterion(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string()))?;
let position = payload.position.unwrap_or(0);
@@ -311,7 +311,7 @@ pub async fn create_criterion(
Ok(Json(criterion))
}
/// Update a criterion
/// Actualizar un criterio
pub async fn update_criterion(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -339,7 +339,7 @@ pub async fn update_criterion(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()))?;
// Update rubric total_points if max_points changed
if payload.max_points.is_some() {
@@ -360,7 +360,7 @@ pub async fn update_criterion(
Ok(Json(criterion))
}
/// Delete a criterion
/// Eliminar un criterio
pub async fn delete_criterion(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -372,7 +372,7 @@ pub async fn delete_criterion(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()))?;
let rubric_id: Uuid = criterion_row.get("rubric_id");
@@ -390,7 +390,7 @@ pub async fn delete_criterion(
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Criterion not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()));
}
// Update rubric total_points
@@ -410,9 +410,9 @@ pub async fn delete_criterion(
Ok(StatusCode::NO_CONTENT)
}
// ==================== Performance Level Management ====================
// ==================== Gestión de Niveles de Desempeño ====================
/// Add a performance level to a criterion
/// Añadir un nivel de desempeño a un criterio
pub async fn create_level(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -426,7 +426,7 @@ pub async fn create_level(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Criterio no encontrado".to_string()))?;
let position = payload.position.unwrap_or(0);
@@ -449,7 +449,7 @@ pub async fn create_level(
Ok(Json(level))
}
/// Update a performance level
/// Actualizar un nivel de desempeño
pub async fn update_level(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -480,12 +480,12 @@ pub async fn update_level(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Level not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Nivel no encontrado".to_string()))?;
Ok(Json(level))
}
/// Delete a performance level
/// Eliminar un nivel de desempeño
pub async fn delete_level(
Org(org_ctx): Org,
State(pool): State<PgPool>,
@@ -508,15 +508,15 @@ pub async fn delete_level(
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Level not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Nivel no encontrado".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Lesson-Rubric Association ====================
// ==================== Asociación Lección-Rúbrica ====================
/// Assign a rubric to a lesson
/// Asignar una rúbrica a una lección
pub async fn assign_rubric_to_lesson(
Org(_org_ctx): Org,
State(pool): State<PgPool>,
@@ -539,7 +539,7 @@ pub async fn assign_rubric_to_lesson(
Ok(Json(lesson_rubric))
}
/// Unassign a rubric from a lesson
/// Desasignar una rúbrica de una lección
pub async fn unassign_rubric_from_lesson(
Org(_org_ctx): Org,
State(pool): State<PgPool>,
@@ -553,13 +553,13 @@ pub async fn unassign_rubric_from_lesson(
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Vínculo lección-rúbrica no encontrado".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
/// Get rubrics assigned to a lesson
/// Obtener rúbricas asignadas a una lección
pub async fn get_lesson_rubrics(
Org(org_ctx): Org,
State(pool): State<PgPool>,
+53 -53
View File
@@ -1,6 +1,6 @@
/// SAM (Sistema de Administración Académica) Integration Handlers
/// Manejadores de Integración con SAM (Sistema de Administración Académica)
///
/// This module handles synchronization between OpenCCB and the external SAM system.
/// Este módulo maneja la sincronización entre OpenCCB y el sistema externo SAM.
/// SAM tables: sige_sam_v3.alumnos, sige_sam_v3.detalle_contrato
use axum::{
@@ -50,27 +50,27 @@ pub struct SamStudentFilters {
pub nombre: Option<String>,
}
/// ==================== SAM Sync Handlers ====================
/// ==================== Manejadores de Sincronización SAM ====================
/// POST /api/sam/sync-students
/// Sync students from sige_sam_v3.alumnos to OpenCCB users table
/// Sincronizar estudiantes de sige_sam_v3.alumnos a la tabla de usuarios de OpenCCB
pub async fn sync_sam_students(
_org: Org,
_claims: Claims,
State(pool): State<PgPool>,
) -> Result<Json<SamSyncResponse>, (StatusCode, String)> {
// Connect to external SAM database
// Conectar a la base de datos externa de SAM
let sam_url = std::env::var("SAM_DATABASE_URL")
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?;
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL no configurada".to_string()))?;
let sam_pool = sqlx::PgPool::connect(&sam_url)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al conectar con la base de datos SAM: {}", e)))?;
let mut errors = Vec::new();
let mut students_synced = 0;
// Fetch active students from SAM using generic query
// Obtener estudiantes activos de SAM usando una consulta genérica
let rows = sqlx::query(
r#"
SELECT
@@ -85,9 +85,9 @@ pub async fn sync_sam_students(
)
.fetch_all(&sam_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener estudiantes de SAM: {}", e)))?;
// Convert to SamStudentInfo
// Convertir a SamStudentInfo
let sam_students: Vec<SamStudentInfo> = rows.iter().map(|row| {
SamStudentInfo {
id_alumno: row.get("id_alumno"),
@@ -98,9 +98,9 @@ pub async fn sync_sam_students(
}
}).collect();
// Sync each student to OpenCCB
// Sincronizar cada estudiante con OpenCCB
for sam_student in sam_students {
// Check if user exists by email
// Comprobar si el usuario existe por correo electrónico
let existing_user: Option<(Uuid, Option<String>)> = sqlx::query_as(
"SELECT id, sam_student_id FROM users WHERE email = $1"
)
@@ -108,7 +108,7 @@ pub async fn sync_sam_students(
.fetch_optional(&pool)
.await
.map_err(|e| {
errors.push(format!("Error checking user {}: {}", sam_student.email, e));
errors.push(format!("Error al comprobar el usuario {}: {}", sam_student.email, e));
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})
.ok()
@@ -116,7 +116,7 @@ pub async fn sync_sam_students(
match existing_user {
Some((user_id, existing_sam_id)) => {
// Update existing user with SAM info
// Actualizar usuario existente con información de SAM
let update_result = sqlx::query(
r#"
UPDATE users
@@ -136,11 +136,11 @@ pub async fn sync_sam_students(
if update_result.is_ok() {
students_synced += 1;
} else {
errors.push(format!("Failed to update user {}", sam_student.email));
errors.push(format!("Error al actualizar el usuario {}", sam_student.email));
}
}
None => {
// Create new user for SAM student
// Crear nuevo usuario para el estudiante de SAM
let insert_result = sqlx::query(
r#"
INSERT INTO users (
@@ -157,18 +157,18 @@ pub async fn sync_sam_students(
"#
)
.bind(&sam_student.email)
.bind(format!("sam_managed_{}", sam_student.id_alumno)) // Placeholder password
.bind(format!("sam_managed_{}", sam_student.id_alumno)) // Contraseña provisional
.bind(&sam_student.nombre)
.bind("student")
.bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) // Default org
.bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) // Organización por defecto
.bind(&sam_student.id_alumno)
.fetch_optional(&pool)
.await;
match insert_result {
Ok(Some(_)) => students_synced += 1,
Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)),
Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)),
Ok(None) => errors.push(format!("Error al crear el usuario para {}", sam_student.email)),
Err(e) => errors.push(format!("Error al crear el usuario {}: {}", sam_student.email, e)),
}
}
}
@@ -184,24 +184,24 @@ pub async fn sync_sam_students(
}
/// POST /api/sam/sync-assignments
/// Sync course assignments from sige_sam_v3.detalle_contrato
/// Sincronizar asignaciones de cursos desde sige_sam_v3.detalle_contrato
pub async fn sync_sam_assignments(
_org: Org,
_claims: Claims,
State(pool): State<PgPool>,
) -> Result<Json<SamSyncResponse>, (StatusCode, String)> {
// Connect to external SAM database
// Conectar a la base de datos externa de SAM
let sam_url = std::env::var("SAM_DATABASE_URL")
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?;
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL no configurada".to_string()))?;
let sam_pool = sqlx::PgPool::connect(&sam_url)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al conectar con la base de datos SAM: {}", e)))?;
let mut errors = Vec::new();
let mut assignments_synced = 0;
// Fetch active course assignments from SAM
// Obtener asignaciones de cursos activas de SAM
let rows = sqlx::query(
r#"
SELECT
@@ -215,9 +215,9 @@ pub async fn sync_sam_assignments(
)
.fetch_all(&sam_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener asignaciones: {}", e)))?;
// Convert to SamAssignmentInfo
// Convertir a SamAssignmentInfo
let sam_assignments: Vec<SamAssignmentInfo> = rows.iter().map(|row| {
SamAssignmentInfo {
id_contrato: row.get("id_contrato"),
@@ -227,10 +227,10 @@ pub async fn sync_sam_assignments(
}
}).collect();
// Sync each assignment
// Sincronizar cada asignación
for assignment in sam_assignments {
// Get the OpenCCB course ID from SAM course ID
// This assumes you have a mapping table or the SAM course ID matches OpenCCB course external_id
// Obtener el ID del curso de OpenCCB a partir del ID del curso SAM
// Esto asume que tienes una tabla de mapeo o que el ID del curso SAM coincide con el external_id del curso en OpenCCB
let course_result = sqlx::query_as::<_, (Uuid,)>(
"SELECT id FROM courses WHERE external_sam_id = $1 OR id = $2"
)
@@ -243,13 +243,13 @@ pub async fn sync_sam_assignments(
Ok(Some((id,))) => Some(id),
Ok(None) => None,
Err(e) => {
errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e));
errors.push(format!("Error al encontrar el curso {}: {}", assignment.id_curso_abierto, e));
continue;
}
};
if let Some(course_id) = course_id {
// Upsert assignment
// Actualizar o insertar asignación
let upsert_result = sqlx::query(
r#"
INSERT INTO sam_course_assignments (sam_student_id, sam_contrato_id, course_id, is_active, synced_at)
@@ -270,7 +270,7 @@ pub async fn sync_sam_assignments(
if upsert_result.is_ok() {
assignments_synced += 1;
} else {
errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno));
errors.push(format!("Error al sincronizar la asignación para el estudiante {}", assignment.id_alumno));
}
}
}
@@ -285,7 +285,7 @@ pub async fn sync_sam_assignments(
}
/// GET /api/sam/students
/// List SAM students with optional filters
/// Listar estudiantes de SAM con filtros opcionales
pub async fn list_sam_students(
_org: Org,
_claims: Claims,
@@ -318,7 +318,7 @@ pub async fn list_sam_students(
let rows = sqlx::query(&query)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch students: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los estudiantes: {}", e)))?;
let students: Vec<serde_json::Value> = rows.iter().map(|row| {
serde_json::json!({
@@ -336,7 +336,7 @@ pub async fn list_sam_students(
}
/// GET /api/sam/students/{student_id}/courses
/// Get courses assigned to a specific SAM student
/// Obtener cursos asignados a un estudiante SAM específico
pub async fn get_sam_student_courses(
_org: Org,
_claims: Claims,
@@ -355,7 +355,7 @@ pub async fn get_sam_student_courses(
.bind(&sam_student_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch courses: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los cursos: {}", e)))?;
let courses: Vec<serde_json::Value> = rows.iter().map(|row| {
serde_json::json!({
@@ -371,7 +371,7 @@ pub async fn get_sam_student_courses(
}
/// POST /api/sam/sync-all
/// Full synchronization: students + assignments
/// Sincronización completa: estudiantes + asignaciones
pub async fn sync_all_sam(
org: Org,
claims: Claims,
@@ -381,17 +381,17 @@ pub async fn sync_all_sam(
let mut students_synced = 0;
let mut assignments_synced = 0;
// Connect to external SAM database
// Conectar a la base de datos externa de SAM
let sam_url = std::env::var("SAM_DATABASE_URL")
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL not configured".to_string()))?;
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "SAM_DATABASE_URL no configurada".to_string()))?;
let sam_pool = sqlx::PgPool::connect(&sam_url)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to SAM DB: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al conectar con la base de datos SAM: {}", e)))?;
// Sync students first
// Sincronizar estudiantes primero
{
// Fetch active students from SAM
// Obtener estudiantes activos de SAM
let rows = sqlx::query(
r#"
SELECT id_alumno, nombre, email, telefono, activo
@@ -401,7 +401,7 @@ pub async fn sync_all_sam(
)
.fetch_all(&sam_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch SAM students: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener estudiantes de SAM: {}", e)))?;
let sam_students: Vec<SamStudentInfo> = rows.iter().map(|row| {
SamStudentInfo {
@@ -413,7 +413,7 @@ pub async fn sync_all_sam(
}
}).collect();
// Sync each student
// Sincronizar cada estudiante
for sam_student in sam_students {
let existing_user: Option<(Uuid, Option<String>)> = sqlx::query_as(
"SELECT id, sam_student_id FROM users WHERE email = $1"
@@ -422,7 +422,7 @@ pub async fn sync_all_sam(
.fetch_optional(&pool)
.await
.map_err(|e| {
errors.push(format!("Error checking user {}: {}", sam_student.email, e));
errors.push(format!("Error al comprobar el usuario {}: {}", sam_student.email, e));
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})
.ok()
@@ -439,7 +439,7 @@ pub async fn sync_all_sam(
.execute(&pool)
.await;
if update_result.is_ok() { students_synced += 1; } else { errors.push(format!("Failed to update user {}", sam_student.email)); }
if update_result.is_ok() { students_synced += 1; } else { errors.push(format!("Error al actualizar el usuario {}", sam_student.email)); }
}
None => {
let insert_result = sqlx::query(
@@ -456,22 +456,22 @@ pub async fn sync_all_sam(
match insert_result {
Ok(Some(_)) => students_synced += 1,
Ok(None) => errors.push(format!("Failed to create user for {}", sam_student.email)),
Err(e) => errors.push(format!("Error creating user {}: {}", sam_student.email, e)),
Ok(None) => errors.push(format!("Error al crear el usuario para {}", sam_student.email)),
Err(e) => errors.push(format!("Error al crear el usuario {}: {}", sam_student.email, e)),
}
}
}
}
}
// Sync assignments
// Sincronizar asignaciones
{
let rows = sqlx::query(
"SELECT id_contrato, id_alumno, id_curso_abierto, estado FROM sige_sam_v3.detalle_contrato WHERE estado = 'activo' OR estado = 'vigente'"
)
.fetch_all(&sam_pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch assignments: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener asignaciones: {}", e)))?;
let sam_assignments: Vec<SamAssignmentInfo> = rows.iter().map(|row| {
SamAssignmentInfo {
@@ -494,7 +494,7 @@ pub async fn sync_all_sam(
let course_id = match course_result {
Ok(Some((id,))) => Some(id),
Ok(None) => continue,
Err(e) => { errors.push(format!("Error finding course {}: {}", assignment.id_curso_abierto, e)); continue; }
Err(e) => { errors.push(format!("Error al encontrar el curso {}: {}", assignment.id_curso_abierto, e)); continue; }
};
if let Some(course_id) = course_id {
@@ -507,7 +507,7 @@ pub async fn sync_all_sam(
.execute(&pool)
.await;
if upsert_result.is_ok() { assignments_synced += 1; } else { errors.push(format!("Failed to sync assignment for student {}", assignment.id_alumno)); }
if upsert_result.is_ok() { assignments_synced += 1; } else { errors.push(format!("Error al sincronizar la asignación para el estudiante {}", assignment.id_alumno)); }
}
}
}
@@ -58,17 +58,17 @@ fn normalize_question_bank_payload_values(
#[derive(Debug, Deserialize)]
pub struct TestTemplateFilters {
pub mysql_course_id: Option<i32>, // Filter by MySQL course ID
pub mysql_course_id: Option<i32>, // Filtrar por ID de curso MySQL
pub level: Option<CourseLevel>,
pub course_type: Option<CourseType>,
pub test_type: Option<TestType>,
pub tags: Option<String>, // Comma-separated list
pub tags: Option<String>, // Lista separada por comas
pub search: Option<String>,
}
// ==================== Create ====================
/// POST /api/test-templates - Create a new test template
/// POST /api/test-templates - Crear una nueva plantilla de test
pub async fn create_test_template(
Org(org_ctx): Org,
claims: Claims,
@@ -115,47 +115,47 @@ pub async fn create_test_template(
// ==================== Read ====================
/// GET /api/test-templates - List test templates with filters
/// GET /api/test-templates - Listar plantillas de test con filtros
pub async fn list_test_templates(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Query(filters): Query<TestTemplateFilters>,
) -> Result<Json<Vec<TestTemplate>>, (StatusCode, String)> {
// Base query
// Consulta base
let mut query = String::from("SELECT * FROM test_templates WHERE organization_id = $1");
let mut param_count = 1;
// Filter by mysql_course_id
// Filtrar por mysql_course_id
if filters.mysql_course_id.is_some() {
param_count += 1;
query.push_str(&format!(" AND mysql_course_id = ${}", param_count));
}
// Filter by level
// Filtrar por nivel
if filters.level.is_some() {
param_count += 1;
query.push_str(&format!(" AND level = ${}", param_count));
}
// Filter by course type
// Filtrar por tipo de curso
if filters.course_type.is_some() {
param_count += 1;
query.push_str(&format!(" AND course_type = ${}", param_count));
}
// Filter by test type
// Filtrar por tipo de test
if filters.test_type.is_some() {
param_count += 1;
query.push_str(&format!(" AND test_type = ${}", param_count));
}
// Filter by tags (array overlap)
// Filtrar por etiquetas (solapamiento de array)
if filters.tags.is_some() {
param_count += 1;
query.push_str(&format!(" AND tags && ${}", param_count));
}
// Search in name and description
// Buscar en nombre y descripción
if filters.search.is_some() {
param_count += 1;
query.push_str(&format!(
@@ -166,7 +166,7 @@ pub async fn list_test_templates(
query.push_str(" ORDER BY created_at DESC");
// Build query with dynamic binds
// Construir consulta con binds dinámicos
let mut sql_query = sqlx::query_as::<_, TestTemplate>(&query).bind(org_ctx.id);
if let Some(mysql_course_id) = &filters.mysql_course_id {
@@ -203,13 +203,13 @@ pub async fn list_test_templates(
Ok(Json(templates))
}
/// GET /api/test-templates/:id - Get a specific test template with questions
/// GET /api/test-templates/:id - Obtener una plantilla de test específica con preguntas
pub async fn get_test_template(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<TestTemplateWithQuestions>, (StatusCode, String)> {
// Get template
// Obtener plantilla
let template: TestTemplate = sqlx::query_as(
r#"
SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type,
@@ -224,11 +224,11 @@ pub async fn get_test_template(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
// Get sections
// Obtener secciones
let sections: Vec<TestTemplateSection> = sqlx::query_as(
r#"
SELECT id, template_id, title, description, section_order, points, instructions, section_data, created_at
@@ -242,7 +242,7 @@ pub async fn get_test_template(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Get questions
// Obtener preguntas
let questions: Vec<TestTemplateQuestion> = sqlx::query_as(
r#"
SELECT id, template_id, section_id, question_order, question_type, question_text,
@@ -266,7 +266,7 @@ pub async fn get_test_template(
// ==================== Update ====================
/// PUT /api/test-templates/:id - Update a test template
/// PUT /api/test-templates/:id - Actualizar una plantilla de test
pub async fn update_test_template(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
@@ -316,7 +316,7 @@ pub async fn update_test_template(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
@@ -325,7 +325,7 @@ pub async fn update_test_template(
// ==================== Delete ====================
/// DELETE /api/test-templates/:id - Delete a test template
/// DELETE /api/test-templates/:id - Eliminar una plantilla de test
pub async fn delete_test_template(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
@@ -344,22 +344,22 @@ pub async fn delete_test_template(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Template Questions Management ====================
// ==================== Gestión de Preguntas de la Plantilla ====================
/// POST /api/test-templates/:id/questions - Add a question to a template
/// POST /api/test-templates/:id/questions - Añadir una pregunta a una plantilla
pub async fn create_template_question(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<CreateQuestionPayload>,
) -> Result<Json<TestTemplateQuestion>, (StatusCode, String)> {
// Verify template exists and belongs to organization
// Verificar que la plantilla existe y pertenece a la organización
let exists: (bool,) = sqlx::query_as(
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
)
@@ -370,7 +370,7 @@ pub async fn create_template_question(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists.0 {
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
}
let question: TestTemplateQuestion = sqlx::query_as(
@@ -414,13 +414,13 @@ pub struct CreateQuestionPayload {
pub metadata: Option<serde_json::Value>,
}
/// DELETE /api/test-templates/:template_id/questions/:question_id - Delete a question
/// DELETE /api/test-templates/:template_id/questions/:question_id - Eliminar una pregunta
pub async fn delete_template_question(
Org(org_ctx): Org,
Path((template_id, question_id)): Path<(Uuid, Uuid)>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Verify template exists and belongs to organization
// Verificar que la plantilla existe y pertenece a la organización
let exists: (bool,) = sqlx::query_as(
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
)
@@ -431,7 +431,7 @@ pub async fn delete_template_question(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists.0 {
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
}
let result = sqlx::query(
@@ -447,22 +447,22 @@ pub async fn delete_template_question(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Question not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Pregunta no encontrada".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Template Sections Management ====================
// ==================== Gestión de Secciones de la Plantilla ====================
/// POST /api/test-templates/:id/sections - Add a section to a template
/// POST /api/test-templates/:id/sections - Añadir una sección a una plantilla
pub async fn create_template_section(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<CreateSectionPayload>,
) -> Result<Json<TestTemplateSection>, (StatusCode, String)> {
// Verify template exists
// Verificar que la plantilla existe
let exists: (bool,) = sqlx::query_as(
r#"SELECT EXISTS(SELECT 1 FROM test_templates WHERE id = $1 AND organization_id = $2)"#
)
@@ -473,7 +473,7 @@ pub async fn create_template_section(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists.0 {
return Err((StatusCode::NOT_FOUND, "Template not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()));
}
let section: TestTemplateSection = sqlx::query_as(
@@ -509,7 +509,7 @@ pub struct CreateSectionPayload {
pub section_data: Option<serde_json::Value>,
}
/// DELETE /api/test-templates/:template_id/sections/:section_id - Delete a section
/// DELETE /api/test-templates/:template_id/sections/:section_id - Eliminar una sección
pub async fn delete_template_section(
Org(org_ctx): Org,
Path((template_id, section_id)): Path<(Uuid, Uuid)>,
@@ -528,15 +528,15 @@ pub async fn delete_template_section(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Section not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Sección no encontrada".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
// ==================== Apply Template to Lesson ====================
// ==================== Aplicar Plantilla a la Lección ====================
/// POST /api/test-templates/:id/apply - Apply a template to a lesson
/// POST /api/test-templates/:id/apply - Aplicar una plantilla a una lección
pub async fn apply_template_to_lesson(
Org(org_ctx): Org,
Path(template_id): Path<Uuid>,
@@ -544,7 +544,7 @@ pub async fn apply_template_to_lesson(
State(pool): State<PgPool>,
Json(payload): Json<ApplyTemplatePayload>,
) -> Result<StatusCode, (StatusCode, String)> {
// Verify template exists and belongs to organization
// Verificar que la plantilla existe y pertenece a la organización
let template: TestTemplate = sqlx::query_as(
r#"
SELECT id, organization_id, mysql_course_id, created_by, name, description, level, course_type,
@@ -559,11 +559,11 @@ pub async fn apply_template_to_lesson(
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Template not found".to_string()),
sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Plantilla no encontrada".to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
})?;
// Verify lesson exists and belongs to organization
// Verificar que la lección existe y pertenece a la organización
let lesson_exists: (bool,) = sqlx::query_as(
r#"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)"#
)
@@ -574,10 +574,10 @@ pub async fn apply_template_to_lesson(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !lesson_exists.0 {
return Err((StatusCode::NOT_FOUND, "Lesson not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".to_string()));
}
// Get template questions with their sections
// Obtener las preguntas de la plantilla con sus secciones
let template_questions: Vec<TestTemplateQuestion> = sqlx::query_as(
r#"
SELECT id, template_id, section_id, question_order, question_type, question_text,
@@ -593,10 +593,10 @@ pub async fn apply_template_to_lesson(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if template_questions.is_empty() {
return Err((StatusCode::BAD_REQUEST, "Template has no questions".to_string()));
return Err((StatusCode::BAD_REQUEST, "La plantilla no tiene preguntas".to_string()));
}
// Build quiz_data JSON from template questions
// Construir el JSON quiz_data a partir de las preguntas de la plantilla
let questions_json: Vec<serde_json::Value> = template_questions
.iter()
.map(|q| {
@@ -621,12 +621,12 @@ pub async fn apply_template_to_lesson(
"passing_score": template.passing_score,
"total_points": template.total_points,
"instructions": template.instructions,
"max_attempts": 1, // Single attempt as requested
"max_attempts": 1, // Intento único según lo solicitado
"show_feedback": true, // Show explanations after answering
"permanent_history": true, // Student can always view their responses
"permanent_history": true, // El estudiante siempre puede ver sus respuestas
});
// Update lesson with quiz data and configuration
// Actualizar lección con datos del cuestionario y configuración
sqlx::query(
r#"
UPDATE lessons
@@ -649,7 +649,7 @@ pub async fn apply_template_to_lesson(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Increment template usage count
// Incrementar el contador de uso de la plantilla
sqlx::query("SELECT increment_template_usage($1)")
.bind(template_id)
.execute(&pool)
@@ -657,7 +657,7 @@ pub async fn apply_template_to_lesson(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!(
"Applied template '{}' to lesson '{}' with {} questions",
"Plantilla '{}' aplicada a la lección '{}' con {} preguntas",
template.name,
payload.lesson_id,
template_questions.len()
@@ -672,9 +672,9 @@ pub struct ApplyTemplatePayload {
pub grading_category_id: Option<Uuid>,
}
// ==================== RAG Question Generation ====================
// ==================== Generación de Preguntas RAG ====================
// Helper function to generate system prompt based on question type
// Función auxiliar para generar el system prompt basado en el tipo de pregunta
fn get_system_prompt_for_question_type(
question_type: &str,
num_questions: i32,
@@ -686,12 +686,12 @@ fn get_system_prompt_for_question_type(
format!(
r#"You are an English Teacher creating quiz questions.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL true-false questions about: {}
Crea {} preguntas ORIGINALES de verdadero-falso sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "The capital of France is Paris.",
@@ -702,10 +702,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure:
}}
]
Rules:
- Each question must be a clear statement
- correct_answer must be true or false
- Explanations must be concise"#,
Reglas:
- Cada pregunta debe ser una afirmación clara
- correct_answer debe ser true o false
- Las explicaciones deben ser concisas"#,
rag_context, num_questions, topic
)
}
@@ -713,12 +713,12 @@ Rules:
format!(
r#"You are an English Teacher creating quiz questions.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL short-answer questions about: {}
Crea {} preguntas ORIGINALES de respuesta corta sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "What is the past tense of 'go'?",
@@ -730,10 +730,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure:
}}
]
Rules:
- correct_answer should be the expected response
- keywords array contains acceptable variations or key concepts to check
- Questions should accept brief responses"#,
Reglas:
- correct_answer debe ser la respuesta esperada
- el array de palabras clave (keywords) contiene variaciones aceptables o conceptos clave para verificar
- Las preguntas deben aceptar respuestas breves"#,
rag_context, num_questions, topic
)
}
@@ -741,12 +741,12 @@ Rules:
format!(
r#"You are an English Teacher creating essay questions.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL essay questions about: {}
Crea {} preguntas ORIGINALES de ensayo sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "Explain how setting influences the mood of a short story.",
@@ -757,10 +757,10 @@ IMPORTANT - Return ONLY a JSON array with this EXACT structure:
}}
]
Rules:
- Questions must require extended written responses
- correct_answer should contain rubric guidance or expected criteria
- No options array is needed"#,
Reglas:
- Las preguntas deben requerir respuestas escritas extensas
- correct_answer debe contener una guía de rúbrica o criterios esperados
- No se necesita un array de opciones (options)"#,
rag_context, num_questions, topic
)
}
@@ -768,15 +768,15 @@ Rules:
format!(
r#"You are an English Teacher creating quiz questions.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL matching question sets about: {}
Crea {} conjuntos de preguntas ORIGINALES de emparejamiento sobre: {}
IMPORTANT - Return ONLY a JSON array with matching questions. Each matching question should have this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con preguntas de emparejamiento. Cada pregunta de emparejamiento debe tener esta estructura EXACTA:
[
{{
"question_text": "Match each vocabulary term with its definition:",
"question_text": "Empareje cada término de vocabulario con su definición:",
"question_type": "matching",
"pairs": [
{{"left": "Verb", "right": "A word that describes an action"}},
@@ -788,128 +788,128 @@ IMPORTANT - Return ONLY a JSON array with matching questions. Each matching ques
}}
]
Rules:
- Create 3-5 matching pairs per question
- left/right items must be clear and distinct
- All items in pairs array must follow the same structure
- One question per array element"#,
Reglas:
- Crear 3-5 pares de emparejamiento por pregunta
- los elementos izquierda/derecha (left/right) deben ser claros y distintos
- Todos los elementos del array de pares deben seguir la misma estructura
- Una pregunta por elemento del array"#,
rag_context, num_questions, topic
)
}
"ordering" => {
format!(
r#"You are an English Teacher creating quiz questions.
r#"Eres un profesor de inglés creando preguntas para un cuestionario.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL ordering questions about: {}
Crea {} preguntas ORIGINALES de ordenación sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "Arrange these steps of the writing process in correct order:",
"question_text": "Organice estos pasos del proceso de escritura en el orden correcto:",
"question_type": "ordering",
"items": ["Revise", "Draft", "Prewrite", "Publish", "Edit"],
"correct_order": [2, 1, 3, 4, 0],
"explanation": "The writing process starts with prewriting, then drafting, revising, editing, and finally publishing.",
"explanation": "El proceso de escritura comienza con la pre-escritura, luego el borrador, la revisión, la edición y finalmente la publicación.",
"points": 3
}}
]
Rules:
- items array contains the items to order
- correct_order is an array of indices showing the proper sequence (0-based)
- Must have at least 4 items to order
- Questions should have a clear logical sequence"#,
Reglas:
- el array de elementos (items) contiene los elementos a ordenar
- correct_order es un array de índices que muestran la secuencia correcta (basado en 0)
- Debe tener al menos 4 elementos para ordenar
- Las preguntas deben tener una secuencia lógica clara"#,
rag_context, num_questions, topic
)
}
"fill-in-the-blanks" => {
format!(
r#"You are an English Teacher creating quiz questions.
r#"Eres un profesor de inglés creando preguntas para un cuestionario.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL fill-in-the-blanks questions about: {}
Crea {} preguntas ORIGINALES de completar espacios en blanco sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "The ________ is the main character in a story, while the ________ opposes them.",
"question_text": "El ________ es el personaje principal de una historia, mientras que el ________ se le opone.",
"question_type": "fill-in-the-blanks",
"blanks": [
{{"answer": "protagonist", "keywords": ["protagonist", "hero", "main character"]}},
{{"answer": "antagonist", "keywords": ["antagonist", "villain", "opponent"]}}
],
"explanation": "These are key literary terms describing characters in stories.",
"explanation": "Estos son términos literarios clave que describen personajes en las historias.",
"points": 2
}}
]
Rules:
- question_text should have ________ for each blank
- blanks array has one object per blank
- Each blank object must have 'answer' and 'keywords' array
- keywords should include the main answer plus acceptable variations
- Questions can have 1-3 blanks"#,
Reglas:
- question_text debe tener ________ para cada espacio en blanco
- el array de espacios (blanks) tiene un objeto por espacio en blanco
- Cada objeto de espacio en blanco debe tener 'answer' y un array 'keywords'
- keywords debe incluir la respuesta principal más variaciones aceptables
- Las preguntas pueden tener de 1 a 3 espacios en blanco"#,
rag_context, num_questions, topic
)
}
"audio-response" => {
format!(
r#"You are an English Teacher creating speaking exercises.
r#"Eres un profesor de inglés creando ejercicios de expresión oral.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL audio-response speaking prompts about: {}
Crea {} consignas ORIGINALES de respuesta de audio sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "Describe a memorable trip using at least three past tense verbs.",
"question_text": "Describa un viaje memorable utilizando al menos tres verbos en pasado.",
"question_type": "audio-response",
"correct_answer": "Use clear past tense forms and relevant travel vocabulary in a coherent answer.",
"explanation": "This prompt checks fluency and grammatical control in spoken production.",
"correct_answer": "Utilice formas claras en tiempo pasado y vocabulario de viaje relevante en una respuesta coherente.",
"explanation": "Esta consigna comprueba la fluidez y el control gramatical en la producción oral.",
"points": 2
}}
]
Rules:
- Questions must require spoken production
- correct_answer should contain rubric guidance or expected response criteria
- No options array is needed"#,
Reglas:
- Las preguntas deben requerir producción oral
- correct_answer debe contener una guía de rúbrica o criterios de respuesta esperados
- No se necesita un array de opciones (options)"#,
rag_context, num_questions, topic
)
}
_ => {
// Default to multiple-choice
format!(
r#"You are an English Teacher creating quiz questions.
r#"Eres un profesor de inglés creando preguntas para un cuestionario.
Use these examples as inspiration (do NOT copy):
Usa estos ejemplos como inspiración (NO los copies):
{}
Create {} ORIGINAL multiple-choice questions about: {}
Crea {} preguntas ORIGINALES de opción múltiple sobre: {}
IMPORTANT - Return ONLY a JSON array with this EXACT structure:
IMPORTANTE - Devuelve SOLO un array JSON con esta estructura EXACTA:
[
{{
"question_text": "The tourist got lost in the ______ of the city.",
"question_text": "El turista se perdió en el ______ de la ciudad.",
"question_type": "multiple-choice",
"options": ["downtown", "countryside", "mountains", "desert"],
"correct_answer": 0,
"explanation": "Downtown is the main area of a city where tourists typically visit.",
"explanation": "El centro (downtown) es la zona principal de una ciudad que los turistas suelen visitar.",
"points": 1,
"skill_assessed": "reading"
}}
]
Rules:
- Option text only, no prefixes like A. or 1)
- Skills must be one of: reading, listening, speaking, writing"#,
Reglas:
- Solo texto de la opción, sin prefijos como A. o 1)
- Las habilidades (skills) deben ser una de: lectura, escucha, habla, escritura"#,
rag_context, num_questions, topic
)
}
@@ -997,8 +997,8 @@ fn parse_ai_response_for_question_type(
flatten_into_questions(questions_data)
}
/// POST /test-templates/generate-with-rag - Generate questions using RAG from imported MySQL question bank
/// Uses semantic search with pgvector embeddings when available, falls back to course_id filtering
/// POST /test-templates/generate-with-rag - Generar preguntas usando RAG del banco de preguntas MySQL importado
/// Usa búsqueda semántica con embeddings de pgvector cuando están disponibles, de lo contrario recurre al filtrado por course_id
pub async fn generate_questions_with_rag(
Org(org_ctx): Org,
claims: Claims,
@@ -1019,7 +1019,7 @@ pub async fn generate_questions_with_rag(
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("HTTP client error: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error del cliente HTTP: {}", e)))?;
let ollama_url = ai::get_ollama_url();
let model = ai::get_embedding_model();
@@ -1070,13 +1070,11 @@ pub async fn generate_questions_with_rag(
.bind(requested_num_questions * 3) // Get more for diversity
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Semantic search failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La búsqueda semántica falló: {}", e)))?;
tracing::info!("Semantic search found {} similar questions", mysql_questions.len());
tracing::info!("La búsqueda semántica encontró {} preguntas similares", mysql_questions.len());
if mysql_questions.is_empty() {
tracing::info!(
"Semantic search returned no rows; falling back to keyword search for topic {:?} and course {:?}",
topic,
payload.course_id
);
@@ -1123,11 +1121,9 @@ pub async fn generate_questions_with_rag(
.bind(requested_num_questions * 3)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword fallback failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("El recurso a palabras clave falló: {}", e)))?;
if mysql_questions.is_empty() {
tracing::info!(
"Keyword fallback returned no rows; falling back to imported MySQL questions for course {:?}",
payload.course_id
);
@@ -1168,7 +1164,7 @@ pub async fn generate_questions_with_rag(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Course fallback failed: {}", e),
format!("El recurso por curso falló: {}", e),
)
})?;
}
@@ -1176,7 +1172,7 @@ pub async fn generate_questions_with_rag(
}
}
Err(e) => {
tracing::warn!("Semantic search failed, falling back to keyword search: {}", e);
tracing::warn!("La búsqueda semántica falló, recurriendo a búsqueda por palabras clave: {}", e);
// Fall back to text search
mysql_questions = sqlx::query_as(
r#"
@@ -1220,11 +1216,9 @@ pub async fn generate_questions_with_rag(
.bind(requested_num_questions * 3)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Keyword search failed: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("La búsqueda por palabras clave falló: {}", e)))?;
if mysql_questions.is_empty() {
tracing::info!(
"No semantic or keyword matches for topic; falling back to imported MySQL questions for course {:?}",
payload.course_id
);
@@ -1265,7 +1259,7 @@ pub async fn generate_questions_with_rag(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Course fallback failed: {}", e),
format!("El recurso por curso falló: {}", e),
)
})?;
}
@@ -1309,7 +1303,7 @@ pub async fn generate_questions_with_rag(
.bind(course_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?;
} else {
// Fetch all imported MySQL questions for this organization
// NO LIMIT - fetch all questions for better RAG context
@@ -1339,12 +1333,10 @@ pub async fn generate_questions_with_rag(
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch questions: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener las preguntas: {}", e)))?;
}
if mysql_questions.is_empty() {
tracing::warn!(
"No imported MySQL questions found for org={} course={:?} topic={:?}; falling back to organization-wide imported questions",
org_ctx.id,
payload.course_id,
payload.topic
@@ -1379,7 +1371,7 @@ pub async fn generate_questions_with_rag(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to fetch organization-wide fallback questions: {}", e),
format!("Error al obtener preguntas de recurso de toda la organización: {}", e),
)
})?;
@@ -1405,7 +1397,7 @@ pub async fn generate_questions_with_rag(
.map(|q| get_course_level_from_mysql(q.nivel_curso, &q.plan_nombre, ""))
.unwrap_or(CourseLevel::Intermediate);
tracing::info!("Determined course_type: {:?}, level: {:?} from imported data", course_type, level);
tracing::info!("Tipo de curso determinado: {:?}, nivel: {:?} a partir de los datos importados", course_type, level);
// 2. Build RAG context from MySQL questions (lightweight format)
let rag_context: String = mysql_questions
@@ -1415,7 +1407,7 @@ pub async fn generate_questions_with_rag(
.collect::<Vec<_>>()
.join("\n");
tracing::info!("RAG context built with {} questions", mysql_questions.len().min(8));
tracing::info!("Contexto RAG construido con {} preguntas", mysql_questions.len().min(8));
// 3. Call AI to generate new questions based on RAG context (Ollama only)
let base_url = std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
@@ -1428,7 +1420,7 @@ pub async fn generate_questions_with_rag(
.build()
.unwrap_or_else(|_| reqwest::Client::new());
tracing::info!("Calling Ollama at {} with model {}", url, model);
tracing::info!("Llamando a Ollama en {} con el modelo {}", url, model);
// Save topic for later use
let topic = payload.topic.clone().unwrap_or_else(|| "English grammar".to_string());
@@ -1459,7 +1451,7 @@ pub async fn generate_questions_with_rag(
&rag_context,
);
tracing::debug!("System prompt length: {} chars", system_prompt.len());
tracing::debug!("Longitud del prompt del sistema: {} caracteres", system_prompt.len());
let request = client
.post(&url)
@@ -1483,19 +1475,19 @@ pub async fn generate_questions_with_rag(
}
}));
tracing::info!("Sending request to Ollama (model: {}, prompt length: {} chars)", model, system_prompt.len());
tracing::info!("Enviando solicitud a Ollama (modelo: {}, longitud del prompt: {} caracteres)", model, system_prompt.len());
let response = request.send().await.map_err(|e| {
tracing::error!("AI request failed after timeout: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, format!("Ollama timeout - el equipo t-800 está tardando en responder. Intenta nuevamente: {}", e))
})?;
tracing::info!("Ollama response status: {}", response.status());
tracing::info!("Estado de la respuesta de Ollama: {}", response.status());
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::error!("Ollama returned non-success status {}: {}", status, body);
tracing::error!("Ollama devolvió un estado de error {}: {}", status, body);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Ollama returned {}. El proxy de IA agotó el tiempo de espera.", status),
@@ -1503,8 +1495,8 @@ pub async fn generate_questions_with_rag(
}
let response_text = response.text().await.map_err(|e| {
tracing::error!("Failed to read AI stream response: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Invalid AI response".to_string())
tracing::error!("Error al leer la respuesta del flujo de la IA: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Respuesta de IA inválida".to_string())
})?;
let aggregated_content = response_text
@@ -1532,7 +1524,7 @@ pub async fn generate_questions_with_rag(
}
});
tracing::debug!("Ollama response: {:?}", response_json);
tracing::debug!("Respuesta de Ollama: {:?}", response_json);
// Parse questions from Ollama response
let ai_payload = response_json
@@ -1619,7 +1611,7 @@ pub async fn generate_questions_with_rag(
.to_string();
if question_text.is_empty() || question_text.eq_ignore_ascii_case("question") {
tracing::warn!("Skipping invalid generated question with empty placeholder text: {:?}", q);
tracing::warn!("Omitiendo pregunta generada inválida con texto de marcador vacío: {:?}", q);
return None;
}
@@ -1644,7 +1636,7 @@ pub async fn generate_questions_with_rag(
let (options, correct_answer, options_shuffled) = match question_type_value.as_str() {
"multiple-choice" => {
if original_options.len() < 2 {
tracing::warn!("Skipping invalid multiple-choice question without enough options: {:?}", q);
tracing::warn!("Omitiendo pregunta de opción múltiple inválida sin suficientes opciones: {:?}", q);
return None;
}
if !original_options.is_empty() && original_correct_idx.is_some() {
@@ -1675,7 +1667,7 @@ pub async fn generate_questions_with_rag(
.and_then(|v| v.as_bool())
.map(|v| if v { json!(0) } else { json!(1) });
if bool_answer.is_none() {
tracing::warn!("Skipping invalid true-false question without boolean correct answer: {:?}", q);
tracing::warn!("Omitiendo pregunta de verdadero-falso inválida sin respuesta correcta booleana: {:?}", q);
return None;
}
(Some(json!(["True", "False"])), bool_answer, false)
@@ -1688,7 +1680,7 @@ pub async fn generate_questions_with_rag(
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if !is_valid {
tracing::warn!("Skipping invalid matching question without pairs: {:?}", q);
tracing::warn!("Omitiendo pregunta de emparejamiento inválida sin pares: {:?}", q);
return None;
}
(pairs.clone(), pairs, false)
@@ -1711,7 +1703,7 @@ pub async fn generate_questions_with_rag(
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if !has_items || !has_order {
tracing::warn!("Skipping invalid ordering question without items/order: {:?}", q);
tracing::warn!("Omitiendo pregunta de ordenación inválida sin elementos/orden: {:?}", q);
return None;
}
(items, order, false)
@@ -1724,7 +1716,7 @@ pub async fn generate_questions_with_rag(
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if !has_blanks {
tracing::warn!("Skipping invalid fill-in-the-blanks question without blanks array: {:?}", q);
tracing::warn!("Omitiendo pregunta de completar espacios en blanco inválida sin array de espacios: {:?}", q);
return None;
}
(blanks.clone(), blanks, false)
@@ -1760,7 +1752,7 @@ pub async fn generate_questions_with_rag(
.collect();
if generated_questions.is_empty() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI failed to generate questions".to_string()));
return Err((StatusCode::INTERNAL_SERVER_ERROR, "La IA no pudo generar las preguntas".to_string()));
}
// Save generated questions to question bank
@@ -1819,7 +1811,7 @@ pub async fn generate_questions_with_rag(
}
tracing::info!(
"Generated {} questions using RAG from MySQL bank, saved {} to question bank",
"Generadas {} preguntas usando RAG del banco MySQL, guardadas {} al banco de preguntas",
generated_questions.len(),
saved_count
);
@@ -1892,7 +1884,7 @@ async fn ensure_mysql_course_metadata(
let mysql_url = std::env::var("MYSQL_DATABASE_URL").map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"MYSQL_DATABASE_URL not configured".to_string(),
"MYSQL_DATABASE_URL no configurada".to_string(),
)
})?;
@@ -1907,7 +1899,7 @@ async fn ensure_mysql_course_metadata(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to external MySQL: {}", e),
format!("Error al conectar con MySQL externo: {}", e),
)
})?;
@@ -1962,7 +1954,7 @@ async fn ensure_mysql_course_metadata(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to upsert MySQL study plan metadata: {}", e),
format!("Error al actualizar/insertar los metadatos del plan de estudios MySQL: {}", e),
)
})?;
@@ -1976,7 +1968,7 @@ async fn ensure_mysql_course_metadata(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to resolve MySQL study plan metadata: {}", e),
format!("Error al resolver los metadatos del plan de estudios MySQL: {}", e),
)
})?;
@@ -2013,7 +2005,7 @@ async fn ensure_mysql_course_metadata(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to upsert MySQL course metadata: {}", e),
format!("Error al actualizar/insertar los metadatos del curso MySQL: {}", e),
)
})?;
+33 -33
View File
@@ -36,32 +36,32 @@ async fn main() {
dotenv().ok();
tracing_subscriber::fmt::init();
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL debe estar configurada");
let pool = PgPoolOptions::new()
.max_connections(10)
.min_connections(2)
.acquire_timeout(Duration::from_secs(30))
.connect(&db_url)
.await
.expect("Failed to connect to database");
.expect("Error al conectar con la base de datos");
// Initialize health state
// Inicializar el estado de salud
let health_state = HealthState::default();
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
.expect("Error al ejecutar las migraciones");
// Sync default organization branding from environment
// Sincronizar la marca de la organización por defecto desde el entorno
sync_default_organization(&pool).await;
// Start AI Background Worker
// Iniciar el trabajador de IA en segundo plano
let worker_pool = pool.clone();
tokio::spawn(async move {
tracing::info!("AI Background Worker started");
tracing::info!("Trabajador de IA en segundo plano iniciado");
loop {
// Check for queued transcriptions
// Buscar transcripciones en cola
let queued_lessons: Vec<sqlx::types::Uuid> = match sqlx::query_scalar(
"SELECT id FROM lessons WHERE transcription_status = 'queued' LIMIT 5",
)
@@ -70,18 +70,18 @@ async fn main() {
{
Ok(ids) => ids,
Err(e) => {
tracing::error!("Failed to fetch queued lessons: {}", e);
tracing::error!("Error al obtener lecciones en cola: {}", e);
tokio::time::sleep(Duration::from_secs(10)).await;
continue;
}
};
for lesson_id in queued_lessons {
tracing::info!("Processing transcription for lesson: {}", lesson_id);
tracing::info!("Procesando transcripción para la lección: {}", lesson_id);
if let Err(e) =
handlers::run_transcription_task(worker_pool.clone(), lesson_id).await
{
tracing::error!("Transcription task failed for lesson {}: {}", lesson_id, e);
tracing::error!("La tarea de transcripción falló para la lección {}: {}", lesson_id, e);
let _ = sqlx::query(
"UPDATE lessons SET transcription_status = 'failed' WHERE id = $1",
)
@@ -97,15 +97,15 @@ async fn main() {
}
});
// CORS configuration - Allow multiple origins for development and production
// Using a predicate closure to support wildcard subdomains for norteamericano.cl
// Configuración de CORS - Permitir múltiples orígenes para desarrollo y producción
// Uso de un cierre de predicado para soportar subdominios comodín para norteamericano.cl
use tower_http::cors::AllowOrigin;
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
let origin_str = origin.to_str().unwrap_or("");
// Development origins
// Orígenes de desarrollo
let allowed_origins = [
"http://localhost:3000",
"http://localhost:3003",
@@ -114,7 +114,7 @@ async fn main() {
"http://192.168.0.254:3000",
"http://192.168.0.254:3003",
"http://192.168.0.254",
// Production - Norteamericano domains (.cl and .com)
// Producción - Dominios de Norteamericano (.cl y .com)
"http://studio.norteamericano.com",
"https://studio.norteamericano.com",
"http://learning.norteamericano.com",
@@ -125,12 +125,12 @@ async fn main() {
"https://learning.norteamericano.cl",
];
// Check exact matches
// Comprobar coincidencias exactas
if allowed_origins.contains(&origin_str) {
return true;
}
// Check wildcard for subdomains in norteamericano.cl/.com over HTTP(S)
// Comprobar comodín para subdominios en norteamericano.cl/.com sobre HTTP(S)
for scheme in ["http://", "https://"] {
for domain in [".norteamericano.cl", ".norteamericano.com"] {
if origin_str.starts_with(scheme) && origin_str.ends_with(domain) {
@@ -140,7 +140,7 @@ async fn main() {
.strip_suffix(domain)
.unwrap_or("");
// Allow any subdomain (e.g., api., cdn., admin., etc.)
// Permitir cualquier subdominio (ej., api., cdn., admin., etc.)
if !subdomain.is_empty() && !subdomain.contains('/') {
return true;
}
@@ -296,7 +296,7 @@ async fn main() {
"/organization/branding",
axum::routing::put(handlers_branding::update_organization_branding),
)
// Content Libraries routes
// Rutas de librerías de contenido
.route(
"/library/blocks",
get(handlers_library::list_library_blocks).post(handlers_library::create_library_block),
@@ -311,7 +311,7 @@ async fn main() {
"/library/blocks/{id}/increment-usage",
post(handlers_library::increment_block_usage),
)
// Advanced Grading (Rubrics) routes
// Rutas de calificación avanzada (rúbricas)
.route(
"/courses/{id}/rubrics",
get(handlers_rubrics::list_course_rubrics).post(handlers_rubrics::create_rubric),
@@ -347,7 +347,7 @@ async fn main() {
"/lessons/{id}/rubrics",
get(handlers_rubrics::get_lesson_rubrics),
)
// Learning Sequences (Dependencies) routes
// Rutas de secuencias de aprendizaje (dependencias)
.route(
"/lessons/{id}/dependencies",
get(handlers_dependencies::list_lesson_dependencies)
@@ -357,7 +357,7 @@ async fn main() {
"/lessons/{id}/dependencies/{prerequisite_id}",
delete(handlers_dependencies::remove_dependency),
)
// Test Templates routes
// Rutas de plantillas de pruebas
.route(
"/test-templates",
get(handlers_test_templates::list_test_templates)
@@ -393,7 +393,7 @@ async fn main() {
"/test-templates/generate-with-rag",
post(handlers_test_templates::generate_questions_with_rag),
)
// Question Bank routes
// Rutas del banco de preguntas
.route(
"/question-bank",
get(handlers_question_bank::list_questions)
@@ -433,7 +433,7 @@ async fn main() {
"/question-bank/ai-generate",
post(handlers_question_bank::ai_generate_question),
)
// Embedding routes for semantic search
// Rutas de embeddings para búsqueda semántica
.route(
"/question-bank/embeddings/generate",
post(handlers_embeddings::generate_question_embeddings),
@@ -450,7 +450,7 @@ async fn main() {
"/question-bank/{id}/embedding/regenerate",
post(handlers_embeddings::regenerate_question_embedding),
)
// SAM Integration routes
// Rutas de integración con SAM
.route(
"/sam/sync-all",
post(handlers_sam::sync_all_sam),
@@ -471,7 +471,7 @@ async fn main() {
"/sam/students/{student_id}/courses",
get(handlers_sam::get_sam_student_courses),
)
// Admin routes
// Rutas de administración
.route(
"/admin/token-usage",
get(handlers_admin::get_token_usage),
@@ -527,12 +527,12 @@ async fn main() {
"/api/assets/s3-proxy/{bucket}/{*key}",
get(handlers_assets::public_s3_proxy),
)
// Health check routes
// Rutas de verificación de salud
.merge(health::health_routes(pool.clone()).with_state(health_state))
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
.merge(auth_routes)
.merge(protected_routes)
// Security headers
// Cabeceras de seguridad
.layer(SetResponseHeaderLayer::overriding(
http::header::STRICT_TRANSPORT_SECURITY,
http::HeaderValue::from_static("max-age=31536000; includeSubDomains"),
@@ -553,14 +553,14 @@ async fn main() {
http::header::REFERRER_POLICY,
http::HeaderValue::from_static("strict-origin-when-cross-origin"),
))
// CORS layer - MUST be last to execute first on response
// Capa CORS - DEBE ser la última para ejecutarse primero en la respuesta
.layer(cors)
// Trace layer for logging requests/responses
// Capa de trazado para registrar solicitudes/respuestas
.layer(TraceLayer::new_for_http())
.with_state(pool);
let addr = SocketAddr::from(([0, 0, 0, 0], 3001));
tracing::info!("CMS Service listening on {} with rate limiting and security headers", addr);
tracing::info!("Servicio CMS escuchando en {} con limitación de tasa y cabeceras de seguridad", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, public_routes).await.unwrap();
}
@@ -597,8 +597,8 @@ async fn sync_default_organization(pool: &sqlx::PgPool) {
.await;
match result {
Ok(_) => tracing::info!("Default organization branding synced from .env"),
Err(e) => tracing::error!("Failed to sync default organization branding: {}", e),
Ok(_) => tracing::info!("Marca de la organización por defecto sincronizada desde .env"),
Err(e) => tracing::error!("Error al sincronizar la marca de la organización por defecto: {}", e),
}
}
+6 -7
View File
@@ -15,7 +15,7 @@ impl WebhookService {
}
pub async fn dispatch(&self, org_id: Uuid, event_type: &str, payload: &serde_json::Value) {
// 1. Fetch active webhooks for this org that are interested in this event
// 1. Obtener webhooks activos para esta org que estén interesados en este evento
let webhooks = match sqlx::query_as::<_, Webhook>(
"SELECT * FROM webhooks WHERE organization_id = $1 AND is_active = TRUE AND $2 = ANY(events)"
)
@@ -25,7 +25,7 @@ impl WebhookService {
.await {
Ok(w) => w,
Err(e) => {
tracing::error!("Failed to fetch webhooks for org {}: {}", org_id, e);
tracing::error!("Error al obtener los webhooks para la org {}: {}", org_id, e);
return;
}
};
@@ -44,7 +44,7 @@ impl WebhookService {
.header("X-OpenCCB-Event", event_type)
.header("X-OpenCCB-Delivery", Uuid::new_v4().to_string());
// Add signature if secret exists
// Añadir firma si existe el secreto
if let Some(secret) = &webhook.secret {
let signature = generate_signature(secret, &payload_str);
request = request.header("X-OpenCCB-Signature", signature);
@@ -56,15 +56,14 @@ impl WebhookService {
match res {
Ok(response) => {
if !response.status().is_success() {
tracing::warn!(
"Webhook delivery to {} (event: {}) failed with status {}",
"El envío del webhook a {} (evento: {}) falló con estado {}",
url,
event_type,
response.status()
);
} else {
tracing::info!(
"Successfully delivered webhook to {} (event: {})",
"Webhook enviado con éxito a {} (evento: {})",
url,
event_type
);
@@ -72,7 +71,7 @@ impl WebhookService {
}
Err(e) => {
tracing::error!(
"Failed to deliver webhook to {} (event: {}): {}",
"Error al enviar el webhook a {} (evento: {}): {}",
url,
event_type,
e
+1 -1
View File
@@ -24,7 +24,7 @@ pub async fn set_session_context(
.await?;
}
if let Some(user_agent) = ua {
// Use set_config for potentially long strings to avoid SQL injection/formatting issues
// Usar set_config para cadenas potencialmente largas para evitar inyección SQL o problemas de formato
sqlx::query("SELECT set_config('app.user_agent', $1, true)")
.bind(user_agent)
.execute(&mut **tx)
+3 -3
View File
@@ -11,16 +11,16 @@ pub async fn init_mysql_pool() -> Option<MySqlPool> {
.await
{
Ok(pool) => {
tracing::info!("Connected to external MySQL database");
tracing::info!("Conectado a la base de datos MySQL externa");
Some(pool)
}
Err(e) => {
tracing::error!("Failed to connect to external MySQL database: {}", e);
tracing::error!("Error al conectar a la base de datos MySQL externa: {}", e);
None
}
}
} else {
tracing::info!("MYSQL_DATABASE_URL not set, skipping external database integration");
tracing::info!("MYSQL_DATABASE_URL no establecida, omitiendo la integración con la base de datos externa");
None
}
}
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,7 @@ use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
// ========== Request/Response DTOs ==========
// ========== DTOs de Solicitud/Respuesta ==========
#[derive(Deserialize)]
pub struct CreateAnnouncementPayload {
@@ -27,7 +27,7 @@ pub struct UpdateAnnouncementPayload {
pub is_pinned: Option<bool>,
}
// ========== HANDLERS ==========
// ========== MANEJADORES ==========
pub async fn list_announcements(
Org(org_ctx): Org,
@@ -50,7 +50,7 @@ pub async fn list_announcements(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Attach cohort_ids to each announcement
// Adjuntar cohort_ids a cada anuncio
for a in &mut announcements {
let cohorts: Vec<(Uuid,)> = sqlx::query_as(
"SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1"
@@ -75,17 +75,17 @@ pub async fn create_announcement(
State(pool): State<PgPool>,
Json(payload): Json<CreateAnnouncementPayload>,
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
// Check if user is instructor or admin
// Verificar si el usuario es instructor o administrador
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
if user.0 != "instructor" && user.0 != "admin" {
return Err((
StatusCode::FORBIDDEN,
"Only instructors can create announcements".to_string(),
"Solo los instructores pueden crear anuncios".to_string(),
));
}
@@ -94,7 +94,7 @@ pub async fn create_announcement(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 1. Create announcement
// 1. Crear anuncio
let mut announcement = sqlx::query_as::<_, CourseAnnouncement>(
"INSERT INTO course_announcements (organization_id, course_id, author_id, title, content, is_pinned)
VALUES ($1, $2, $3, $4, $5, $6)
@@ -110,7 +110,7 @@ pub async fn create_announcement(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 2. Link cohorts if provided
// 2. Vincular cohortes si se proporcionan
if let Some(ref cohort_ids) = payload.cohort_ids {
for cohort_id in cohort_ids {
sqlx::query(
@@ -129,7 +129,7 @@ pub async fn create_announcement(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Get target students for notifications
// 3. Obtener estudiantes objetivo para notificaciones
let enrolled_students = if let Some(ref cohort_ids) = payload.cohort_ids {
if !cohort_ids.is_empty() {
sqlx::query_as::<_, (Uuid,)>(
@@ -144,7 +144,7 @@ pub async fn create_announcement(
.fetch_all(&pool)
.await
} else {
// Fallback to everyone if empty list provided (though UI should prevent)
// Recurrir a todos si se proporciona una lista vacía (aunque la interfaz debería evitarlo)
sqlx::query_as::<_, (Uuid,)>(
"SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2",
)
@@ -154,7 +154,7 @@ pub async fn create_announcement(
.await
}
} else {
// No segment provided -> everyone in the course
// No se proporcionó segmento -> todos en el curso
sqlx::query_as::<_, (Uuid,)>(
"SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2",
)
@@ -165,7 +165,7 @@ pub async fn create_announcement(
}
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Create notification for each enrolled student
// Crear notificación para cada estudiante inscrito
for (student_id,) in enrolled_students {
let notification_title = format!("Nuevo Anuncio: {}", payload.title);
let notification_message = if payload.content.len() > 100 {
@@ -198,21 +198,21 @@ pub async fn update_announcement(
State(pool): State<PgPool>,
Json(payload): Json<UpdateAnnouncementPayload>,
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
// Check if user is instructor or admin
// Verificar si el usuario es instructor o administrador
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
if user.0 != "instructor" && user.0 != "admin" {
return Err((
StatusCode::FORBIDDEN,
"Only instructors can update announcements".to_string(),
"Solo los instructores pueden actualizar anuncios".to_string(),
));
}
// Get current announcement to verify ownership
// Obtener el anuncio actual para verificar la propiedad
let current = sqlx::query_as::<_, CourseAnnouncement>(
"SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2",
)
@@ -220,7 +220,7 @@ pub async fn update_announcement(
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Announcement not found".to_string()))?;
.map_err(|_| (StatusCode::NOT_FOUND, "Anuncio no encontrado".to_string()))?;
let title = payload.title.unwrap_or(current.title);
let content = payload.content.unwrap_or(current.content);
@@ -250,17 +250,17 @@ pub async fn delete_announcement(
Path(announcement_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Check if user is instructor or admin
// Verificar si el usuario es instructor o administrador
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
if user.0 != "instructor" && user.0 != "admin" {
return Err((
StatusCode::FORBIDDEN,
"Only instructors can delete announcements".to_string(),
"Solo los instructores pueden eliminar anuncios".to_string(),
));
}
+6 -6
View File
@@ -55,7 +55,7 @@ pub async fn add_cohort_member(
State(pool): State<PgPool>,
Json(payload): Json<AddMemberPayload>,
) -> Result<Json<UserCohort>, (StatusCode, String)> {
// Verify cohort belongs to org
// Verificar que la cohorte pertenece a la organización
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
)
@@ -66,7 +66,7 @@ pub async fn add_cohort_member(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists {
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()));
}
let member = sqlx::query_as::<_, UserCohort>(
@@ -92,7 +92,7 @@ pub async fn remove_cohort_member(
Path((cohort_id, user_id)): Path<(Uuid, Uuid)>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Verify cohort belongs to org
// Verificar que la cohorte pertenece a la organización
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
)
@@ -103,7 +103,7 @@ pub async fn remove_cohort_member(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists {
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()));
}
sqlx::query("DELETE FROM user_cohorts WHERE cohort_id = $1 AND user_id = $2")
@@ -122,7 +122,7 @@ pub async fn get_cohort_members(
Path(cohort_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Vec<Uuid>>, (StatusCode, String)> {
// Verify cohort belongs to org
// Verificar que la cohorte pertenece a la organización
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
)
@@ -133,7 +133,7 @@ pub async fn get_cohort_members(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists {
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
return Err((StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()));
}
let members = sqlx::query_scalar("SELECT user_id FROM user_cohorts WHERE cohort_id = $1")
@@ -10,7 +10,7 @@ use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
// ========== Request/Response DTOs ==========
// ========== DTOs de Solicitud/Respuesta ==========
#[derive(Deserialize)]
pub struct CreateThreadPayload {
@@ -37,7 +37,7 @@ pub struct ThreadListQuery {
pub page: Option<i64>,
}
// ========== THREAD HANDLERS ==========
// ========== MANEJADORES DE HILOS ==========
pub async fn list_threads(
Org(org_ctx): Org,
@@ -141,7 +141,7 @@ pub async fn create_thread(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Auto-subscribe author to thread
// Suscribir automáticamente al autor al hilo
let _ = sqlx::query(
"INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id)
VALUES ($1, $2, $3)
@@ -162,13 +162,13 @@ pub async fn get_thread_detail(
Path(thread_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Increment view count
// Incrementar el conteo de visualizaciones
let _ = sqlx::query("UPDATE discussion_threads SET view_count = view_count + 1 WHERE id = $1")
.bind(thread_id)
.execute(&pool)
.await;
// Get thread with author info
// Obtener el hilo con información del autor
let thread = sqlx::query_as::<_, ThreadWithAuthor>(
"SELECT
t.*,
@@ -186,9 +186,9 @@ pub async fn get_thread_detail(
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?;
.map_err(|_| (StatusCode::NOT_FOUND, "Hilo no encontrado".to_string()))?;
// Get all posts with author info and user votes
// Obtener todos los mensajes con información del autor y votos del usuario
let posts = get_thread_posts_recursive(&pool, thread_id, None, claims.sub, org_ctx.id).await?;
Ok(Json(serde_json::json!({
@@ -197,7 +197,7 @@ pub async fn get_thread_detail(
})))
}
// Recursive function to build nested post tree
// Función recursiva para construir el árbol de mensajes anidados
fn get_thread_posts_recursive<'a>(
pool: &'a PgPool,
thread_id: Uuid,
@@ -244,7 +244,7 @@ fn get_thread_posts_recursive<'a>(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Recursively fetch replies for each post
// Obtener respuestas recursivamente para cada mensaje
for post in &mut posts {
post.replies =
get_thread_posts_recursive(pool, thread_id, Some(post.id), user_id, org_id).await?;
@@ -260,17 +260,17 @@ pub async fn pin_thread(
Path(thread_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Check if user is instructor
// Verificar si el usuario es instructor o administrador
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
if user.0 != "instructor" && user.0 != "admin" {
return Err((
StatusCode::FORBIDDEN,
"Only instructors can pin threads".to_string(),
"Solo los instructores pueden fijar hilos".to_string(),
));
}
@@ -290,17 +290,17 @@ pub async fn lock_thread(
Path(thread_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Check if user is instructor
// Verificar si el usuario es instructor o administrador
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
if user.0 != "instructor" && user.0 != "admin" {
return Err((
StatusCode::FORBIDDEN,
"Only instructors can lock threads".to_string(),
"Solo los instructores pueden bloquear hilos".to_string(),
));
}
@@ -314,7 +314,7 @@ pub async fn lock_thread(
Ok(StatusCode::OK)
}
// ========== POST HANDLERS ==========
// ========== MANEJADORES DE MENSAJES ==========
pub async fn create_post(
Org(org_ctx): Org,
@@ -323,13 +323,13 @@ pub async fn create_post(
State(pool): State<PgPool>,
Json(payload): Json<CreatePostPayload>,
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
// Check if thread is locked
// Verificar si el hilo está bloqueado
let thread =
sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1")
.bind(thread_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()))?;
.map_err(|_| (StatusCode::NOT_FOUND, "Hilo no encontrado".to_string()))?;
if thread.0 {
return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string()));
@@ -349,7 +349,7 @@ pub async fn create_post(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// TODO: Send notifications to subscribed users
// TODO: Enviar notificaciones a los usuarios suscritos
Ok(Json(post))
}
@@ -360,17 +360,17 @@ pub async fn endorse_post(
Path(post_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Check if user is instructor
// Verificar si el usuario es instructor o administrador
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
.bind(claims.sub)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
if user.0 != "instructor" && user.0 != "admin" {
return Err((
StatusCode::FORBIDDEN,
"Only instructors can endorse posts".to_string(),
"Solo los instructores pueden recomendar mensajes".to_string(),
));
}
@@ -395,7 +395,7 @@ pub async fn vote_post(
return Err((StatusCode::BAD_REQUEST, "Tipo de voto inválido".to_string()));
}
// Upsert vote
// Upsert de voto
sqlx::query(
"INSERT INTO discussion_votes (organization_id, post_id, user_id, vote_type)
VALUES ($1, $2, $3, $4)
@@ -410,7 +410,7 @@ pub async fn vote_post(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Recalculate upvotes
// Recalcular votos positivos
let upvote_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM discussion_votes WHERE post_id = $1 AND vote_type = 'upvote'",
)
@@ -429,7 +429,7 @@ pub async fn vote_post(
Ok(StatusCode::OK)
}
// ========== SUBSCRIPTION HANDLERS ==========
// ========== MANEJADORES DE SUSCRIPCIONES ==========
pub async fn subscribe_thread(
Org(org_ctx): Org,
+20 -20
View File
@@ -1,5 +1,5 @@
//! Handlers for PGVector embeddings in Knowledge Base (LMS)
//! Enables semantic search for AI tutor chat with RAG
//! Manejadores para embeddings de PGVector en la Base de Conocimientos (LMS)
//! Habilita la búsqueda semántica para el chat del tutor de IA con RAG
use axum::{
Json,
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
// ==================== Query Parameters ====================
// ==================== Parámetros de Consulta ====================
#[derive(Debug, Deserialize)]
pub struct KnowledgeSearchFilters {
@@ -41,7 +41,7 @@ pub struct GenerateKnowledgeEmbeddingsResult {
pub duration_ms: u64,
}
// ==================== Generate Embeddings ====================
// ==================== Generar Embeddings ====================
/// POST /api/knowledge-base/embeddings/generate - Generate embeddings for all knowledge base entries
pub async fn generate_knowledge_embeddings(
@@ -50,7 +50,7 @@ pub async fn generate_knowledge_embeddings(
) -> Result<Json<GenerateKnowledgeEmbeddingsResult>, (StatusCode, String)> {
let start = std::time::Instant::now();
// Create client that accepts invalid certificates (for dev with self-signed certs)
// Crear cliente que acepte certificados inválidos (para desarrollo con certificados autofirmados)
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
@@ -60,7 +60,7 @@ pub async fn generate_knowledge_embeddings(
let ollama_url = ai::get_ollama_url();
let model = ai::get_embedding_model();
// Get knowledge base entries without embeddings
// Obtener entradas de la base de conocimientos sin embeddings
let entries: Vec<KnowledgeBaseEntry> = sqlx::query_as(
r#"
SELECT * FROM knowledge_base
@@ -80,12 +80,12 @@ pub async fn generate_knowledge_embeddings(
let mut failed = 0;
for entry in entries {
// Generate embedding from content chunk
// Generar embedding desde el fragmento de contenido
match generate_embedding(&client, &ollama_url, &model, &entry.content_chunk).await {
Ok(response) => {
let pgvector = ai::embedding_to_pgvector(&response.embedding);
// Update entry with embedding
// Actualizar entrada con embedding
let result: Result<(i64,), sqlx::Error> = sqlx::query_as(
r#"
UPDATE knowledge_base
@@ -108,7 +108,7 @@ pub async fn generate_knowledge_embeddings(
}
Err(e) => {
tracing::error!(
"Failed to generate embedding for knowledge entry {}: {}",
"Error al generar el embedding para la entrada de conocimiento {}: {}",
entry.id,
e
);
@@ -133,13 +133,13 @@ pub async fn generate_knowledge_embeddings(
}))
}
/// POST /api/knowledge-base/{id}/embedding/regenerate - Regenerate embedding for a specific entry
/// POST /api/knowledge-base/{id}/embedding/regenerate - Regenerar embedding para una entrada específica
pub async fn regenerate_knowledge_embedding(
Org(org_ctx): Org,
Path(entry_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Create client that accepts invalid certificates
// Crear cliente que acepte certificados inválidos
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
@@ -149,7 +149,7 @@ pub async fn regenerate_knowledge_embedding(
let ollama_url = ai::get_ollama_url();
let model = ai::get_embedding_model();
// Get entry
// Obtener entrada
let entry: KnowledgeBaseEntry = sqlx::query_as(
"SELECT * FROM knowledge_base WHERE id = $1 AND organization_id = $2"
)
@@ -158,16 +158,16 @@ pub async fn regenerate_knowledge_embedding(
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Knowledge base entry not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Entrada de la base de conocimientos no encontrada".to_string()))?;
// Generate embedding
// Generar embedding
let response = generate_embedding(&client, &ollama_url, &model, &entry.content_chunk)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
let pgvector = ai::embedding_to_pgvector(&response.embedding);
// Update entry
// Actualizar entrada
sqlx::query(
r#"
UPDATE knowledge_base
@@ -185,7 +185,7 @@ pub async fn regenerate_knowledge_embedding(
Ok(StatusCode::OK)
}
// ==================== Semantic Search ====================
// ==================== Búsqueda Semántica ====================
/// GET /api/knowledge-base/semantic-search - Search knowledge base by semantic similarity
pub async fn semantic_search_knowledge(
@@ -193,7 +193,7 @@ pub async fn semantic_search_knowledge(
State(pool): State<PgPool>,
Query(filters): Query<KnowledgeSearchFilters>,
) -> Result<Json<Vec<KnowledgeSearchResult>>, (StatusCode, String)> {
// Create client that accepts invalid certificates
// Crear cliente que acepte certificados inválidos
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
@@ -203,7 +203,7 @@ pub async fn semantic_search_knowledge(
let ollama_url = ai::get_ollama_url();
let model = ai::get_embedding_model();
// Generate embedding for query
// Generar embedding para la consulta
let embedding_response = generate_embedding(&client, &ollama_url, &model, &filters.query)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI error: {}", e)))?;
@@ -213,7 +213,7 @@ pub async fn semantic_search_knowledge(
let limit = filters.limit.unwrap_or(10);
let threshold = filters.threshold.unwrap_or(0.5);
// Build query with optional filters
// Construir consulta con filtros opcionales
let mut query = String::from(
r#"
SELECT
@@ -269,7 +269,7 @@ pub async fn semantic_search_knowledge(
Ok(Json(results))
}
// ==================== Helper Structs ====================
// ==================== Estructuras de Ayuda ====================
#[derive(Debug, sqlx::FromRow, Clone)]
struct KnowledgeBaseEntry {
+15 -15
View File
@@ -26,18 +26,18 @@ pub async fn create_payment_preference(
let user_id = claims.sub;
let course_id = payload.course_id;
// 1. Get Course details
// 1. Obtener detalles del curso
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(course_id)
.fetch_one(&pool)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?;
.map_err(|_| (StatusCode::NOT_FOUND, "Curso no encontrado".into()))?;
if course.price <= 0.0 {
return Err((StatusCode::BAD_REQUEST, "Course is free".into()));
return Err((StatusCode::BAD_REQUEST, "El curso es gratuito".into()));
}
// 2. Create a pending transaction
// 2. Crear una transacción pendiente
let transaction_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO transactions (id, organization_id, user_id, course_id, amount, currency, status)
@@ -53,7 +53,7 @@ pub async fn create_payment_preference(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Call Mercado Pago API
// 3. Llamar a la API de Mercado Pago
let mp_access_token = std::env::var("MP_ACCESS_TOKEN").unwrap_or_default();
let back_url_success = std::env::var("MP_BACK_URL_SUCCESS").unwrap_or_default();
let back_url_failure = std::env::var("MP_BACK_URL_FAILURE").unwrap_or_default();
@@ -94,7 +94,7 @@ pub async fn create_payment_preference(
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("MP Error: {}", e),
format!("Error de MP: {}", e),
)
})?;
@@ -102,14 +102,14 @@ pub async fn create_payment_preference(
let err_text = mp_response.text().await.unwrap_or_default();
return Err((
StatusCode::BAD_GATEWAY,
format!("MP API Error: {}", err_text),
format!("Error de la API de MP: {}", err_text),
));
}
let mp_data: serde_json::Value = mp_response.json().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to parse MP response: {}", e),
format!("Error al analizar la respuesta de MP: {}", e),
)
})?;
@@ -119,7 +119,7 @@ pub async fn create_payment_preference(
.unwrap_or_default()
.to_string();
// Update transaction with provider reference
// Actualizar transacción con la referencia del proveedor
sqlx::query("UPDATE transactions SET provider_reference = $1 WHERE id = $2")
.bind(&preference_id)
.bind(transaction_id)
@@ -155,7 +155,7 @@ pub async fn mercadopago_webhook(
let payment_id = payload.data.id;
let mp_access_token = std::env::var("MP_ACCESS_TOKEN").unwrap_or_default();
// 1. Fetch payment details from Mercado Pago to verify status
// 1. Obtener detalles del pago de Mercado Pago para verificar el estado
let client = reqwest::Client::new();
let mp_response = client
.get(format!(
@@ -181,27 +181,27 @@ pub async fn mercadopago_webhook(
.unwrap_or_default();
if status != "approved" {
return Ok(StatusCode::OK); // Payment not yet approved, wait for next notification
return Ok(StatusCode::OK); // El pago aún no ha sido aprobado, esperar a la siguiente notificación
}
// 2. Find transaction by external reference (transaction_id)
// 2. Buscar transacción por referencia externa (transaction_id)
let transaction: Option<(Uuid, Uuid, Uuid)> = sqlx::query_as(
"SELECT id, user_id, course_id FROM transactions WHERE id = $1 OR provider_reference = $1",
)
.bind(&external_reference) // Try by external reference first
.bind(&external_reference) // Intentar por referencia externa primero
.fetch_optional(&pool)
.await
.unwrap_or(None);
if let Some((trans_id, user_id, course_id)) = transaction {
// Mark transaction as success
// Marcar transacción como exitosa
sqlx::query("UPDATE transactions SET status = 'success', updated_at = NOW() WHERE id = $1")
.bind(trans_id)
.execute(&pool)
.await
.ok();
// Auto-enroll the user
// Inscribir automáticamente al usuario
sqlx::query("INSERT INTO enrollments (id, user_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
.bind(Uuid::new_v4())
.bind(user_id)
@@ -18,7 +18,7 @@ pub async fn submit_assignment(
Path((course_id, lesson_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SubmitAssignmentPayload>,
) -> Result<Json<CourseSubmission>, (StatusCode, String)> {
// Check if submission already exists
// Verificar si la entrega ya existe
let existing: Option<CourseSubmission> = sqlx::query_as(
"SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2"
)
@@ -29,7 +29,7 @@ pub async fn submit_assignment(
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(_) = existing {
// Update existing submission
// Actualizar entrega existente
let updated: CourseSubmission = sqlx::query_as(
r#"
UPDATE course_submissions
@@ -48,7 +48,7 @@ pub async fn submit_assignment(
return Ok(Json(updated));
}
// Create new submission
// Crear nueva entrega
let submission: CourseSubmission = sqlx::query_as(
r#"
INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content)
@@ -74,10 +74,10 @@ pub async fn get_peer_review_assignment(
State(pool): State<PgPool>,
Path((course_id, lesson_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Option<CourseSubmission>>, (StatusCode, String)> {
// Find a submission that:
// 1. Is not my own
// 2. Has fewer than 2 reviews (configurable, but hardcoded for now)
// 3. I haven't reviewed yet
// Buscar una entrega que:
// 1. No sea la mía propia
// 2. Tenga menos de 2 revisiones (configurable, pero hardcoded por ahora)
// 3. Yo no haya revisado aún
let submission: Option<CourseSubmission> = sqlx::query_as(
r#"
SELECT s.*
@@ -115,7 +115,7 @@ pub async fn submit_peer_review(
Path((_course_id, _lesson_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SubmitPeerReviewPayload>,
) -> Result<Json<PeerReview>, (StatusCode, String)> {
// Verify valid submission
// Verificar entrega válida
let submission_row = sqlx::query(
"SELECT user_id FROM course_submissions WHERE id = $1"
)
@@ -126,17 +126,17 @@ pub async fn submit_peer_review(
let submission_user_id = match submission_row {
Some(row) => row.get::<Uuid, _>("user_id"),
None => return Err((StatusCode::NOT_FOUND, "Submission not found".to_string())),
None => return Err((StatusCode::NOT_FOUND, "Entrega no encontrada".to_string())),
};
if submission_user_id == claims.sub {
return Err((
StatusCode::BAD_REQUEST,
"Cannot review your own submission".to_string(),
"No puedes revisar tu propia entrega".to_string(),
));
}
// Check if already reviewed
// Verificar si ya fue revisada
let existing = sqlx::query(
"SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2"
)
@@ -149,11 +149,11 @@ pub async fn submit_peer_review(
if existing.is_some() {
return Err((
StatusCode::CONFLICT,
"You have already reviewed this submission".to_string(),
"Ya has revisado esta entrega".to_string(),
));
}
// Create review
// Crear revisión
let review: PeerReview = sqlx::query_as(
r#"
INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id)
@@ -179,7 +179,7 @@ pub async fn get_my_submission_feedback(
State(pool): State<PgPool>,
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Vec<PeerReview>>, (StatusCode, String)> {
// Get reviews for my submission on this lesson
// Obtener revisiones para mi entrega en esta lección
let reviews: Vec<PeerReview> = sqlx::query_as(
r#"
SELECT pr.*
+9 -9
View File
@@ -6,23 +6,23 @@ pub fn get_lti_private_key() -> jsonwebtoken::EncodingKey {
let key_str = env::var("LTI_PRIVATE_KEY").unwrap_or_else(|_| {
let dev_key_path = "services/lms-service/dev_keys/lti_private.pem";
std::fs::read_to_string(dev_key_path).unwrap_or_else(|_| {
// Return a dummy key or handle error gracefully in production
// For now, we'll return a string that will likely fail decoding if used,
// but allows the service to start if LTI is not used.
tracing::warn!("LTI private key not found at {} and LTI_PRIVATE_KEY is not set.", dev_key_path);
// Devolver una clave ficticia o manejar el error de forma adecuada en producción
// Por ahora, devolveremos una cadena que probablemente fallará al decodificarse si se utiliza,
// pero permite que el servicio se inicie si no se utiliza LTI.
tracing::warn!("Clave privada LTI no encontrada en {} y LTI_PRIVATE_KEY no está establecida.", dev_key_path);
String::new()
})
});
if key_str.is_empty() {
// Handle the empty key case - maybe return a specialized error or a dummy key
// that fails later. jsonwebtoken::EncodingKey::from_rsa_pem usually expects valid PEM.
// We'll use a dummy valid-looking but useless PEM if it's empty to avoid panic on startup
// but it will fail on actual LTI usage.
// Manejar el caso de clave vacía; tal vez devolver un error especializado o una clave ficticia
// que falle más tarde. jsonwebtoken::EncodingKey::from_rsa_pem suele esperar un PEM válido.
// Utilizaremos un PEM ficticio con apariencia válida pero inútil si está vacío para evitar pánico al inicio,
// pero fallará en el uso real de LTI.
return jsonwebtoken::EncodingKey::from_rsa_pem(b"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA7f...dummy...\n-----END RSA PRIVATE KEY-----").expect("Dummy key failed");
}
jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Invalid LTI private key format")
jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Formato de clave privada LTI inválido")
}
pub fn get_lti_jwks() -> JwkSet {
+2 -2
View File
@@ -42,7 +42,7 @@ pub async fn create_meeting(
Json(payload): Json<CreateMeetingPayload>,
) -> Result<Json<Meeting>, (StatusCode, String)> {
if claims.role == "student" {
return Err((StatusCode::FORBIDDEN, "Only instructors can create meetings".to_string()));
return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden crear reuniones".to_string()));
}
let meeting_id = format!("openccb-{}", Uuid::new_v4());
@@ -76,7 +76,7 @@ pub async fn delete_meeting(
claims: Claims,
) -> Result<StatusCode, (StatusCode, String)> {
if claims.role == "student" {
return Err((StatusCode::FORBIDDEN, "Only instructors can delete meetings".to_string()));
return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden eliminar reuniones".to_string()));
}
sqlx::query("DELETE FROM meetings WHERE id = $1 AND organization_id = $2")
+30 -30
View File
@@ -25,7 +25,7 @@ pub async fn lti_login_initiation(
State(pool): State<PgPool>,
Query(params): Query<LtiLoginParams>,
) -> Result<Redirect, (StatusCode, String)> {
// 1. Find registration
// 1. Buscar registro
let registration = sqlx::query_as::<_, LtiRegistration>(
"SELECT * FROM lti_registrations WHERE issuer = $1 AND ($2::text IS NULL OR client_id = $2)"
)
@@ -34,20 +34,20 @@ pub async fn lti_login_initiation(
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "LTI Registration not found".to_string()))?;
.ok_or((StatusCode::BAD_REQUEST, "Registro LTI no encontrado".to_string()))?;
// 2. Generate state and nonce
// 2. Generar estado y nonce
let state = Uuid::new_v4().to_string();
let nonce = Uuid::new_v4().to_string();
// 3. Store nonce
// 3. Almacenar nonce
sqlx::query("INSERT INTO lti_nonces (nonce) VALUES ($1)")
.bind(&nonce)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 4. Construct redirect URL
// 4. Construir URL de redirección
let mut url = format!(
"{}?scope=openid&response_type=id_token&client_id={}&redirect_uri={}&login_hint={}&state={}&nonce={}&response_mode=form_post",
registration.auth_login_url,
@@ -76,9 +76,9 @@ pub async fn validate_lti_jwt(
client_id: &str,
) -> Result<LtiLaunchClaims, String> {
let header = decode_header(id_token).map_err(|e| e.to_string())?;
let kid = header.kid.ok_or("Missing kid in JWT header")?;
let kid = header.kid.ok_or("Falta kid en el encabezado JWT")?;
// Fetch JWKS
// Obtener JWKS
let jwks: JwkSet = reqwest::get(jwks_url)
.await
.map_err(|e| e.to_string())?
@@ -86,7 +86,7 @@ pub async fn validate_lti_jwt(
.await
.map_err(|e| e.to_string())?;
let jwk = jwks.find(&kid).ok_or("JWK not found for kid")?;
let jwk = jwks.find(&kid).ok_or("JWK no encontrado para kid")?;
let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?;
let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256);
@@ -102,27 +102,27 @@ pub async fn lti_launch(
State(pool): State<PgPool>,
Form(payload): Form<LtiLaunchParams>,
) -> Result<Redirect, (StatusCode, String)> {
// 1. Decode claims manually to find registration (since we don't have the key yet)
// 1. Decodificar claims manualmente para encontrar el registro (ya que aún no tenemos la clave)
let parts: Vec<&str> = payload.id_token.split('.').collect();
if parts.len() != 3 {
return Err((StatusCode::BAD_REQUEST, "Invalid JWT format".to_string()));
return Err((StatusCode::BAD_REQUEST, "Formato JWT inválido".to_string()));
}
let decoded_claims = URL_SAFE_NO_PAD.decode(parts[1])
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64 in JWT payload: {}", e)))?;
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Base64 inválido en el payload del JWT: {}", e)))?;
let claims: serde_json::Value = serde_json::from_slice(&decoded_claims)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON in JWT payload: {}", e)))?;
.map_err(|e| (StatusCode::BAD_REQUEST, format!("JSON inválido en el payload del JWT: {}", e)))?;
let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Missing iss claim".to_string()))?;
let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Falta el claim iss".to_string()))?;
let aud_val = &claims["aud"];
let aud = match aud_val {
serde_json::Value::String(s) => s.as_str(),
serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "Invalid aud in array".to_string()))?,
_ => return Err((StatusCode::BAD_REQUEST, "Invalid aud claim".to_string())),
serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "aud inválido en el array".to_string()))?,
_ => return Err((StatusCode::BAD_REQUEST, "Claim aud inválido".to_string())),
};
// 2. Find registration
// 2. Buscar registro
let registration = sqlx::query_as::<_, LtiRegistration>(
"SELECT * FROM lti_registrations WHERE issuer = $1 AND client_id = $2"
)
@@ -131,14 +131,14 @@ pub async fn lti_launch(
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "LTI Registration not found for issuer/aud".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Registro LTI no encontrado para emisor/audiencia".to_string()))?;
// 3. Validate JWT
// 3. Validar JWT
let lti_claims = validate_lti_jwt(&payload.id_token, &registration.jwks_url, &registration.client_id)
.await
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("JWT validation failed: {}", e)))?;
.map_err(|e| (StatusCode::UNAUTHORIZED, format!("Validación de JWT fallida: {}", e)))?;
// 4. Verify nonce
// 4. Verificar nonce
let nonce_exists = sqlx::query("DELETE FROM lti_nonces WHERE nonce = $1")
.bind(&lti_claims.nonce)
.execute(&pool)
@@ -147,10 +147,10 @@ pub async fn lti_launch(
.rows_affected() > 0;
if !nonce_exists {
return Err((StatusCode::BAD_REQUEST, "Invalid or expired nonce".to_string()));
return Err((StatusCode::BAD_REQUEST, "Nonce inválido o expirado".to_string()));
}
// 5. Find or create user
// 5. Buscar o crear usuario
let email = lti_claims.email.clone().unwrap_or_else(|| format!("lti_{}@{}", lti_claims.subject, iss.replace("http://", "").replace("https://", "")));
let full_name = lti_claims.name.clone().unwrap_or_else(|| "LTI User".to_string());
@@ -206,16 +206,16 @@ pub async fn lti_launch(
let user = user.unwrap();
// 8. Redirect based on message type
// 8. Redirigir según el tipo de mensaje
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
let studio_url = std::env::var("NEXT_PUBLIC_STUDIO_URL").unwrap_or_else(|_| "http://localhost:3001".to_string());
let token = common::auth::create_jwt(user.id, user.organization_id, &user.role)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create token: {}", e)))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al crear el token: {}", e)))?;
let redirect_target = lti_claims.resource_link.as_ref().map(|rl| rl.id.clone()).unwrap_or_default();
if lti_claims.message_type == "LtiDeepLinkingRequest" {
let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Missing deep_linking_settings".to_string()))?;
let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Faltan deep_linking_settings".to_string()))?;
let dl_request_id = Uuid::new_v4();
sqlx::query(
@@ -249,8 +249,8 @@ pub async fn lti_deep_linking_response(
claims: Claims,
Json(payload): Json<LtiDeepLinkingResponsePayload>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// 1. Retrieve and delete DL request
let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid DL token".to_string()))?;
// 1. Recuperar y eliminar la solicitud de DL
let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Token de DL inválido".to_string()))?;
let dl_request = sqlx::query(
"DELETE FROM lti_deep_linking_requests WHERE id = $1 RETURNING registration_id, deployment_id, return_url, data"
@@ -259,15 +259,15 @@ pub async fn lti_deep_linking_response(
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::UNAUTHORIZED, "Invalid or expired DL request".to_string()))?;
.ok_or((StatusCode::UNAUTHORIZED, "Solicitud de DL inválida o expirada".to_string()))?;
// Manual mapping since we can't use query!/query_as! easily for RETURNING without a struct
// Mapeo manual ya que no podemos usar query!/query_as! fácilmente para RETURNING sin una estructura
let registration_id: Uuid = dl_request.get("registration_id");
let deployment_id: String = dl_request.get("deployment_id");
let _return_url: String = dl_request.get::<String, _>("return_url");
let dl_data: Option<String> = dl_request.get("data");
// 2. Find registration
// 2. Buscar registro
let registration = sqlx::query_as::<_, LtiRegistration>(
"SELECT * FROM lti_registrations WHERE id = $1",
)
+25 -25
View File
@@ -36,44 +36,44 @@ async fn main() {
dotenv().ok();
tracing_subscriber::fmt::init();
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL debe estar configurada");
let pool = PgPoolOptions::new()
.max_connections(10)
.min_connections(2)
.acquire_timeout(Duration::from_secs(30))
.connect(&db_url)
.await
.expect("Failed to connect to database");
.expect("Error al conectar con la base de datos");
// Initialize health state
// Inicializar estado de salud
let health_state = HealthState::default();
let mysql_pool = external_db::init_mysql_pool().await;
// Run migrations automatically
// Ejecutar migraciones automáticamente
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
.expect("Error al ejecutar las migraciones");
// Start background task for deadline notifications
// Iniciar tarea en segundo plano para notificaciones de fechas límite
let pool_clone = pool.clone();
tokio::spawn(async move {
loop {
handlers::check_deadlines_and_notify(pool_clone.clone()).await;
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; // Every hour
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; // Cada hora
}
});
// CORS configuration - Allow multiple origins for development and production
// Using a predicate closure to support wildcard subdomains for norteamericano.cl
// Configuración de CORS - Permitir múltiples orígenes para desarrollo y producción
// Usando un cierre de predicado para soportar subdominios comodín para norteamericano.cl
use tower_http::cors::AllowOrigin;
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
let origin_str = origin.to_str().unwrap_or("");
// Development origins
// Orígenes de desarrollo
let allowed_origins = [
"http://localhost:3000",
"http://localhost:3003",
@@ -82,17 +82,17 @@ async fn main() {
"http://192.168.0.254:3000",
"http://192.168.0.254:3003",
"http://192.168.0.254",
// Production - Norteamericano domains (HTTPS)
// Producción - Dominios de Norteamericano (HTTPS)
"https://studio.norteamericano.cl",
"https://learning.norteamericano.cl",
];
// Check exact matches
// Comprobar coincidencias exactas
if allowed_origins.contains(&origin_str) {
return true;
}
// Check wildcard for subdomains: https://*.norteamericano.cl
// Comprobar comodín para subdominios: https://*.norteamericano.cl
if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") {
let subdomain = origin_str
.strip_prefix("https://")
@@ -100,7 +100,7 @@ async fn main() {
.strip_suffix(".norteamericano.cl")
.unwrap_or("");
// Allow any subdomain (e.g., api., cdn., admin., etc.)
// Permitir cualquier subdominio (p. ej., api., cdn., admin., etc.)
if !subdomain.is_empty() && !subdomain.contains('/') {
return true;
}
@@ -169,10 +169,10 @@ async fn main() {
"/courses/{id}/dropout-risks",
get(predictive::get_course_dropout_risks),
)
// Live Learning
// Aprendizaje en Vivo (Live Learning)
.route("/courses/{id}/meetings", get(live::get_course_meetings).post(live::create_meeting))
.route("/courses/{id}/meetings/{meeting_id}", delete(live::delete_meeting))
// Portfolio & Badges
// Portafolio e insignias (Badges)
.route("/profile/{user_id}", get(portfolio::get_public_profile))
.route("/my/badges", get(portfolio::get_my_badges))
.route("/badges/award", post(portfolio::award_badge))
@@ -189,7 +189,7 @@ async fn main() {
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap))
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
.route("/audio/evaluate-file", post(handlers::evaluate_audio_file))
// Audio Response Teacher Routes
// Rutas de Profesor para Respuesta de Audio
.route("/audio-responses", get(handlers::get_audio_responses))
.route("/audio-responses/{id}", get(handlers::get_audio_response_detail))
.route("/audio-responses/{id}/audio", get(handlers::get_audio_response_audio))
@@ -204,7 +204,7 @@ async fn main() {
"/notifications/{id}/read",
post(handlers::mark_notification_as_read),
)
// Knowledge Base Embedding Routes for Semantic RAG
// Rutas de Embeddings de Base de Conocimientos para RAG Semántico
.route(
"/knowledge-base/embeddings/generate",
post(handlers_embeddings::generate_knowledge_embeddings),
@@ -217,7 +217,7 @@ async fn main() {
"/knowledge-base/{id}/embedding/regenerate",
post(handlers_embeddings::regenerate_knowledge_embedding),
)
// Discussion Forums Routes
// Rutas de Foros de Discusión
.route(
"/courses/{id}/discussions",
get(handlers_discussions::list_threads),
@@ -255,7 +255,7 @@ async fn main() {
"/discussions/{id}/unsubscribe",
post(handlers_discussions::unsubscribe_thread),
)
// Announcements
// Anuncios
.route(
"/courses/{id}/announcements",
get(handlers_announcements::list_announcements),
@@ -274,7 +274,7 @@ async fn main() {
)
.route("/lessons/{id}/notes", get(handlers_notes::get_note))
.route("/lessons/{id}/notes", put(handlers_notes::save_note))
// Cohorts
// Cohortes (Cohorts)
.route("/cohorts", get(handlers_cohorts::list_cohorts))
.route("/cohorts", post(handlers_cohorts::create_cohort))
.route(
@@ -289,7 +289,7 @@ async fn main() {
"/cohorts/{id}/members",
get(handlers_cohorts::get_cohort_members),
)
// Peer Assessment
// Evaluación por Pares (Peer Assessment)
.route(
"/courses/{id}/lessons/{lesson_id}/submit",
post(handlers_peer_review::submit_assignment),
@@ -338,7 +338,7 @@ async fn main() {
</html>
"#)
}))
// Health check routes
// Rutas de comprobación de salud (Health check)
.merge(health::health_routes(pool.clone()).with_state(health_state))
.route("/catalog", get(handlers::get_course_catalog))
.route("/ingest", post(handlers::ingest_course))
@@ -353,7 +353,7 @@ async fn main() {
.route("/lti/jwks", get(jwks::lti_jwks_handler))
.route("/lti/deep-linking/response", post(lti::lti_deep_linking_response))
.merge(protected_routes)
// Security headers
// Encabezados de seguridad (Security headers)
.layer(SetResponseHeaderLayer::overriding(
http::header::STRICT_TRANSPORT_SECURITY,
http::HeaderValue::from_static("max-age=31536000; includeSubDomains"),
@@ -379,7 +379,7 @@ async fn main() {
.layer(axum::Extension(mysql_pool));
let addr = SocketAddr::from(([0, 0, 0, 0], 3002));
tracing::info!("LMS Service listening on {} with rate limiting and security headers", addr);
tracing::info!("LMS Service escuchando en {} con limitación de tasa y encabezados de seguridad", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, public_routes).await.unwrap();
}
+6 -6
View File
@@ -150,7 +150,7 @@ pub struct ApiDoc;
tag = "Cursos",
request_body = IngestCourseRequest,
responses(
(status = 200, description = "Curso ingestada exitosamente"),
(status = 200, description = "Curso ingestado exitosamente"),
(status = 400, description = "Payload inválido o JSON mal formado"),
(status = 500, description = "Error interno del servidor"),
)
@@ -227,11 +227,11 @@ pub fn get_course_outline() {}
///
/// | id | nombre | descripcion |
/// |----|--------|-------------|
/// | 1 | CA | Continuous Assessment |
/// | 2 | MWT | Midterm Written Test |
/// | 3 | MOT | Midterm Oral Test |
/// | 5 | FOT | Final Oral Test |
/// | 6 | FWT | Final written test |
/// | 1 | CA | Evaluación Continua (Continuous Assessment) |
/// | 2 | MWT | Examen Escrito de Mitad de Ciclo (Midterm Written Test) |
/// | 3 | MOT | Examen Oral de Mitad de Ciclo (Midterm Oral Test) |
/// | 5 | FOT | Examen Oral Final (Final Oral Test) |
/// | 6 | FWT | Examen Escrito Final (Final Written Test) |
#[utoipa::path(
get,
path = "/tipo-nota",
+3 -3
View File
@@ -19,11 +19,11 @@ pub async fn get_public_profile(
.fetch_optional(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
.ok_or((StatusCode::NOT_FOUND, "Usuario no encontrado".to_string()))?;
let is_public: bool = user.get("is_public_profile");
if !is_public {
return Err((StatusCode::FORBIDDEN, "This profile is private".to_string()));
return Err((StatusCode::FORBIDDEN, "Este perfil es privado".to_string()));
}
let badges = sqlx::query_as::<sqlx::Postgres, Badge>(
@@ -98,7 +98,7 @@ pub async fn award_badge(
Json(payload): Json<UserBadge>,
) -> Result<StatusCode, (StatusCode, String)> {
if claims.role == "student" {
return Err((StatusCode::FORBIDDEN, "Only admins can award badges manually".to_string()));
return Err((StatusCode::FORBIDDEN, "Solo los administradores pueden otorgar insignias manualmente".to_string()));
}
sqlx::query("INSERT INTO user_badges (user_id, badge_id, awarded_at) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING")
+4 -4
View File
@@ -16,7 +16,7 @@ pub async fn get_course_dropout_risks(
claims: Claims,
) -> Result<Json<Vec<DropoutRisk>>, (StatusCode, String)> {
if claims.role == "student" {
return Err((StatusCode::FORBIDDEN, "Only instructors can view risk reports".to_string()));
return Err((StatusCode::FORBIDDEN, "Solo los instructores pueden ver informes de riesgo".to_string()));
}
calculate_risks_for_course(&pool, course_id, claims.org).await
@@ -34,7 +34,7 @@ pub async fn get_course_dropout_risks(
.bind(claims.org)
.fetch_all(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch risks: {}", e)))?;
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al obtener los riesgos: {}", e)))?;
let risks: Vec<DropoutRisk> = rows.into_iter().map(|row| {
DropoutRisk {
@@ -106,8 +106,8 @@ pub async fn calculate_risks_for_course(
};
let reasons = vec![
DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Grade: {:.0}%", avg_grade * 100.0) },
DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} actions in last week", last_activity_count) },
DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Calificación: {:.0}%", avg_grade * 100.0) },
DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} acciones en la última semana", last_activity_count) },
];
sqlx::query(
+41 -41
View File
@@ -1,33 +1,33 @@
//! AI Utilities for OpenCCB
//! Provides embedding generation and other AI helper functions
//! Utilidades de IA para OpenCCB
//! Proporciona generación de embeddings y otras funciones de ayuda de IA
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Default embedding model for Ollama
/// Modelo de embedding por defecto para Ollama
pub const DEFAULT_EMBEDDING_MODEL: &str = "nomic-embed-text";
/// Default Ollama URL
/// URL de Ollama por defecto
pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
/// Embedding dimensions for nomic-embed-text
/// Dimensiones del embedding para nomic-embed-text
pub const EMBEDDING_DIMENSIONS: usize = 768;
/// Model selection for different use cases
/// Selección de modelo para diferentes casos de uso
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelType {
/// Fast conversational AI (chat, tutor, Q&A)
/// IA conversacional rápida (chat, tutor, preguntas y respuestas)
Chat,
/// Complex reasoning (analysis, recommendations, feedback)
/// Razonamiento complejo (análisis, recomendaciones, retroalimentación)
Complex,
/// Advanced tasks (course generation, detailed analysis)
/// Tareas avanzadas (generación de cursos, análisis detallado)
Advanced,
/// Embedding generation
/// Generación de embeddings
Embedding,
}
impl ModelType {
/// Get the model name for this type from environment
/// Obtener el nombre del modelo para este tipo desde el entorno
pub fn get_model(&self) -> String {
match self {
ModelType::Chat => {
@@ -45,34 +45,34 @@ impl ModelType {
}
}
/// Get recommended temperature for this model type
/// Obtener la temperatura recomendada para este tipo de modelo
pub fn get_temperature(&self) -> f32 {
match self {
ModelType::Chat => 0.7, // Balanced creativity/accuracy
ModelType::Complex => 0.5, // More focused reasoning
ModelType::Advanced => 0.6, // Balanced for analysis
ModelType::Embedding => 0.0, // Deterministic for embeddings
ModelType::Chat => 0.7, // Equilibrio entre creatividad y precisión
ModelType::Complex => 0.5, // Razonamiento más enfocado
ModelType::Advanced => 0.6, // Equilibrado para análisis
ModelType::Embedding => 0.0, // Determinista para embeddings
}
}
/// Get max tokens for this model type
/// Obtener los tokens máximos para este tipo de modelo
pub fn get_max_tokens(&self) -> u32 {
match self {
ModelType::Chat => 1024,
ModelType::Complex => 2048,
ModelType::Advanced => 4096,
ModelType::Embedding => 0, // Not applicable
ModelType::Embedding => 0, // No aplicable
}
}
}
#[derive(Error, Debug)]
pub enum AiError {
#[error("Ollama request failed: {0}")]
#[error("Solicitud a Ollama fallida: {0}")]
OllamaRequest(String),
#[error("Invalid embedding response: {0}")]
#[error("Respuesta de embedding inválida: {0}")]
InvalidResponse(String),
#[error("Model not available: {0}")]
#[error("Modelo no disponible: {0}")]
ModelNotAvailable(String),
}
@@ -83,19 +83,19 @@ pub struct EmbeddingResponse {
pub model: String,
}
/// Get Ollama URL from environment or default
/// Obtener la URL de Ollama desde el entorno o por defecto
pub fn get_ollama_url() -> String {
std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| DEFAULT_OLLAMA_URL.to_string())
}
/// Get embedding model from environment or default
/// Obtener el modelo de embedding desde el entorno o por defecto
pub fn get_embedding_model() -> String {
std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| DEFAULT_EMBEDDING_MODEL.to_string())
}
/// Get the best model for a specific task
/// Obtener el mejor modelo para una tarea específica
pub fn get_model_for_task(task: &str) -> String {
// Task-based model selection
// Selección de modelo basada en la tarea
match task.to_lowercase().as_str() {
t if t.contains("chat") || t.contains("tutor") || t.contains("conversation") => {
ModelType::Chat.get_model()
@@ -116,22 +116,22 @@ pub fn get_model_for_task(task: &str) -> String {
}
}
/// Create a reqwest client that accepts invalid certificates (for dev with self-signed certs)
/// Crear un cliente reqwest que acepte certificados inválidos (para desarrollo con certificados autofirmados)
fn create_insecure_client() -> Result<reqwest::Client, AiError> {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
.map_err(|e| AiError::OllamaRequest(format!("Failed to create HTTP client: {}", e)))
.map_err(|e| AiError::OllamaRequest(format!("Error al crear el cliente HTTP: {}", e)))
}
/// Generate embedding for text using Ollama
/// Generar embedding para texto usando Ollama
///
/// # Arguments
/// * `client` - reqwest::Client instance
/// * `ollama_url` - Base URL for Ollama (e.g., "http://localhost:11434")
/// * `model` - Embedding model name (default: "nomic-embed-text")
/// * `text` - Text to embed
/// # Argumentos
/// * `client` - instancia de reqwest::Client
/// * `ollama_url` - URL base para Ollama (e.g., "http://localhost:11434")
/// * `model` - Nombre del modelo de embedding (por defecto: "nomic-embed-text")
/// * `text` - Texto a incrustar
pub async fn generate_embedding(
client: &reqwest::Client,
ollama_url: &str,
@@ -148,25 +148,25 @@ pub async fn generate_embedding(
}))
.send()
.await
.map_err(|e| AiError::OllamaRequest(format!("Request failed: {}", e)))?;
.map_err(|e| AiError::OllamaRequest(format!("Solicitud fallida: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(AiError::OllamaRequest(
format!("Ollama API error ({}): {}", status, error_text)
format!("Error de la API de Ollama ({}): {}", status, error_text)
));
}
let embedding_response: EmbeddingResponse = response
.json()
.await
.map_err(|e| AiError::InvalidResponse(format!("Failed to parse response: {}", e)))?;
.map_err(|e| AiError::InvalidResponse(format!("Error al analizar la respuesta: {}", e)))?;
Ok(embedding_response)
}
/// Generate embeddings for multiple texts in batch
/// Generar embeddings para múltiples textos en lote
pub async fn generate_embeddings_batch(
client: &reqwest::Client,
ollama_url: &str,
@@ -183,8 +183,8 @@ pub async fn generate_embeddings_batch(
Ok(embeddings)
}
/// Convert a vector of f32 to pgvector-compatible format
/// PostgreSQL vector format: "[0.1,0.2,0.3,...]"
/// Convertir un vector de f32 a un formato compatible con pgvector
/// Formato de vector de PostgreSQL: "[0.1,0.2,0.3,...]"
pub fn embedding_to_pgvector(embedding: &[f32]) -> String {
let formatted: Vec<String> = embedding
.iter()
@@ -193,12 +193,12 @@ pub fn embedding_to_pgvector(embedding: &[f32]) -> String {
format!("[{}]", formatted.join(","))
}
/// Parse pgvector format back to Vec<f32>
/// Analizar el formato pgvector de vuelta a Vec<f32>
pub fn pgvector_to_embedding(pgvector: &str) -> Result<Vec<f32>, String> {
let trimmed = pgvector.trim().trim_start_matches('[').trim_end_matches(']');
trimmed
.split(',')
.map(|s| s.trim().parse::<f32>().map_err(|e| format!("Parse error: {}", e)))
.map(|s| s.trim().parse::<f32>().map_err(|e| format!("Error de análisis: {}", e)))
.collect()
}
+6 -6
View File
@@ -1,11 +1,11 @@
//! Health check endpoints for monitoring and observability
//! Endpoints de verificación de salud para monitoreo y observabilidad
use axum::{Json, Router, routing::get, extract::State};
use serde_json::json;
use sqlx::PgPool;
use std::time::Instant;
/// Health check state shared across requests
/// Estado de verificación de salud compartido entre peticiones
#[derive(Clone)]
pub struct HealthState {
pub start_time: Instant,
@@ -21,7 +21,7 @@ impl Default for HealthState {
}
}
/// Basic health check endpoint
/// Endpoint básico de verificación de salud
pub async fn health_check() -> Json<serde_json::Value> {
Json(json!({
"status": "healthy",
@@ -29,7 +29,7 @@ pub async fn health_check() -> Json<serde_json::Value> {
}))
}
/// Detailed readiness check including database connectivity
/// Verificación detallada de disponibilidad que incluye la conectividad con la base de datos
pub async fn readiness_check(pool: PgPool) -> Json<serde_json::Value> {
let db_status = match pool.acquire().await {
Ok(_) => "connected",
@@ -45,7 +45,7 @@ pub async fn readiness_check(pool: PgPool) -> Json<serde_json::Value> {
}))
}
/// Liveness check with uptime information
/// Verificación de vitalidad (liveness) con información de tiempo de actividad (uptime)
pub async fn liveness_check(state: State<HealthState>) -> Json<serde_json::Value> {
let uptime = state.start_time.elapsed();
@@ -57,7 +57,7 @@ pub async fn liveness_check(state: State<HealthState>) -> Json<serde_json::Value
}))
}
/// Create health routes
/// Crear rutas de salud
pub fn health_routes(pool: PgPool) -> Router<HealthState> {
Router::new()
.route("/health", get(health_check))
+1 -1
View File
@@ -28,7 +28,7 @@ pub async fn org_extractor_middleware(
let token = if let Some(token_str) = auth_header.and_then(|s: &str| s.strip_prefix("Bearer ")) {
token_str.to_string()
} else {
// Check for preview_token in query string
// Verificar si hay preview_token en la cadena de consulta
let query = req.uri().query().unwrap_or_default();
let preview_token = query
.split('&')
+56 -56
View File
@@ -11,7 +11,7 @@ pub struct Course {
pub title: String,
pub description: Option<String>,
pub instructor_id: Uuid,
pub pacing_mode: String, // "self_paced" or "instructor_led"
pub pacing_mode: String, // "self_paced" o "instructor_led"
pub start_date: Option<DateTime<Utc>>,
pub end_date: Option<DateTime<Utc>>,
pub passing_percentage: i32,
@@ -121,7 +121,7 @@ pub struct GradingCategory {
pub name: String,
pub weight: i32, // 0-100
pub drop_count: i32,
pub tipo_nota_id: Option<i32>, // Maps to idTipoNota in external MySQL system
pub tipo_nota_id: Option<i32>, // Se mapea con idTipoNota en el sistema MySQL externo
pub created_at: DateTime<Utc>,
}
@@ -131,7 +131,7 @@ pub struct UserGrade {
pub user_id: Uuid,
pub course_id: Uuid,
pub lesson_id: Uuid,
pub score: f32, // 0.0 to 1.0
pub score: f32, // 0.0 a 1.0
pub attempts_count: i32,
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
@@ -187,7 +187,7 @@ pub struct Enrollment {
pub user_id: Uuid,
pub organization_id: Uuid,
pub course_id: Uuid,
pub external_id: Option<i32>, // idDetalleContrato from the external system
pub external_id: Option<i32>, // idDetalleContrato del sistema externo
pub enrolled_at: DateTime<Utc>,
}
@@ -242,7 +242,7 @@ pub struct LtiLaunchClaims {
#[serde(rename = "sub")]
pub subject: String,
#[serde(rename = "aud")]
pub audience: serde_json::Value, // Can be string or array
pub audience: serde_json::Value, // Puede ser una cadena o un array
#[serde(rename = "exp")]
pub expires_at: i64,
#[serde(rename = "iat")]
@@ -363,7 +363,7 @@ pub struct Transaction {
pub course_id: Uuid,
pub amount: f64,
pub currency: String,
pub status: String, // "pending", "success", "failure"
pub status: String, // "pending", "success", "failure" (pendiente, éxito, falla)
pub provider_reference: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -377,7 +377,7 @@ pub struct User {
pub email: String,
pub password_hash: String,
pub full_name: String,
pub role: String, // admin, instructor, student
pub role: String, // admin (administrador), instructor, student (estudiante)
pub xp: i32,
pub level: i32,
pub avatar_url: Option<String>,
@@ -538,7 +538,7 @@ pub struct Recommendation {
pub title: String,
pub description: String,
pub lesson_id: Option<Uuid>,
pub priority: String, // "high", "medium", "low"
pub priority: String, // "high", "medium", "low" (alta, media, baja)
pub reason: String,
}
@@ -596,7 +596,7 @@ pub struct AuditLog {
}
// Discussion Forums Models
// Modelos de Foros de Discusión
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct DiscussionThread {
pub id: Uuid,
@@ -633,7 +633,7 @@ pub struct DiscussionVote {
pub organization_id: Uuid,
pub post_id: Uuid,
pub user_id: Uuid,
pub vote_type: String, // 'upvote' or 'downvote'
pub vote_type: String, // 'upvote' o 'downvote'
pub created_at: DateTime<Utc>,
}
@@ -646,10 +646,10 @@ pub struct DiscussionSubscription {
pub created_at: DateTime<Utc>,
}
// Response DTOs for Discussion APIs
// DTOs de respuesta para las APIs de Discusión
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct ThreadWithAuthor {
// Thread fields
// Campos del hilo
pub id: Uuid,
pub organization_id: Uuid,
pub course_id: Uuid,
@@ -662,17 +662,17 @@ pub struct ThreadWithAuthor {
pub view_count: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
// Author info
// Información del autor
pub author_name: String,
pub author_avatar: Option<String>,
// Aggregated data
// Datos agregados
pub post_count: i64,
pub has_endorsed_answer: bool,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct PostWithAuthor {
// Post fields
// Campos de la publicación
pub id: Uuid,
pub organization_id: Uuid,
pub thread_id: Uuid,
@@ -686,14 +686,14 @@ pub struct PostWithAuthor {
// Author info
pub author_name: String,
pub author_avatar: Option<String>,
// User interaction
pub user_vote: Option<String>, // 'upvote', 'downvote', or null
// Nested replies (not from DB, populated manually)
// Interacción del usuario
pub user_vote: Option<String>, // 'upvote', 'downvote' o nulo
// Respuestas anidadas (no desde la BD, pobladas manualmente)
#[sqlx(skip)]
pub replies: Vec<PostWithAuthor>,
}
// Course Announcements
// Anuncios del Curso
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct CourseAnnouncement {
pub id: Uuid,
@@ -711,7 +711,7 @@ pub struct CourseAnnouncement {
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct AnnouncementWithAuthor {
// Announcement fields
// Campos del anuncio
pub id: Uuid,
pub organization_id: Uuid,
pub course_id: Uuid,
@@ -728,7 +728,7 @@ pub struct AnnouncementWithAuthor {
pub cohort_ids: Option<Vec<Uuid>>,
}
// Student Notes
// Notas del Estudiante
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct StudentNote {
pub id: Uuid,
@@ -744,7 +744,7 @@ pub struct SaveNotePayload {
pub content: String,
}
// Cohorts & Groups
// Cohortes y Grupos
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct Cohort {
pub id: Uuid,
@@ -784,7 +784,7 @@ pub struct StudentGradeReport {
pub last_active_at: Option<DateTime<Utc>>,
}
// Peer Assessment
// Evaluación por Pares
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct CourseSubmission {
pub id: Uuid,
@@ -832,7 +832,7 @@ pub struct SubmissionWithReviews {
pub average_score: Option<f64>,
}
// Content Libraries
// Librerías de Contenido
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct LibraryBlock {
pub id: Uuid,
@@ -894,8 +894,8 @@ pub struct DropoutRisk {
pub course_id: Uuid,
pub user_id: Uuid,
pub risk_level: DropoutRiskLevel,
pub score: f32, // 0.0 to 1.0 (Higher means higher risk)
pub reasons: Option<serde_json::Value>, // e.g., ["low_grades", "inactivity"]
pub score: f32, // 0.0 a 1.0 (Más alto significa mayor riesgo)
pub reasons: Option<serde_json::Value>, // ej., ["low_grades", "inactivity"]
pub last_calculated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -921,7 +921,7 @@ mod tests {
let lesson = Lesson {
id: lesson_id,
organization_id: course_id, // Use course_id as proxy for org_id in test
organization_id: course_id, // Usar course_id como proxi para org_id en la prueba
module_id,
title: "Test Lesson".to_string(),
content_type: "activity".to_string(),
@@ -1040,7 +1040,7 @@ mod tests {
}
}
// ==================== Advanced Grading / Rubrics ====================
// ==================== Calificación Avanzada / Rúbricas ====================
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct Rubric {
@@ -1112,7 +1112,7 @@ pub struct AssessmentScore {
pub feedback: Option<String>,
pub created_at: DateTime<Utc>,
}
// ==================== Learning Sequences / Dependencies ====================
// ==================== Secuencias de Aprendizaje / Dependencias ====================
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct LessonDependency {
@@ -1124,7 +1124,7 @@ pub struct LessonDependency {
pub created_at: DateTime<Utc>,
}
// ==================== Live Learning (Meetings) ====================
// ==================== Aprendizaje en Vivo (Reuniones) ====================
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct Meeting {
@@ -1134,7 +1134,7 @@ pub struct Meeting {
pub title: String,
pub description: Option<String>,
pub provider: String, // "jitsi" | "bbb"
pub meeting_id: String, // Room name or external ID
pub meeting_id: String, // Nombre de la sala o ID externo
pub start_at: DateTime<Utc>,
pub duration_minutes: i32,
pub join_url: Option<String>,
@@ -1143,7 +1143,7 @@ pub struct Meeting {
pub updated_at: DateTime<Utc>,
}
// ==================== Student portfolio & Badges ====================
// ==================== Portafolio del Estudiante e Insignias ====================
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct Badge {
@@ -1177,7 +1177,7 @@ pub struct PublicProfile {
pub completed_courses_count: i64,
}
// ==================== Test Templates ====================
// ==================== Plantillas de Examen ====================
#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq)]
#[sqlx(type_name = "course_level", rename_all = "lowercase")]
@@ -1206,15 +1206,15 @@ pub enum CourseType {
#[sqlx(type_name = "test_type")]
pub enum TestType {
#[sqlx(rename = "CA")]
CA, // Continuous Assessment
CA, // Evaluación Continua
#[sqlx(rename = "MWT")]
MWT, // Midterm Written Test
MWT, // Examen Escrito de Mitad de Ciclo
#[sqlx(rename = "MOT")]
MOT, // Midterm Oral Test
MOT, // Examen Oral de Mitad de Ciclo
#[sqlx(rename = "FOT")]
FOT, // Final Oral Test
FOT, // Examen Oral Final
#[sqlx(rename = "FWT")]
FWT, // Final Written Test
FWT, // Examen Escrito Final
}
impl std::fmt::Display for TestType {
@@ -1233,17 +1233,17 @@ impl std::fmt::Display for TestType {
pub struct TestTemplate {
pub id: Uuid,
pub organization_id: Uuid,
pub mysql_course_id: Option<i32>, // Reference to imported MySQL course
pub mysql_course_id: Option<i32>, // Referencia al curso de MySQL importado
pub name: String,
pub description: Option<String>,
pub level: Option<CourseLevel>, // Deprecated: use mysql_course_id instead
pub course_type: Option<CourseType>, // Deprecated: use mysql_course_id instead
pub level: Option<CourseLevel>, // Depreciado: use mysql_course_id en su lugar
pub course_type: Option<CourseType>, // Depreciado: use mysql_course_id en su lugar
pub test_type: TestType,
pub duration_minutes: i32,
pub passing_score: i32, // 0-100 percentage
pub passing_score: i32, // Porcentaje 0-100
pub total_points: i32,
pub instructions: Option<String>,
pub template_data: serde_json::Value, // Complete test structure with sections and questions
pub template_data: serde_json::Value, // Estructura completa del examen con secciones y preguntas
pub tags: Option<Vec<String>>,
pub is_active: bool,
pub usage_count: i32,
@@ -1261,7 +1261,7 @@ pub struct TestTemplateSection {
pub section_order: i32,
pub points: i32,
pub instructions: Option<String>,
pub section_data: Option<serde_json::Value>, // Section-specific configuration
pub section_data: Option<serde_json::Value>, // Configuración específica de la sección
pub created_at: DateTime<Utc>,
}
@@ -1277,7 +1277,7 @@ pub struct TestTemplateQuestion {
pub correct_answer: Option<serde_json::Value>, // Can be index, array of indices, or text
pub explanation: Option<String>,
pub points: i32,
pub metadata: Option<serde_json::Value>, // Additional question metadata
pub metadata: Option<serde_json::Value>, // Metadatos adicionales de la pregunta
pub created_at: DateTime<Utc>,
}
@@ -1285,9 +1285,9 @@ pub struct TestTemplateQuestion {
pub struct CreateTestTemplatePayload {
pub name: String,
pub description: Option<String>,
pub mysql_course_id: Option<i32>, // Reference to imported MySQL course (preferred)
pub level: Option<CourseLevel>, // Fallback if mysql_course_id not provided
pub course_type: Option<CourseType>, // Fallback if mysql_course_id not provided
pub mysql_course_id: Option<i32>, // Referencia al curso de MySQL importado (preferido)
pub level: Option<CourseLevel>, // Alternativa si no se proporciona mysql_course_id
pub course_type: Option<CourseType>, // Alternativa si no se proporciona mysql_course_id
pub test_type: TestType,
pub duration_minutes: i32,
pub passing_score: i32,
@@ -1327,7 +1327,7 @@ pub struct ApplyTemplatePayload {
pub grading_category_id: Option<Uuid>,
}
// ==================== Question Bank ====================
// ==================== Banco de Preguntas ====================
#[derive(Debug, Serialize, Deserialize, Clone, Copy, sqlx::Type, PartialEq)]
#[sqlx(type_name = "question_bank_type")]
@@ -1389,7 +1389,7 @@ pub struct QuestionBank {
pub points: i32,
pub difficulty: Option<String>,
pub tags: Option<Vec<String>>,
pub skill_assessed: Option<String>, // reading, listening, speaking, writing
pub skill_assessed: Option<String>, // lectura (reading), escucha (listening), habla (speaking), escritura (writing)
pub source: Option<String>,
pub source_metadata: Option<serde_json::Value>,
pub imported_mysql_id: Option<i32>,
@@ -1401,10 +1401,10 @@ pub struct QuestionBank {
pub created_by: Option<Uuid>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub embedding: Option<String>, // PGVector embedding for semantic search
pub embedding: Option<String>, // Embedding de PGVector para búsqueda semántica
pub embedding_updated_at: Option<chrono::DateTime<chrono::Utc>>,
pub source_asset_id: Option<Uuid>, // audio/video asset that originated this RAG chunk
pub unit_number: Option<i32>, // syllabus unit number from ZIP folder structure
pub source_asset_id: Option<Uuid>, // Activo de audio/video que originó este fragmento RAG
pub unit_number: Option<i32>, // Número de unidad del sílabo desde la estructura de carpetas ZIP
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -1419,7 +1419,7 @@ pub struct CreateQuestionBankPayload {
pub tags: Option<Vec<String>>,
pub media_url: Option<String>,
pub media_type: Option<String>,
pub skill_assessed: Option<String>, // reading, listening, speaking, writing
pub skill_assessed: Option<String>, // lectura (reading), escucha (listening), habla (speaking), escritura (writing)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -1453,8 +1453,8 @@ pub struct QuestionBankFilters {
pub has_audio: Option<bool>,
}
// ==================== AUDIO RESPONSE MODELS ====================
// For speaking practice exercises with AI + Teacher evaluation
// ==================== MODELOS DE RESPUESTA DE AUDIO ====================
// Para ejercicios de práctica de habla con evaluación de IA + Profesor
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
+15 -15
View File
@@ -1,5 +1,5 @@
//! AI Token Limit Utilities
//! Provides functions to check and enforce monthly token limits
//! Utilidades de Límite de Tokens de IA
//! Proporciona funciones para verificar y hacer cumplir los límites mensuales de tokens
use sqlx::{PgPool, FromRow};
use uuid::Uuid;
@@ -23,16 +23,16 @@ pub struct TokenLimitError {
pub reset_date: DateTime<Utc>,
}
/// Verify if user has available tokens for AI operations
/// Verificar si el usuario tiene tokens disponibles para operaciones de IA
///
/// # Arguments
/// * `pool` - Database connection pool
/// * `user_id` - User UUID
/// * `estimated_tokens` - Estimated tokens for this operation (default: 1000)
/// # Argumentos
/// * `pool` - Pool de conexión a la base de datos
/// * `user_id` - UUID del usuario
/// * `estimated_tokens` - Tokens estimados para esta operación (por defecto: 1000)
///
/// # Returns
/// * `Ok(TokenLimitCheck)` - User has available tokens
/// * `Err(TokenLimitError)` - User exceeded limit
/// # Retornos
/// * `Ok(TokenLimitCheck)` - El usuario tiene tokens disponibles
/// * `Err(TokenLimitError)` - El usuario excedió el límite
pub async fn check_ai_token_limit(
pool: &PgPool,
user_id: Uuid,
@@ -46,9 +46,9 @@ pub async fn check_ai_token_limit(
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Failed to check token limit: {}", e);
tracing::error!("Error al verificar el límite de tokens: {}", e);
TokenLimitError {
error: "Failed to verify token limit".to_string(),
error: "Error al verificar el límite de tokens".to_string(),
monthly_limit: 0,
used_tokens: 0,
remaining_tokens: 0,
@@ -58,7 +58,7 @@ pub async fn check_ai_token_limit(
if !result.has_available_tokens {
tracing::warn!(
"User {} exceeded token limit: {}/{} (reset: {})",
"El usuario {} excedió el límite de tokens: {}/{} (reinicio: {})",
user_id,
result.used_tokens,
result.monthly_limit,
@@ -67,7 +67,7 @@ pub async fn check_ai_token_limit(
return Err(TokenLimitError {
error: format!(
"Monthly AI token limit exceeded. Used: {} / Limit: {}. Reset date: {}",
"Límite mensual de tokens de IA excedido. Usados: {} / Límite: {}. Fecha de reinicio: {}",
result.used_tokens,
result.monthly_limit,
result.reset_date.format("%Y-%m-%d %H:%M UTC")
@@ -82,7 +82,7 @@ pub async fn check_ai_token_limit(
Ok(result)
}
/// Get formatted token usage message for API responses
/// Obtener el mensaje formateado de uso de tokens para las respuestas de la API
pub fn format_token_limit_message(used: i64, limit: i32, reset: DateTime<Utc>) -> String {
let percentage = (used as f64 / limit as f64 * 100.0).round();
format!(
+3 -3
View File
@@ -23,7 +23,7 @@ impl WebhookService {
.await {
Ok(w) => w,
Err(e) => {
tracing::error!("Failed to fetch webhooks for org {}: {}", org_id, e);
tracing::error!("Error al obtener los webhooks para la org {}: {}", org_id, e);
return;
}
};
@@ -54,7 +54,7 @@ impl WebhookService {
Ok(response) => {
if !response.status().is_success() {
tracing::warn!(
"Webhook delivery to {} (event: {}) failed with status {}",
"La entrega del webhook a {} (evento: {}) falló con el estado {}",
url,
event_type,
response.status()
@@ -63,7 +63,7 @@ impl WebhookService {
}
Err(e) => {
tracing::error!(
"Failed to deliver webhook to {} (event: {}): {}",
"Error al entregar el webhook a {} (evento: {}): {}",
url,
event_type,
e