diff --git a/.env.example b/.env.example index d4d3a62..e84dd32 100644 --- a/.env.example +++ b/.env.example @@ -105,3 +105,6 @@ NEXT_PUBLIC_STUDIO_DOMAIN=studio.norteamericano.com NEXT_PUBLIC_LEARNING_DOMAIN=learning.norteamericano.com NEXT_PUBLIC_CMS_API_URL=https://studio.norteamericano.com NEXT_PUBLIC_LMS_API_URL=https://learning.norteamericano.com +ZIP_IMPORT_MAX_UPLOAD_BYTES=4294967296 +ZIP_IMPORT_MAX_ENTRY_BYTES=1073741824 +ZIP_IMPORT_MAX_TOTAL_BYTES=17179869184 diff --git a/roadmap.md b/roadmap.md index 3c72b0b..244b138 100644 --- a/roadmap.md +++ b/roadmap.md @@ -74,10 +74,10 @@ --- ## Fase 22: Estabilidad y Funcionalidades Pendientes 🛠️ (En Ejecución) -- [ ] **Generación de Certificados Premium**: Mejorar UI de configuración de templates en Studio. -- [ ] **Tracking de Progreso Atómico**: Reemplazar hardcodes por cálculo real de completitud. -- [ ] **Notificaciones de Foros**: Implementar despacho de alertas vía SMTP. -- [ ] **Importación Masiva (Excel)**: Finalizar soporte para Question Bank. +- [x] **Generación de Certificados Premium**: Mejorar UI de configuración de templates en Studio. +- [x] **Tracking de Progreso Atómico**: Reemplazar hardcodes por cálculo real de completitud. +- [x] **Notificaciones de Foros**: Implementar despacho de alertas vía SMTP. +- [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. diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 00490d5..03e9230 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -22,6 +22,7 @@ use common::models::{ LessonDependency, }; use crate::external_db::MySqlPool; +use crate::progress_tracking::{CourseCompletionMetrics, calculate_course_completion}; use serde_json::json; use base64::Engine; use tokio::time::{Duration, timeout}; @@ -1340,6 +1341,7 @@ pub async fn get_user_enrollments( WHERE ug.user_id = e.user_id AND ug.course_id = e.course_id AND l.is_graded = true + AND (ug.score * 100.0) >= COALESCE(l.passing_percentage::double precision, 60.0) ) graded ON TRUE LEFT JOIN LATERAL ( SELECT COUNT(DISTINCT li.lesson_id)::int AS ungraded_done @@ -1530,31 +1532,26 @@ pub async fn submit_lesson_score( .await; // Lógica de detección de finalización de curso - let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE module_id IN (SELECT id FROM modules WHERE course_id = $1)") - .bind(payload.course_id) - .fetch_one(&pool).await.unwrap_or(0); - - let completed_lessons: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM user_grades WHERE organization_id = $1 AND user_id = $2 AND course_id = $3", - ) - .bind(org_ctx.id) - .bind(payload.user_id) - .bind(payload.course_id) - .fetch_one(&pool) - .await - .unwrap_or(0); - - if total_lessons > 0 && completed_lessons >= total_lessons { - webhook_service - .dispatch( - org_ctx.id, - "course.completed", - &serde_json::json!({ - "user_id": payload.user_id, - "course_id": payload.course_id - }), - ) - .await; + if let Ok(course_completion) = calculate_course_completion(&pool, payload.user_id, payload.course_id).await { + if course_completion.completed { + webhook_service + .dispatch( + org_ctx.id, + "course.completed", + &serde_json::json!({ + "user_id": payload.user_id, + "course_id": payload.course_id, + "progress_percentage": course_completion.progress_percentage + }), + ) + .await; + } + } else { + tracing::warn!( + "No se pudo calcular la completitud real del curso {} para el usuario {}", + payload.course_id, + payload.user_id + ); } Ok(Json(grade)) @@ -1799,49 +1796,25 @@ pub async fn get_student_progress_stats( ) -> Result, (StatusCode, String)> { let user_id = claims.sub; - // 1. Lecciones totales - let total_lessons: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)" - ) - .bind(org_ctx.id) - .bind(course_id) - .fetch_one(&pool) - .await - .unwrap_or(0); + let course_completion = calculate_course_completion(&pool, user_id, course_id) + .await + .unwrap_or_else(|e| { + tracing::warn!( + "No se pudo calcular el progreso del curso {} para el usuario {}: {}", + course_id, + user_id, + e + ); + CourseCompletionMetrics { + total_lessons: 0, + completed_lessons: 0, + progress_percentage: 0.0, + completed: false, + } + }); - // 2. Lecciones completadas - let completed_lessons: i64 = sqlx::query_scalar( - r#" - SELECT COUNT(*) - FROM lessons l - JOIN modules m ON m.id = l.module_id - WHERE m.course_id = $2 - AND l.organization_id = $3 - AND ( - (l.is_graded = true AND EXISTS ( - SELECT 1 - FROM user_grades ug - WHERE ug.user_id = $1 - AND ug.course_id = $2 - AND ug.lesson_id = l.id - )) - OR - (l.is_graded = false AND EXISTS ( - SELECT 1 - FROM lesson_interactions li - WHERE li.user_id = $1 - AND li.lesson_id = l.id - AND li.event_type = 'complete' - )) - ) - "#, - ) - .bind(user_id) - .bind(course_id) - .bind(org_ctx.id) - .fetch_one(&pool) - .await - .unwrap_or(0); + let total_lessons = course_completion.total_lessons; + let completed_lessons = course_completion.completed_lessons; // 3. Progreso diario (Últimos 30 días) let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>( @@ -1888,11 +1861,7 @@ pub async fn get_student_progress_stats( None }; - let progress_percentage = if total_lessons > 0 { - (completed_lessons as f32 / total_lessons as f32) * 100.0 - } else { - 0.0 - }; + let progress_percentage = course_completion.progress_percentage as f32; Ok(Json(common::models::ProgressStats { total_lessons, diff --git a/services/lms-service/src/handlers_announcements.rs b/services/lms-service/src/handlers_announcements.rs index df81725..6d4b9ce 100644 --- a/services/lms-service/src/handlers_announcements.rs +++ b/services/lms-service/src/handlers_announcements.rs @@ -27,6 +27,55 @@ pub struct UpdateAnnouncementPayload { pub is_pinned: Option, } +fn is_instructor_or_admin(role: &str) -> bool { + role == "instructor" || role == "admin" +} + +fn normalize_lms_role(role: &str) -> &'static str { + match role { + "admin" => "admin", + "instructor" => "instructor", + _ => "student", + } +} + +async fn ensure_announcement_author_exists( + pool: &PgPool, + user_id: Uuid, + organization_id: Uuid, + role: &str, +) -> Result<(), (StatusCode, String)> { + let exists = sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)") + .bind(user_id) + .fetch_one(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if exists { + return Ok(()); + } + + let synthetic_email = format!("user-{}@openccb.local", user_id); + let synthetic_name = format!("Usuario {}", &user_id.to_string()[..8]); + let normalized_role = normalize_lms_role(role); + + sqlx::query( + "INSERT INTO users (id, email, password_hash, full_name, organization_id, role) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(user_id) + .bind(&synthetic_email) + .bind("external-auth") + .bind(&synthetic_name) + .bind(organization_id) + .bind(normalized_role) + .execute(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("No se pudo provisionar usuario LMS: {}", e)))?; + + Ok(()) +} + // ========== MANEJADORES ========== pub async fn list_announcements( @@ -75,20 +124,15 @@ pub async fn create_announcement( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // 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, "Usuario no encontrado".to_string()))?; - - if user.0 != "instructor" && user.0 != "admin" { + if !is_instructor_or_admin(&claims.role) { return Err(( StatusCode::FORBIDDEN, "Solo los instructores pueden crear anuncios".to_string(), )); } + ensure_announcement_author_exists(&pool, claims.sub, org_ctx.id, &claims.role).await?; + let mut tx = pool .begin() .await @@ -198,14 +242,7 @@ pub async fn update_announcement( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - // 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, "Usuario no encontrado".to_string()))?; - - if user.0 != "instructor" && user.0 != "admin" { + if !is_instructor_or_admin(&claims.role) { return Err(( StatusCode::FORBIDDEN, "Solo los instructores pueden actualizar anuncios".to_string(), @@ -250,14 +287,7 @@ pub async fn delete_announcement( Path(announcement_id): Path, State(pool): State, ) -> Result { - // 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, "Usuario no encontrado".to_string()))?; - - if user.0 != "instructor" && user.0 != "admin" { + if !is_instructor_or_admin(&claims.role) { return Err(( StatusCode::FORBIDDEN, "Solo los instructores pueden eliminar anuncios".to_string(), diff --git a/services/lms-service/src/handlers_certificates.rs b/services/lms-service/src/handlers_certificates.rs index 810f436..8717c85 100644 --- a/services/lms-service/src/handlers_certificates.rs +++ b/services/lms-service/src/handlers_certificates.rs @@ -3,10 +3,12 @@ use axum::{ extract::{Path, State}, http::StatusCode, }; +use crate::progress_tracking::{CourseCompletionMetrics, calculate_course_completion}; use common::auth::Claims; use serde::{Deserialize, Serialize}; use sha2::{Sha256, Digest}; use sqlx::{PgPool, Row}; +use std::env; use uuid::Uuid; // Macro para usar json!() sin importar serde_json @@ -50,6 +52,45 @@ struct CertificateRecord { metadata: serde_json::Value, } +fn resolve_certificate_asset_url(path: &str) -> String { + if path.starts_with("http://") || path.starts_with("https://") { + return path.to_string(); + } + + let cms_base_url = env::var("NEXT_PUBLIC_CMS_API_URL") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "/cms-api".to_string()); + let cms_base_url = cms_base_url.trim_end_matches('/'); + + if let Some(without_scheme) = path.strip_prefix("s3://") { + if let Some((bucket, key)) = without_scheme.split_once('/') { + return format!( + "{}/api/assets/s3-proxy/{}/{}", + cms_base_url, + bucket, + key + ); + } + } + + let normalized = if let Some(stripped) = path.strip_prefix("uploads/") { + format!("/assets/{}", stripped) + } else if let Some(stripped) = path.strip_prefix("/uploads/") { + format!("/assets/{}", stripped) + } else if path.starts_with("/assets/") { + path.to_string() + } else if path.starts_with("assets/") { + format!("/{}", path) + } else if path.starts_with('/') { + path.to_string() + } else { + format!("/{}", path) + }; + + format!("{}{}", cms_base_url, normalized) +} + // ============= Handlers ============= /// GET /courses/{id}/certificate @@ -170,7 +211,7 @@ pub async fn get_certificate( StatusCode::FORBIDDEN, Json(json!({ "error": "No has completado este curso aún", - "progress": course_completion.progress, + "progress": course_completion.progress_percentage, "required": 100.0 })), )); @@ -257,7 +298,7 @@ pub async fn issue_certificate( StatusCode::FORBIDDEN, Json(json!({ "error": "No has completado este curso aún", - "progress": course_completion.progress, + "progress": course_completion.progress_percentage, "required": 100.0 })), )); @@ -326,98 +367,19 @@ pub async fn verify_certificate( // ============= Funciones Internas ============= -struct CourseCompletion { - completed: bool, - progress: f64, -} - async fn check_course_completion( user_id: Uuid, course_id: Uuid, pool: &PgPool, -) -> Result)> { - // Obtener todas las lecciones graduables del curso - let gradable_lessons = sqlx::query( - r#" - SELECT l.id, l.is_graded, l.passing_percentage - FROM lessons l - JOIN modules m ON m.id = l.module_id - WHERE m.course_id = $1 AND l.is_graded = true - "# - ) - .bind(course_id) - .fetch_all(pool) +) -> Result)> { + calculate_course_completion(pool, user_id, course_id) .await .map_err(|e: sqlx::Error| { - tracing::error!("Error al obtener lecciones graduables: {}", e); + tracing::error!("Error al calcular completitud del curso: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) - })?; - - if gradable_lessons.is_empty() { - // Si no hay lecciones graduables, considerar completado si está inscrito - let enrolled = sqlx::query( - "SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2" - ) - .bind(user_id) - .bind(course_id) - .fetch_optional(pool) - .await - .map_err(|e: sqlx::Error| { - tracing::error!("Error al verificar inscripción: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Error interno del servidor"})), - ) - })?; - - return Ok(CourseCompletion { - completed: enrolled.is_some(), - progress: if enrolled.is_some() { 100.0 } else { 0.0 }, - }); - } - - // Contar lecciones aprobadas - let total_lessons = gradable_lessons.len() as f64; - let mut passed_lessons = 0.0; - - for lesson in gradable_lessons { - let passing_pct = lesson.get::, _>("passing_percentage").unwrap_or(60) as f64; - let lesson_id: Uuid = lesson.get("id"); - - let best_score = sqlx::query( - r#" - SELECT MAX(score_percentage) as max_score - FROM grades - WHERE user_id = $1 AND lesson_id = $2 - "# - ) - .bind(user_id) - .bind(lesson_id) - .fetch_optional(pool) - .await - .map_err(|e: sqlx::Error| { - tracing::error!("Error al obtener calificaciones: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Error interno del servidor"})), - ) - })?; - - if let Some(row) = best_score { - if row.get::, _>("max_score").unwrap_or(0.0) >= passing_pct { - passed_lessons += 1.0; - } - } - } - - let progress = (passed_lessons / total_lessons) * 100.0; - - Ok(CourseCompletion { - completed: progress >= 100.0, - progress, }) } @@ -469,7 +431,7 @@ async fn issue_certificate_internal( let organization_id: Uuid = course_row.get("organization_id"); let org_data = sqlx::query( r#" - SELECT name, certificate_template + SELECT name, platform_name, logo_url, primary_color, secondary_color, certificate_template FROM organizations WHERE id = $1 "# @@ -492,7 +454,27 @@ async fn issue_certificate_internal( .or_else(|| org_data.as_ref().and_then(|o| o.get::, _>("certificate_template"))) .unwrap_or_else(|| get_default_certificate_template()); - let _org_name = org_data.as_ref().map(|o| o.get::("name")).unwrap_or_else(|| "OpenCCB".to_string()); + let organization_name = org_data + .as_ref() + .map(|o| o.get::("name")) + .unwrap_or_else(|| "OpenCCB".to_string()); + let platform_name = org_data + .as_ref() + .and_then(|o| o.get::, _>("platform_name")) + .unwrap_or_else(|| organization_name.clone()); + let logo_url = org_data + .as_ref() + .and_then(|o| o.get::, _>("logo_url")) + .map(|value| resolve_certificate_asset_url(&value)) + .unwrap_or_default(); + let primary_color = org_data + .as_ref() + .and_then(|o| o.get::, _>("primary_color")) + .unwrap_or_else(|| "#2563eb".to_string()); + let secondary_color = org_data + .as_ref() + .and_then(|o| o.get::, _>("secondary_color")) + .unwrap_or_else(|| "#7c3aed".to_string()); // Reemplazar variables en el template let now = chrono::Utc::now(); @@ -501,6 +483,11 @@ async fn issue_certificate_internal( .replace("{{course_title}}", &course_title) .replace("{{date}}", &now.format("%d/%m/%Y").to_string()) .replace("{{score}}", "Aprobado") + .replace("{{organization_name}}", &organization_name) + .replace("{{platform_name}}", &platform_name) + .replace("{{logo_url}}", &logo_url) + .replace("{{primary_color}}", &primary_color) + .replace("{{secondary_color}}", &secondary_color) .replace("{{verification_code}}", "VER-PLACEHOLDER"); // Generar código de verificación único @@ -519,16 +506,12 @@ async fn issue_certificate_internal( let certificate_hash = format!("{:x}", hasher.finalize()); // Obtener progreso final para metadata - let course_completion = check_course_completion(user_id, course_id, pool).await - .unwrap_or(CourseCompletion { - completed: true, - progress: 100.0, - }); + let course_completion = check_course_completion(user_id, course_id, pool).await?; // Insertar certificado en BD let metadata = serde_json::json!({ "completion_date": now.to_rfc3339(), - "final_score": course_completion.progress, + "final_score": course_completion.progress_percentage, "organization_id": organization_id.to_string(), }); diff --git a/services/lms-service/src/handlers_discussions.rs b/services/lms-service/src/handlers_discussions.rs index 8cd3f70..dc40260 100644 --- a/services/lms-service/src/handlers_discussions.rs +++ b/services/lms-service/src/handlers_discussions.rs @@ -57,6 +57,42 @@ fn parse_bool(value: &str) -> bool { normalized == "1" || normalized == "true" || normalized == "yes" } +fn build_learning_base_url() -> String { + let domain = env::var("NEXT_PUBLIC_LEARNING_DOMAIN") + .or_else(|_| env::var("LEARNING_DOMAIN")) + .unwrap_or_else(|_| "localhost:3003".to_string()) + .trim() + .trim_end_matches('/') + .to_string(); + + if domain.starts_with("http://") || domain.starts_with("https://") { + return domain; + } + + let scheme = if domain.contains("localhost") || domain.contains("127.0.0.1") { + "http" + } else { + "https" + }; + + format!("{}://{}", scheme, domain) +} + +fn build_discussion_thread_url(course_id: Uuid) -> String { + format!("{}/courses/{}#discussions", build_learning_base_url(), course_id) +} + +async fn load_organization_name(pool: &PgPool, organization_id: Uuid) -> String { + sqlx::query_scalar::<_, String>("SELECT name FROM organizations WHERE id = $1") + .bind(organization_id) + .fetch_optional(pool) + .await + .ok() + .flatten() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "OpenCCB".to_string()) +} + fn load_env_smtp_config() -> Result { let enabled = env::var("SMTP_ENABLED").map(|v| parse_bool(&v)).unwrap_or(false); let host = env::var("SMTP_HOST").map_err(|_| "SMTP_HOST no está configurado".to_string())?; @@ -429,6 +465,8 @@ pub async fn create_thread( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { + let organization_name = load_organization_name(&pool, org_ctx.id).await; + let thread_url = build_discussion_thread_url(course_id); let author_name: Option = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1") .bind(claims.sub) .fetch_optional(&pool) @@ -502,7 +540,7 @@ pub async fn create_thread( thread.content.clone() }) .bind("forum_thread") - .bind(format!("/courses/{}#discussions", course_id)) + .bind(thread_url.clone()) .execute(&pool) .await; } @@ -512,8 +550,8 @@ pub async fn create_thread( variables.insert("thread_title", thread.title.clone()); variables.insert("author_name", author_display.clone()); variables.insert("message_content", thread.content.clone()); - variables.insert("thread_url", format!("/courses/{}#discussions", course_id)); - variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org + variables.insert("thread_url", thread_url); + variables.insert("organization_name", organization_name); send_forum_email_notifications(&pool, org_ctx.id, &instructor_recipients, "forum_thread", &variables).await; @@ -687,6 +725,7 @@ pub async fn create_post( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { + let organization_name = load_organization_name(&pool, org_ctx.id).await; // Verificar si el hilo está bloqueado let thread = sqlx::query_as::<_, (bool, String, Uuid, Uuid)>( @@ -702,6 +741,8 @@ pub async fn create_post( return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string())); } + let thread_url = build_discussion_thread_url(thread.2); + let author_name: Option = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1") .bind(claims.sub) .fetch_optional(&pool) @@ -782,7 +823,7 @@ pub async fn create_post( post.content.clone() }) .bind("forum_reply") - .bind(format!("/courses/{}#discussions", thread.2)) + .bind(thread_url.clone()) .execute(&pool) .await; } @@ -792,8 +833,8 @@ pub async fn create_post( variables.insert("thread_title", thread.1.clone()); variables.insert("author_name", author_display.clone()); variables.insert("message_content", post.content.clone()); - variables.insert("thread_url", format!("/courses/{}#discussions", thread.2)); - variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org + variables.insert("thread_url", thread_url); + variables.insert("organization_name", organization_name); send_forum_email_notifications(&pool, org_ctx.id, &recipients, "forum_reply", &variables).await; diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 03c6188..083987b 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -9,6 +9,7 @@ mod handlers_peer_review; mod handlers_embeddings; mod handlers_faq; mod handlers_certificates; +mod progress_tracking; mod lti; mod jwks; mod predictive; diff --git a/services/lms-service/src/progress_tracking.rs b/services/lms-service/src/progress_tracking.rs new file mode 100644 index 0000000..abc0bd7 --- /dev/null +++ b/services/lms-service/src/progress_tracking.rs @@ -0,0 +1,73 @@ +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct CourseCompletionMetrics { + pub total_lessons: i64, + pub completed_lessons: i64, + pub progress_percentage: f64, + pub completed: bool, +} + +pub async fn calculate_course_completion( + pool: &PgPool, + user_id: Uuid, + course_id: Uuid, +) -> Result { + let row = sqlx::query( + r#" + SELECT + COALESCE(totals.total_lessons, 0)::bigint AS total_lessons, + LEAST( + COALESCE(totals.total_lessons, 0)::bigint, + COALESCE(graded.graded_done, 0)::bigint + COALESCE(ungraded.ungraded_done, 0)::bigint + ) AS completed_lessons + FROM (SELECT 1) seed + LEFT JOIN LATERAL ( + SELECT COUNT(*)::bigint AS total_lessons + FROM lessons l + JOIN modules m ON m.id = l.module_id + WHERE m.course_id = $2 + ) totals ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(DISTINCT ug.lesson_id)::bigint AS graded_done + FROM user_grades ug + JOIN lessons l ON l.id = ug.lesson_id + JOIN modules m ON m.id = l.module_id + WHERE ug.user_id = $1 + AND m.course_id = $2 + AND l.is_graded = true + AND (ug.score * 100.0) >= COALESCE(l.passing_percentage::double precision, 60.0) + ) graded ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(DISTINCT li.lesson_id)::bigint AS ungraded_done + FROM lesson_interactions li + JOIN lessons l ON l.id = li.lesson_id + JOIN modules m ON m.id = l.module_id + WHERE li.user_id = $1 + AND li.event_type = 'complete' + AND m.course_id = $2 + AND l.is_graded = false + ) ungraded ON TRUE + "#, + ) + .bind(user_id) + .bind(course_id) + .fetch_one(pool) + .await?; + + let total_lessons = row.get::("total_lessons"); + let completed_lessons = row.get::("completed_lessons"); + let progress_percentage = if total_lessons > 0 { + (completed_lessons as f64 / total_lessons as f64) * 100.0 + } else { + 0.0 + }; + + Ok(CourseCompletionMetrics { + total_lessons, + completed_lessons, + progress_percentage, + completed: total_lessons > 0 && completed_lessons >= total_lessons, + }) +} \ No newline at end of file diff --git a/web/studio/package-lock.json b/web/studio/package-lock.json index 61276ab..c23ef76 100644 --- a/web/studio/package-lock.json +++ b/web/studio/package-lock.json @@ -18,7 +18,8 @@ "react": "^18", "react-dom": "^18", "react-markdown": "^10.1.0", - "tailwind-merge": "^2.3.0" + "tailwind-merge": "^2.3.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/node": "^20", @@ -939,6 +940,7 @@ "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1013,6 +1015,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz", "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -1496,6 +1499,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1512,6 +1516,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1947,6 +1960,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2008,6 +2034,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", @@ -2078,6 +2105,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2136,6 +2172,18 @@ "layout-base": "^1.0.0" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2180,6 +2228,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -2589,6 +2638,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3104,6 +3154,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3266,6 +3317,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3684,6 +3736,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/framer-motion": { "version": "11.18.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", @@ -4704,6 +4765,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6196,6 +6258,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6348,6 +6411,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6492,6 +6556,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6503,6 +6568,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6589,7 +6655,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -7045,6 +7112,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -7521,6 +7600,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -7707,6 +7787,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8079,6 +8160,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8188,6 +8287,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/studio/package.json b/web/studio/package.json index a7be94c..657704b 100644 --- a/web/studio/package.json +++ b/web/studio/package.json @@ -23,7 +23,8 @@ "react": "^18", "react-dom": "^18", "react-markdown": "^10.1.0", - "tailwind-merge": "^2.3.0" + "tailwind-merge": "^2.3.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/node": "^20", diff --git a/web/studio/src/app/admin/materials/page.tsx b/web/studio/src/app/admin/materials/page.tsx index 36dc317..c390c88 100644 --- a/web/studio/src/app/admin/materials/page.tsx +++ b/web/studio/src/app/admin/materials/page.tsx @@ -6,7 +6,7 @@ import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle, Scissors } export default function AdminSharedMaterialsPage() { const [zipFile, setZipFile] = useState(null); - const [ingestRag, setIngestRag] = useState(false); + const [ingestRag, setIngestRag] = useState(true); const [englishLevel, setEnglishLevel] = useState(''); const [plans, setPlans] = useState([]); const [courses, setCourses] = useState([]); diff --git a/web/studio/src/app/courses/[id]/settings/page.tsx b/web/studio/src/app/courses/[id]/settings/page.tsx index 377208c..7139cc3 100644 --- a/web/studio/src/app/courses/[id]/settings/page.tsx +++ b/web/studio/src/app/courses/[id]/settings/page.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { useParams, useRouter } from "next/navigation"; -import { cmsApi, Course } from "@/lib/api"; +import { cmsApi, Course, Organization, getImageUrl } from "@/lib/api"; import { Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Download, Upload, Copy, Wand2 } from "lucide-react"; const DEFAULT_CERTIFICATE_TEMPLATE = ` @@ -55,12 +55,54 @@ const MINIMAL_CERTIFICATE_TEMPLATE = ` `; +const BRANDED_CERTIFICATE_TEMPLATE = ` +
+
+
+
+

