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:
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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, ®istration.jwks_url, ®istration.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(<i_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",
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user