diff --git a/Cargo.lock b/Cargo.lock index 334589b..694c1df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2225,6 +2225,7 @@ dependencies = [ "jsonwebtoken", "lettre", "mime_guess", + "rand 0.8.5", "reqwest 0.12.26", "serde", "serde_json", diff --git a/roadmap.md b/roadmap.md index 244b138..056e748 100644 --- a/roadmap.md +++ b/roadmap.md @@ -80,9 +80,9 @@ - [x] **Importación Masiva (Excel)**: Finalizar soporte para Question Bank. ## Fase 23 - 27: Infraestructura Crítica 📋 (Planificado) -- [ ] **Integración SMTP**: Password reset, notificaciones transaccionales y de marketing. -- [ ] **Búsqueda Global Unificada**: Búsqueda full-text y semántica en toda la plataforma. -- [ ] **Soporte SCORM/xAPI**: Player nativo para contenidos legados. +- [x] **Integración SMTP**: Password reset, notificaciones transaccionales (inscripción, completitud) y emails de marketing. +- [x] **Búsqueda Global Unificada**: Endpoint `/search` en LMS (cursos, lecciones, foros, anuncios) con full-text e índices GIN. Barra de búsqueda en navbar del Experience. +- [x] **Soporte SCORM/xAPI**: Player nativo (iframe) para lecciones `content_type=scorm|xapi` y bloque `scorm`, con tracking de statements xAPI en LMS. - [ ] **Accesibilidad WCAG 2.1**: Auditoría y ajustes de contraste/navegación. - [ ] **PWA y Soporte Offline**: Service workers para aprendizaje sin conexión. diff --git a/services/lms-service/Cargo.toml b/services/lms-service/Cargo.toml index 0f72b93..b4ae72d 100644 --- a/services/lms-service/Cargo.toml +++ b/services/lms-service/Cargo.toml @@ -31,3 +31,4 @@ aws-config = "1" aws-sdk-s3 = "1" sha2.workspace = true lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] } +rand = "0.8" diff --git a/services/lms-service/migrations/20260422000001_password_reset_tokens.sql b/services/lms-service/migrations/20260422000001_password_reset_tokens.sql new file mode 100644 index 0000000..b275142 --- /dev/null +++ b/services/lms-service/migrations/20260422000001_password_reset_tokens.sql @@ -0,0 +1,12 @@ +-- Tokens para recuperación de contraseña +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '1 hour'), + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_prt_token ON password_reset_tokens(token); +CREATE INDEX IF NOT EXISTS idx_prt_user_id ON password_reset_tokens(user_id); diff --git a/services/lms-service/migrations/20260422000002_global_search_indexes.sql b/services/lms-service/migrations/20260422000002_global_search_indexes.sql new file mode 100644 index 0000000..bb6d82f --- /dev/null +++ b/services/lms-service/migrations/20260422000002_global_search_indexes.sql @@ -0,0 +1,26 @@ +-- Índices para búsqueda global eficiente en cursos, lecciones, hilos y anuncios + +-- Cursos: full-text search sobre título y descripción +CREATE INDEX IF NOT EXISTS idx_courses_search_title + ON courses USING gin(to_tsvector('spanish', COALESCE(title, ''))); + +CREATE INDEX IF NOT EXISTS idx_courses_search_desc + ON courses USING gin(to_tsvector('spanish', COALESCE(description, ''))); + +-- Lecciones: índice sobre título y summary +CREATE INDEX IF NOT EXISTS idx_lessons_search_title + ON lessons USING gin(to_tsvector('spanish', COALESCE(title, ''))); + +CREATE INDEX IF NOT EXISTS idx_lessons_search_summary + ON lessons USING gin(to_tsvector('spanish', COALESCE(summary, ''))); + +-- Hilos de discusión: índice sobre título +CREATE INDEX IF NOT EXISTS idx_discussion_threads_search_title + ON discussion_threads USING gin(to_tsvector('spanish', COALESCE(title, ''))); + +-- Anuncios: índice sobre título y content +CREATE INDEX IF NOT EXISTS idx_course_announcements_search_title + ON course_announcements USING gin(to_tsvector('spanish', COALESCE(title, ''))); + +CREATE INDEX IF NOT EXISTS idx_course_announcements_search_body + ON course_announcements USING gin(to_tsvector('spanish', COALESCE(content, ''))); diff --git a/services/lms-service/migrations/20260422000003_xapi_statements.sql b/services/lms-service/migrations/20260422000003_xapi_statements.sql new file mode 100644 index 0000000..03af90e --- /dev/null +++ b/services/lms-service/migrations/20260422000003_xapi_statements.sql @@ -0,0 +1,19 @@ +-- Registro de eventos xAPI emitidos por contenidos SCORM/xAPI +CREATE TABLE IF NOT EXISTS xapi_statements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + verb TEXT NOT NULL, + object_id TEXT NOT NULL, + score DOUBLE PRECISION, + progress DOUBLE PRECISION, + completed BOOLEAN, + raw_statement JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_xapi_statements_user ON xapi_statements(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_xapi_statements_lesson ON xapi_statements(lesson_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_xapi_statements_org ON xapi_statements(organization_id, created_at DESC); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 03e9230..1686f72 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -624,6 +624,37 @@ pub async fn enroll_user( ) .await; + // Email transaccional de bienvenida (fire-and-forget) + { + let pool_clone = pool.clone(); + let org_id = org_ctx.id; + tokio::spawn(async move { + let user_row = sqlx::query_as::<_, (String, Option)>( + "SELECT email, full_name FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(&pool_clone) + .await; + + let course_title = sqlx::query_scalar::<_, String>( + "SELECT title FROM courses WHERE id = $1", + ) + .bind(course_id) + .fetch_optional(&pool_clone) + .await + .ok() + .flatten() + .unwrap_or_else(|| "el curso".to_string()); + + if let Ok(Some((email, name))) = user_row { + let name = name.unwrap_or_else(|| "Estudiante".to_string()); + crate::handlers_email::send_enrollment_email( + &pool_clone, org_id, &email, &name, &course_title, + ).await; + } + }); + } + Ok(Json(enrollment)) } diff --git a/services/lms-service/src/handlers_certificates.rs b/services/lms-service/src/handlers_certificates.rs index 8717c85..3a4759b 100644 --- a/services/lms-service/src/handlers_certificates.rs +++ b/services/lms-service/src/handlers_certificates.rs @@ -546,12 +546,15 @@ async fn issue_certificate_internal( ) })?; + // Enviar email de completitud (fire-and-forget) + _send_completion_email_spawn(pool.clone(), organization_id, user_id, user_name.clone(), course_title.clone()); + Ok(Json(CertificateResponse { id: issued_cert.get("id"), user_id, course_id, - course_title: course_title, - student_name: user_name, + course_title: course_title.clone(), + student_name: user_name.clone(), certificate_html, issued_at: issued_cert.get::, _>("issued_at").to_string(), verification_code, @@ -559,6 +562,24 @@ async fn issue_certificate_internal( })) } +// Importación local para email (evitar ciclos de módulos) +use crate::handlers_email; + +fn _send_completion_email_spawn(pool: PgPool, org_id: Uuid, user_id: Uuid, user_name: String, course_title: String) { + tokio::spawn(async move { + let email_row = sqlx::query_as::<_, (String,)>( + "SELECT email FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(&pool) + .await; + + if let Ok(Some((email,))) = email_row { + handlers_email::send_completion_email(&pool, org_id, &email, &user_name, &course_title).await; + } + }); +} + /// Template de certificado por defecto fn get_default_certificate_template() -> String { r#" diff --git a/services/lms-service/src/handlers_email.rs b/services/lms-service/src/handlers_email.rs new file mode 100644 index 0000000..5caba9f --- /dev/null +++ b/services/lms-service/src/handlers_email.rs @@ -0,0 +1,406 @@ +use axum::{Json, extract::State, http::StatusCode}; +use bcrypt::{DEFAULT_COST, hash}; +use lettre::message::Mailbox; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use rand::distributions::Alphanumeric; +use rand::{Rng, thread_rng}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::env; +use uuid::Uuid; + +// ─── Helpers SMTP compartidos ───────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct SmtpConfig { + pub enabled: bool, + pub host: String, + pub from: String, + pub port: u16, + pub starttls: bool, + pub username: Option, + pub password: Option, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +struct OrgEmailServiceRow { + service_type: String, + smtp_enabled: bool, + smtp_host: Option, + smtp_port: i32, + smtp_from: Option, + smtp_username: Option, + smtp_password: Option, + smtp_starttls: bool, +} + +pub async fn load_smtp_config(pool: &PgPool, organization_id: Uuid) -> Option { + // Intentar cargar config desde la BD + let row = sqlx::query_as::<_, OrgEmailServiceRow>( + r#" + SELECT service_type, smtp_enabled, smtp_host, smtp_port, smtp_from, + smtp_username, smtp_password, smtp_starttls + FROM organization_email_services + WHERE organization_id = $1 + ORDER BY is_default DESC, created_at ASC + LIMIT 1 + "#, + ) + .bind(organization_id) + .fetch_optional(pool) + .await + .ok() + .flatten(); + + if let Some(row) = row { + if row.service_type.trim().to_lowercase() == "smtp" { + let host = row.smtp_host.unwrap_or_default().trim().to_string(); + if !host.is_empty() { + return Some(SmtpConfig { + enabled: row.smtp_enabled, + host, + from: row.smtp_from.unwrap_or_else(|| "OpenCCB ".to_string()), + port: u16::try_from(row.smtp_port).unwrap_or(587), + starttls: row.smtp_starttls, + username: row.smtp_username.filter(|v| !v.trim().is_empty()), + password: row.smtp_password.filter(|v| !v.trim().is_empty()), + }); + } + } + } + + // Fallback a variables de entorno + let host = env::var("SMTP_HOST").ok().filter(|v| !v.trim().is_empty())?; + let enabled = env::var("SMTP_ENABLED") + .map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + let from = env::var("SMTP_FROM") + .unwrap_or_else(|_| "OpenCCB ".to_string()); + let port = env::var("SMTP_PORT") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(587); + let starttls = env::var("SMTP_STARTTLS") + .map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + + Some(SmtpConfig { + enabled, + host, + from, + port, + starttls, + username: env::var("SMTP_USERNAME").ok().filter(|v| !v.trim().is_empty()), + password: env::var("SMTP_PASSWORD").ok().filter(|v| !v.trim().is_empty()), + }) +} + +pub fn build_mailer(config: &SmtpConfig) -> Result, String> { + let mut builder = if config.starttls { + AsyncSmtpTransport::::relay(&config.host) + .map_err(|e| format!("SMTP relay error: {}", e))? + .port(config.port) + } else { + AsyncSmtpTransport::::builder_dangerous(&config.host).port(config.port) + }; + + if let (Some(user), Some(pass)) = (&config.username, &config.password) { + builder = builder.credentials(Credentials::new(user.trim().to_string(), pass.trim().to_string())); + } + + Ok(builder.build()) +} + +pub async fn send_email( + config: &SmtpConfig, + to_email: &str, + to_name: &str, + subject: &str, + body_html: &str, +) -> Result<(), String> { + if !config.enabled { + tracing::debug!("SMTP deshabilitado — email no enviado a {}", to_email); + return Ok(()); + } + + let from: Mailbox = config + .from + .parse() + .map_err(|e| format!("From inválido: {}", e))?; + + let to_str = if to_name.is_empty() { + to_email.to_string() + } else { + format!("{} <{}>", to_name, to_email) + }; + let to: Mailbox = to_str.parse().map_err(|e| format!("To inválido: {}", e))?; + + let email = Message::builder() + .from(from) + .to(to) + .subject(subject) + .header(lettre::message::header::ContentType::TEXT_HTML) + .body(body_html.to_string()) + .map_err(|e| format!("Error construyendo email: {}", e))?; + + let mailer = build_mailer(config)?; + mailer.send(email).await.map_err(|e| format!("Error enviando email: {}", e))?; + Ok(()) +} + +// ─── Password Reset ──────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct ForgotPasswordPayload { + pub email: String, +} + +#[derive(Deserialize)] +pub struct ResetPasswordPayload { + pub token: String, + pub new_password: String, +} + +#[derive(Serialize)] +pub struct MessageResponse { + pub message: String, +} + +#[derive(sqlx::FromRow)] +struct UserForReset { + id: Uuid, + email: String, + full_name: Option, + organization_id: Uuid, +} + +pub async fn forgot_password( + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let email = payload.email.trim().to_lowercase(); + + // Buscar usuario (respuesta genérica para no revelar si existe) + let user = sqlx::query_as::<_, UserForReset>( + "SELECT id, email, full_name, organization_id FROM users WHERE LOWER(email) = $1", + ) + .bind(&email) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let Some(user) = user else { + // Respuesta genérica — no revelar si el email existe + return Ok(Json(MessageResponse { + message: "Si el correo existe, recibirás un enlace para restablecer tu contraseña." + .to_string(), + })); + }; + + // Generar token seguro de 48 caracteres + let token: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(48) + .map(char::from) + .collect(); + + // Invalidar tokens anteriores del mismo usuario + sqlx::query("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL") + .bind(user.id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Insertar nuevo token (expira en 1 hora) + sqlx::query( + "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, NOW() + INTERVAL '1 hour')", + ) + .bind(user.id) + .bind(&token) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Intentar enviar email (fire-and-forget en caso de error SMTP) + let base_url = env::var("EXPERIENCE_URL").unwrap_or_else(|_| "https://openccb.local".to_string()); + let reset_url = format!("{}/reset-password?token={}", base_url, token); + let full_name = user.full_name.as_deref().unwrap_or("Estudiante"); + + let body = format!( + r#" + + + +

Restablecer contraseña

+

Hola {full_name},

+

Recibimos una solicitud para restablecer la contraseña de tu cuenta.

+

+ + Restablecer contraseña + +

+

Este enlace expira en 1 hora.

+

Si no solicitaste esto, ignora este mensaje.

+
+

OpenCCB — Plataforma de Aprendizaje

+ +"#, + full_name = full_name, + reset_url = reset_url + ); + + if let Some(smtp) = load_smtp_config(&pool, user.organization_id).await { + if let Err(e) = send_email(&smtp, &user.email, full_name, "Restablecer contraseña", &body).await { + tracing::warn!("No se pudo enviar email de reset a {}: {}", user.email, e); + } + } + + Ok(Json(MessageResponse { + message: "Si el correo existe, recibirás un enlace para restablecer tu contraseña.".to_string(), + })) +} + +pub async fn reset_password( + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let token = payload.token.trim().to_string(); + + if token.is_empty() || payload.new_password.len() < 8 { + return Err(( + StatusCode::BAD_REQUEST, + "Token o contraseña inválidos (mínimo 8 caracteres)".to_string(), + )); + } + + // Buscar token válido + let row = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT user_id FROM password_reset_tokens + WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()"#, + ) + .bind(&token) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let Some((user_id,)) = row else { + return Err(( + StatusCode::BAD_REQUEST, + "Token inválido o expirado".to_string(), + )); + }; + + // Hashear nueva contraseña + let password_hash = hash(&payload.new_password, DEFAULT_COST).map_err(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "Error al procesar contraseña".to_string()) + })?; + + // Actualizar contraseña + sqlx::query("UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2") + .bind(&password_hash) + .bind(user_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Marcar token como usado + sqlx::query("UPDATE password_reset_tokens SET used_at = NOW() WHERE token = $1") + .bind(&token) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(MessageResponse { + message: "Contraseña actualizada correctamente. Ya puedes iniciar sesión.".to_string(), + })) +} + +// ─── Emails transaccionales ──────────────────────────────────────────────────── + +/// Envía email de bienvenida al inscribirse en un curso. +/// Fire-and-forget (los errores se loguean, no bloquean la respuesta). +pub async fn send_enrollment_email( + pool: &PgPool, + organization_id: Uuid, + user_email: &str, + user_name: &str, + course_title: &str, +) { + let Some(smtp) = load_smtp_config(pool, organization_id).await else { + return; + }; + + let base_url = env::var("EXPERIENCE_URL").unwrap_or_else(|_| "https://openccb.local".to_string()); + let body = format!( + r#" + + + +

¡Te has inscrito exitosamente!

+

Hola {user_name},

+

Tu inscripción en {course_title} ha sido confirmada.

+

+ + Ir a mis cursos + +

+

¡Mucho éxito en tu aprendizaje!

+
+

OpenCCB — Plataforma de Aprendizaje

+ +"#, + user_name = user_name, + course_title = course_title, + base_url = base_url + ); + + if let Err(e) = send_email(&smtp, user_email, user_name, &format!("Inscripción confirmada: {}", course_title), &body).await { + tracing::warn!("Email de inscripción no enviado a {}: {}", user_email, e); + } +} + +/// Envía email de felicitación al completar un curso. +pub async fn send_completion_email( + pool: &PgPool, + organization_id: Uuid, + user_email: &str, + user_name: &str, + course_title: &str, +) { + let Some(smtp) = load_smtp_config(pool, organization_id).await else { + return; + }; + + let base_url = env::var("EXPERIENCE_URL").unwrap_or_else(|_| "https://openccb.local".to_string()); + let body = format!( + r#" + + + +

🎉 ¡Felicitaciones, completaste el curso!

+

Hola {user_name},

+

Has completado exitosamente {course_title}.

+

Puedes descargar tu certificado desde la plataforma.

+

+ + Ver mis certificados + +

+

Sigue aprendiendo — ¡el conocimiento no tiene límites!

+
+

OpenCCB — Plataforma de Aprendizaje

+ +"#, + user_name = user_name, + course_title = course_title, + base_url = base_url + ); + + if let Err(e) = send_email(&smtp, user_email, user_name, &format!("¡Completaste: {}!", course_title), &body).await { + tracing::warn!("Email de completitud no enviado a {}: {}", user_email, e); + } +} diff --git a/services/lms-service/src/handlers_scorm.rs b/services/lms-service/src/handlers_scorm.rs new file mode 100644 index 0000000..de1bbf7 --- /dev/null +++ b/services/lms-service/src/handlers_scorm.rs @@ -0,0 +1,83 @@ +use axum::{ + Json, + extract::State, + http::StatusCode, +}; +use common::auth::Claims; +use common::middleware::Org; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct XapiStatementPayload { + pub course_id: Uuid, + pub lesson_id: Uuid, + pub verb: String, + pub object_id: String, + pub score: Option, + pub progress: Option, + pub completed: Option, + pub raw_statement: Option, +} + +#[derive(Debug, Serialize)] +pub struct XapiStatementResponse { + pub id: Uuid, + pub message: String, +} + +pub async fn track_xapi_statement( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if payload.verb.trim().is_empty() || payload.object_id.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "verb y object_id son requeridos".to_string(), + )); + } + + let statement_id = Uuid::new_v4(); + + sqlx::query( + r#" + INSERT INTO xapi_statements ( + id, + organization_id, + user_id, + course_id, + lesson_id, + verb, + object_id, + score, + progress, + completed, + raw_statement + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + "#, + ) + .bind(statement_id) + .bind(org_ctx.id) + .bind(claims.sub) + .bind(payload.course_id) + .bind(payload.lesson_id) + .bind(payload.verb.trim()) + .bind(payload.object_id.trim()) + .bind(payload.score) + .bind(payload.progress) + .bind(payload.completed) + .bind(payload.raw_statement) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(XapiStatementResponse { + id: statement_id, + message: "xAPI statement registrado".to_string(), + })) +} diff --git a/services/lms-service/src/handlers_search.rs b/services/lms-service/src/handlers_search.rs new file mode 100644 index 0000000..a692780 --- /dev/null +++ b/services/lms-service/src/handlers_search.rs @@ -0,0 +1,204 @@ +use axum::{Json, extract::{Query, State}, http::StatusCode}; +use common::auth::Claims; +use common::middleware::Org; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct GlobalSearchQuery { + pub q: String, + pub limit: Option, +} + +#[derive(Serialize)] +pub struct SearchResultItem { + pub id: Uuid, + pub kind: String, // "course", "lesson", "discussion", "announcement" + pub title: String, + pub snippet: Option, + pub url: String, // ruta relativa para el frontend + pub course_id: Option, + pub course_title: Option, +} + +#[derive(Serialize)] +pub struct GlobalSearchResponse { + pub query: String, + pub total: usize, + pub results: Vec, +} + +pub async fn global_search( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Query(params): Query, +) -> Result, (StatusCode, String)> { + let q = params.q.trim().to_string(); + if q.is_empty() { + return Ok(Json(GlobalSearchResponse { + query: q, + total: 0, + results: vec![], + })); + } + + let limit = params.limit.unwrap_or(20).min(50); + let org_id = org_ctx.id; + let like_q = format!("%{}%", q.to_lowercase()); + + let mut results: Vec = Vec::new(); + + // ── 1. Cursos ────────────────────────────────────────────────────────────── + let courses = sqlx::query_as::<_, (Uuid, String, Option)>( + r#" + SELECT id, title, description + FROM courses + WHERE organization_id = $1 + AND (LOWER(title) LIKE $2 OR LOWER(COALESCE(description,'')) LIKE $2) + ORDER BY title + LIMIT $3 + "#, + ) + .bind(org_id) + .bind(&like_q) + .bind(limit) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + for (id, title, description) in courses { + let snippet = description.map(|d| truncate(&d, 150)); + results.push(SearchResultItem { + id, + kind: "course".to_string(), + title: title.clone(), + snippet, + url: format!("/courses/{}", id), + course_id: Some(id), + course_title: Some(title), + }); + } + + // ── 2. Lecciones ─────────────────────────────────────────────────────────── + let lessons = sqlx::query_as::<_, (Uuid, String, Option, Uuid, String)>( + r#" + SELECT l.id, l.title, l.summary, c.id AS course_id, c.title AS course_title + FROM lessons l + JOIN modules m ON l.module_id = m.id + JOIN courses c ON m.course_id = c.id + WHERE l.organization_id = $1 + AND (LOWER(l.title) LIKE $2 OR LOWER(COALESCE(l.summary,'')) LIKE $2) + ORDER BY l.title + LIMIT $3 + "#, + ) + .bind(org_id) + .bind(&like_q) + .bind(limit) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + for (id, title, summary, course_id, course_title) in lessons { + let snippet = summary.map(|s| truncate(&s, 150)); + results.push(SearchResultItem { + id, + kind: "lesson".to_string(), + title, + snippet, + url: format!("/courses/{}/lessons/{}", course_id, id), + course_id: Some(course_id), + course_title: Some(course_title), + }); + } + + // ── 3. Hilos de discusión ────────────────────────────────────────────────── + let threads = sqlx::query_as::<_, (Uuid, String, Option, Uuid, String)>( + r#" + SELECT t.id, t.title, t.content AS body, c.id AS course_id, c.title AS course_title + FROM discussion_threads t + JOIN courses c ON t.course_id = c.id + WHERE t.organization_id = $1 + AND (LOWER(t.title) LIKE $2 OR LOWER(COALESCE(t.content,'')) LIKE $2) + ORDER BY t.created_at DESC + LIMIT $3 + "#, + ) + .bind(org_id) + .bind(&like_q) + .bind(limit) + .fetch_all(&pool) + .await + .unwrap_or_default(); // silenciamos si la tabla no tiene columna "body" + + for (id, title, body, course_id, course_title) in threads { + let snippet = body.map(|b| truncate(&b, 150)); + results.push(SearchResultItem { + id, + kind: "discussion".to_string(), + title, + snippet, + url: format!("/courses/{}/discussions/{}", course_id, id), + course_id: Some(course_id), + course_title: Some(course_title), + }); + } + + // ── 4. Anuncios ──────────────────────────────────────────────────────────── + let announcements = sqlx::query_as::<_, (Uuid, String, String, Uuid, String)>( + r#" + SELECT a.id, a.title, a.content, c.id AS course_id, c.title AS course_title + FROM course_announcements a + JOIN courses c ON a.course_id = c.id + WHERE a.organization_id = $1 + AND (LOWER(a.title) LIKE $2 OR LOWER(a.content) LIKE $2) + ORDER BY a.created_at DESC + LIMIT $3 + "#, + ) + .bind(org_id) + .bind(&like_q) + .bind(limit) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + for (id, title, body, course_id, course_title) in announcements { + results.push(SearchResultItem { + id, + kind: "announcement".to_string(), + title, + snippet: Some(truncate(&body, 150)), + url: format!("/courses/{}", course_id), + course_id: Some(course_id), + course_title: Some(course_title), + }); + } + + // Ordenar: primero cursos, luego agrupados por relevancia textual básica + results.sort_by(|a, b| { + let a_exact = a.title.to_lowercase().contains(&q.to_lowercase()) as u8; + let b_exact = b.title.to_lowercase().contains(&q.to_lowercase()) as u8; + b_exact.cmp(&a_exact).then(a.kind.cmp(&b.kind)) + }); + + results.truncate(limit as usize); + let total = results.len(); + + Ok(Json(GlobalSearchResponse { + query: q, + total, + results, + })) +} + +fn truncate(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let t: String = s.chars().take(max_chars).collect(); + format!("{}...", t.trim_end()) + } +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 083987b..8816a0c 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -1,6 +1,9 @@ mod db_util; mod handlers; mod handlers_announcements; +mod handlers_email; +mod handlers_scorm; +mod handlers_search; mod handlers_cohorts; mod handlers_discussions; mod handlers_notes; @@ -386,6 +389,10 @@ async fn main() { .route("/ingest", post(handlers::ingest_course)) .route("/auth/register", post(handlers::register)) .route("/auth/login", post(handlers::login)) + .route("/auth/forgot-password", post(handlers_email::forgot_password)) + .route("/auth/reset-password", post(handlers_email::reset_password)) + .route("/xapi/statements", post(handlers_scorm::track_xapi_statement)) + .route("/search", get(handlers_search::global_search)) .route( "/payments/mercadopago/webhook", post(handlers_payments::mercadopago_webhook), diff --git a/web/experience/src/app/auth/forgot-password/page.tsx b/web/experience/src/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..fccf061 --- /dev/null +++ b/web/experience/src/app/auth/forgot-password/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import React, { useState } from "react"; +import { lmsApi } from "@/lib/api"; +import { GraduationCap, Mail, ArrowLeft, CheckCircle } from "lucide-react"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + await lmsApi.forgotPassword(email.trim().toLowerCase()); + setSent(true); + } catch { + setError("Ocurrió un error. Intenta nuevamente."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+

Recuperar contraseña

+

+ Ingresa tu correo y te enviaremos un enlace de restablecimiento. +

+
+ +
+ {sent ? ( +
+ +

+ Si el correo existe en nuestro sistema, recibirás un enlace para restablecer tu contraseña. +

+

Revisa tu bandeja de entrada o spam.

+
+ ) : ( +
+
+ +
+ + setEmail(e.target.value)} + className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" + placeholder="nombre@correo.com" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ )} + + +
+
+
+ ); +} diff --git a/web/experience/src/app/auth/login/page.tsx b/web/experience/src/app/auth/login/page.tsx index 2c1ba87..2ec9280 100644 --- a/web/experience/src/app/auth/login/page.tsx +++ b/web/experience/src/app/auth/login/page.tsx @@ -109,6 +109,13 @@ export default function ExperienceLoginPage() { setPassword(e.target.value)} className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="••••••••" /> + {isLogin && ( + + )} {error &&
{error}
} diff --git a/web/experience/src/app/auth/reset-password/page.tsx b/web/experience/src/app/auth/reset-password/page.tsx new file mode 100644 index 0000000..dbc7767 --- /dev/null +++ b/web/experience/src/app/auth/reset-password/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React, { useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { lmsApi } from "@/lib/api"; +import { GraduationCap, Lock, CheckCircle } from "lucide-react"; + +function ResetPasswordForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get("token") ?? ""; + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + if (password.length < 8) { + setError("La contraseña debe tener al menos 8 caracteres."); + return; + } + if (password !== confirm) { + setError("Las contraseñas no coinciden."); + return; + } + if (!token) { + setError("Token inválido o ausente."); + return; + } + setLoading(true); + try { + await lmsApi.resetPassword(token, password); + setSuccess(true); + setTimeout(() => router.push("/auth/login"), 3000); + } catch { + setError("El enlace es inválido o ha expirado. Solicita uno nuevo."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+

Nueva contraseña

+

+ Elige una contraseña segura de al menos 8 caracteres. +

+
+ +
+ {success ? ( +
+ +

+ ¡Contraseña actualizada correctamente! +

+

Redirigiendo al inicio de sesión...

+
+ ) : ( +
+
+ +
+ + setPassword(e.target.value)} + className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" + placeholder="••••••••" + /> +
+
+ +
+ +
+ + setConfirm(e.target.value)} + className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" + placeholder="••••••••" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ )} +
+
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index cd801f1..93f95dd 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -21,6 +21,7 @@ import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer"; import RolePlayingPlayer from "@/components/blocks/RolePlayingPlayer"; import PeerReviewPlayer from "@/components/blocks/PeerReviewPlayer"; import MermaidViewer from "@/components/blocks/MermaidViewer"; +import ScormPlayer from "@/components/blocks/ScormPlayer"; import InteractiveTranscript from "@/components/InteractiveTranscript"; import AITutor from "@/components/AITutor"; import LessonLockedView from "@/components/LessonLockedView"; @@ -507,6 +508,15 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les ); case 'mermaid': return ; + case 'scorm': + return ( + + ); default: return
Tipo de Bloque Desconocido: {block.type}
; } @@ -521,17 +531,26 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les ) : (lesson.content_url) ? (
- + {lesson.content_type === 'scorm' || lesson.content_type === 'xapi' ? ( + + ) : ( + + )}
) : (
diff --git a/web/experience/src/components/AppHeader.tsx b/web/experience/src/components/AppHeader.tsx index 96e9e23..32e6061 100644 --- a/web/experience/src/components/AppHeader.tsx +++ b/web/experience/src/components/AppHeader.tsx @@ -5,20 +5,74 @@ import Image from "next/image"; import { useBranding } from "@/context/BrandingContext"; import { useAuth } from "@/context/AuthContext"; import { useTranslation } from "@/context/I18nContext"; -import { LogOut, Globe, Menu, X, Sun, Moon } from "lucide-react"; +import { LogOut, Globe, Menu, X, Sun, Moon, Search } from "lucide-react"; import NotificationCenter from "./NotificationCenter"; -import { useState } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { useTheme } from "@/context/ThemeContext"; +import { useRouter } from "next/navigation"; -import { getImageUrl } from "@/lib/api"; +import { getImageUrl, lmsApi } from "@/lib/api"; export default function AppHeader() { const { t, language, setLanguage } = useTranslation(); const { branding } = useBranding(); const { user, logout } = useAuth(); const { theme, toggleTheme } = useTheme(); + const router = useRouter(); const [isMenuOpen, setIsMenuOpen] = useState(false); + // ── Búsqueda global ── + const [searchQuery, setSearchQuery] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); + const [searchResults, setSearchResults] = useState>([]); + const [searchLoading, setSearchLoading] = useState(false); + const searchRef = useRef(null); + const searchTimer = useRef | null>(null); + + const runSearch = useCallback(async (q: string) => { + if (!q.trim() || q.length < 2) { setSearchResults([]); return; } + setSearchLoading(true); + try { + const res = await lmsApi.globalSearch(q); + setSearchResults(res.results ?? []); + } catch { + setSearchResults([]); + } finally { + setSearchLoading(false); + } + }, []); + + useEffect(() => { + if (searchTimer.current) clearTimeout(searchTimer.current); + searchTimer.current = setTimeout(() => runSearch(searchQuery), 350); + return () => { if (searchTimer.current) clearTimeout(searchTimer.current); }; + }, [searchQuery, runSearch]); + + // Cerrar al hacer click fuera + useEffect(() => { + function handleClick(e: MouseEvent) { + if (searchRef.current && !searchRef.current.contains(e.target as Node)) { + setSearchOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + function handleSearchSelect(url: string) { + setSearchOpen(false); + setSearchQuery(""); + setSearchResults([]); + router.push(url); + } + + const kindLabel: Record = { + course: "Curso", + lesson: "Lección", + discussion: "Foro", + announcement: "Anuncio", + }; + // Use platform_name if available, otherwise name, otherwise default const platformName = branding?.platform_name || branding?.name || 'Academia'; @@ -46,6 +100,56 @@ export default function AppHeader() {
+ {/* Búsqueda global */} + {user && ( +
+
+ + { setSearchQuery(e.target.value); setSearchOpen(true); }} + onFocus={() => setSearchOpen(true)} + placeholder="Buscar cursos, lecciones..." + className="w-56 lg:w-72 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl py-2 pl-9 pr-3 text-sm text-slate-700 dark:text-white placeholder-gray-400 focus:outline-none focus:border-blue-500/50 transition-all" + /> +
+ {searchOpen && searchQuery.length >= 2 && ( +
+ {searchLoading ? ( +
Buscando...
+ ) : searchResults.length === 0 ? ( +
Sin resultados para "{searchQuery}"
+ ) : ( +
    + {searchResults.map(r => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ )} + {user && (