{{platform_name}}

+

Premium Certificate

+

Issued by {{organization_name}}

+
+
+ {{organization_name}} +
+
+
+

Awarded to

+

{{student_name}}

+

for successfully completing

+

{{course_title}}

+
+
+
+

Completion

+

{{date}}

+
+
+

Final Result

+

{{score}}

+
+
+

Verification

+

{{verification_code}}

+
+
+
+
+`; + const TEMPLATE_VARIABLES = [ - "{{student_name}}", - "{{course_title}}", - "{{date}}", - "{{score}}", - "{{verification_code}}", + { token: "{{student_name}}", label: "Estudiante", description: "Nombre completo del estudiante." }, + { token: "{{course_title}}", label: "Curso", description: "Título del curso completado." }, + { token: "{{date}}", label: "Fecha", description: "Fecha de emisión del certificado." }, + { token: "{{score}}", label: "Resultado", description: "Resultado o puntaje final." }, + { token: "{{verification_code}}", label: "Verificación", description: "Código público de verificación." }, + { token: "{{organization_name}}", label: "Organización", description: "Nombre legal de la organización." }, + { token: "{{platform_name}}", label: "Plataforma", description: "Nombre comercial de la plataforma." }, + { token: "{{primary_color}}", label: "Color primario", description: "Color primario de branding." }, + { token: "{{secondary_color}}", label: "Color secundario", description: "Color secundario de branding." }, + { token: "{{logo_url}}", label: "Logo", description: "URL pública del logo organizacional." }, ]; import CourseEditorLayout from "@/components/CourseEditorLayout"; @@ -71,6 +113,7 @@ export default function CourseSettingsPage() { const { id } = useParams() as { id: string }; const router = useRouter(); const [course, setCourse] = useState(null); + const [organization, setOrganization] = useState(null); const [passingPercentage, setPassingPercentage] = useState(70); const [certificateTemplate, setCertificateTemplate] = useState(""); const [loading, setLoading] = useState(true); @@ -83,7 +126,7 @@ export default function CourseSettingsPage() { const [price, setPrice] = useState(0); const [currency, setCurrency] = useState("USD"); const [previewStudentName, setPreviewStudentName] = useState("Jane Doe"); - const [previewScore, setPreviewScore] = useState("95"); + const [previewScore, setPreviewScore] = useState("95%"); const [templateWarning, setTemplateWarning] = useState(null); const buildPreviewCertificate = () => { @@ -91,11 +134,16 @@ export default function CourseSettingsPage() { .replace(/{{student_name}}/g, previewStudentName || "Jane Doe") .replace(/{{course_title}}/g, course?.title || "Demo Course") .replace(/{{date}}/g, new Date().toLocaleDateString()) - .replace(/{{score}}/g, previewScore || "95") - .replace(/{{verification_code}}/g, "OPENCCB-VERIFY-2026"); + .replace(/{{score}}/g, previewScore || "95%") + .replace(/{{verification_code}}/g, "OPENCCB-VERIFY-2026") + .replace(/{{organization_name}}/g, organization?.name || "OpenCCB") + .replace(/{{platform_name}}/g, organization?.platform_name || organization?.name || "OpenCCB") + .replace(/{{primary_color}}/g, organization?.primary_color || "#2563eb") + .replace(/{{secondary_color}}/g, organization?.secondary_color || "#7c3aed") + .replace(/{{logo_url}}/g, organization?.logo_url ? getImageUrl(organization.logo_url) : "https://placehold.co/240x96?text=Logo"); }; - const applyTemplatePreset = (preset: "default" | "modern" | "minimal") => { + const applyTemplatePreset = (preset: "default" | "modern" | "minimal" | "branded") => { if (preset === "modern") { setCertificateTemplate(MODERN_CERTIFICATE_TEMPLATE); return; @@ -104,6 +152,10 @@ export default function CourseSettingsPage() { setCertificateTemplate(MINIMAL_CERTIFICATE_TEMPLATE); return; } + if (preset === "branded") { + setCertificateTemplate(BRANDED_CERTIFICATE_TEMPLATE); + return; + } setCertificateTemplate(DEFAULT_CERTIFICATE_TEMPLATE); }; @@ -125,14 +177,22 @@ export default function CourseSettingsPage() { setTemplateWarning(`Faltan variables clave: ${missingCore.join(", ")}`); return; } + if (certificateTemplate.includes("{{logo_url}}") && !organization?.logo_url) { + setTemplateWarning("La plantilla usa {{logo_url}}, pero la organización no tiene logo configurado."); + return; + } setTemplateWarning(null); - }, [certificateTemplate]); + }, [certificateTemplate, organization?.logo_url]); useEffect(() => { const fetchCourse = async () => { try { - const data = await cmsApi.getCourse(id); + const [data, orgData] = await Promise.all([ + cmsApi.getCourse(id), + cmsApi.getOrganization(), + ]); setCourse(data); + setOrganization(orgData); setPassingPercentage(data.passing_percentage || 70); setCertificateTemplate(data.certificate_template || DEFAULT_CERTIFICATE_TEMPLATE); setPacingMode(data.pacing_mode || "self_paced"); @@ -451,19 +511,25 @@ export default function CourseSettingsPage() { > Minimal +
- {TEMPLATE_VARIABLES.map((token) => ( -
+ {TEMPLATE_VARIABLES.map(({ token, label, description }) => ( +
)} + {organization && ( +
+
+

Organización

+

{organization.name}

+
+
+

Plataforma

+

{organization.platform_name || organization.name}

+
+
+

Color primario

+
+ +

{organization.primary_color || "#2563eb"}

+
+
+
+

Color secundario

+
+ +

{organization.secondary_color || "#7c3aed"}

+
+
+
+ )} +
@@ -517,6 +610,9 @@ export default function CourseSettingsPage() { placeholder="Score" />
+

+ La previsualización usa el branding actual de la organización y resuelve el logo con la misma lógica pública usada por la plataforma. +