use axum::{ Json, 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 use serde_json::json; // ============= Structs ============= #[derive(Serialize)] pub struct CertificateResponse { pub id: Uuid, pub user_id: Uuid, pub course_id: Uuid, pub course_title: String, pub student_name: String, pub certificate_html: String, pub issued_at: String, pub verification_code: String, pub metadata: serde_json::Value, } #[derive(Serialize)] pub struct CertificateVerificationResponse { pub valid: bool, pub certificate: Option, pub message: String, } #[derive(Deserialize)] pub struct IssueCertificateRequest { pub force_reissue: Option, // Para re-emitir si ya existe } #[derive(sqlx::FromRow)] struct CertificateRecord { id: Uuid, user_id: Uuid, course_id: Uuid, certificate_html: String, issued_at: chrono::DateTime, verification_code: String, 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 /// Obtiene el certificado emitido para el usuario actual en este curso. /// Si no existe, lo genera automáticamente si el usuario completó el curso. pub async fn get_certificate( claims: Claims, State(pool): State, Path(course_id): Path, ) -> Result, (StatusCode, Json)> { let user_id = claims.sub; // 1. Verificar si la organización tiene certificados habilitados let org_certificates_enabled = sqlx::query( "SELECT certificates_enabled FROM organizations WHERE id = ( SELECT organization_id FROM courses WHERE id = $1 )" ) .bind(course_id) .fetch_optional(&pool) .await .map_err(|e| { tracing::error!("Error al verificar configuración de certificados: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })?; if let Some(org_config) = org_certificates_enabled { if !org_config.get::("certificates_enabled") { return Err(( StatusCode::NOT_IMPLEMENTED, Json(json!({ "error": "La generación de certificados está deshabilitada por el administrador", "hint": "Contacta al administrador de la plataforma para más información" })), )); } } // 2. Verificar si ya existe un certificado emitido let existing_cert = sqlx::query_as::<_, CertificateRecord>( r#" SELECT ic.id, ic.user_id, ic.course_id, ic.certificate_html, ic.issued_at, ic.verification_code, ic.metadata FROM issued_certificates ic WHERE ic.user_id = $1 AND ic.course_id = $2 "# ) .bind(user_id) .bind(course_id) .fetch_optional(&pool) .await .map_err(|e| { tracing::error!("Error al consultar certificado: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })?; if let Some(cert) = existing_cert { // Obtener título del curso y nombre del estudiante let course_info = sqlx::query( "SELECT title FROM courses WHERE id = $1" ) .bind(course_id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener info del curso: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })?; let user_info = sqlx::query( "SELECT full_name FROM users WHERE id = $1" ) .bind(user_id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener info del usuario: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })?; return Ok(Json(CertificateResponse { id: cert.id, user_id: cert.user_id, course_id: cert.course_id, course_title: course_info.map(|c| c.get::("title")).unwrap_or_default(), student_name: user_info.map(|u| u.get::("full_name")).unwrap_or_default(), certificate_html: cert.certificate_html, issued_at: cert.issued_at.to_string(), verification_code: cert.verification_code, metadata: cert.metadata, })); } // 2. Si no existe, verificar si el usuario completó el curso y generar certificado let course_completion = check_course_completion(user_id, course_id, &pool).await .map_err(|e| e)?; if !course_completion.completed { return Err(( StatusCode::FORBIDDEN, Json(json!({ "error": "No has completado este curso aún", "progress": course_completion.progress_percentage, "required": 100.0 })), )); } // 3. Generar el certificado issue_certificate_internal(user_id, course_id, &pool, None).await .map_err(|e| e) } /// POST /courses/{id}/certificate/issue /// Emite un certificado para el usuario actual si completó el curso. /// Permite re-emisión si force_reissue = true. pub async fn issue_certificate( claims: Claims, State(pool): State, Path(course_id): Path, Json(payload): Json>, ) -> Result, (StatusCode, Json)> { let user_id = claims.sub; let force_reissue = payload.and_then(|p| p.force_reissue).unwrap_or(false); // Verificar si la organización tiene certificados habilitados let org_certificates_enabled = sqlx::query( "SELECT certificates_enabled FROM organizations WHERE id = ( SELECT organization_id FROM courses WHERE id = $1 )" ) .bind(course_id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| { tracing::error!("Error al verificar configuración de certificados: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })?; if let Some(org_config) = org_certificates_enabled { if !org_config.get::("certificates_enabled") { return Err(( StatusCode::NOT_IMPLEMENTED, Json(json!({ "error": "La generación de certificados está deshabilitada por el administrador", "hint": "Contacta al administrador de la plataforma para más información" })), )); } } // Verificar si ya existe let existing = sqlx::query( "SELECT id FROM issued_certificates 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 certificado existente: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })?; if existing.is_some() && !force_reissue { return Err(( StatusCode::CONFLICT, Json(json!({ "error": "Ya tienes un certificado emitido para este curso", "hint": "Usa force_reissue=true para re-emitir" })), )); } // Verificar completitud del curso let course_completion = check_course_completion(user_id, course_id, &pool).await .map_err(|e| e)?; if !course_completion.completed { return Err(( StatusCode::FORBIDDEN, Json(json!({ "error": "No has completado este curso aún", "progress": course_completion.progress_percentage, "required": 100.0 })), )); } // Emitir certificado issue_certificate_internal(user_id, course_id, &pool, None).await .map_err(|e| e) } /// GET /certificates/verify/{code} /// Verifica la autenticidad de un certificado por su código público. pub async fn verify_certificate( Path(code): Path, State(pool): State, ) -> Result, StatusCode> { let cert = sqlx::query( r#" SELECT ic.id, ic.user_id, ic.course_id, ic.certificate_html, ic.issued_at, ic.verification_code, ic.metadata, c.title as course_title, u.full_name as student_name FROM issued_certificates ic JOIN courses c ON c.id = ic.course_id JOIN users u ON u.id = ic.user_id WHERE ic.verification_code = $1 "# ) .bind(code) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| { tracing::error!("Error al verificar certificado: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; match cert { Some(row) => Ok(Json(CertificateVerificationResponse { valid: true, certificate: Some(CertificateResponse { id: row.get("id"), user_id: row.get("user_id"), course_id: row.get("course_id"), course_title: row.get("course_title"), student_name: row.get("student_name"), certificate_html: row.get("certificate_html"), issued_at: row.get::, _>("issued_at").to_string(), verification_code: row.get("verification_code"), metadata: row.get("metadata"), }), message: "Certificado válido".to_string(), })), None => Ok(Json(CertificateVerificationResponse { valid: false, certificate: None, message: "Certificado no encontrado o inválido".to_string(), })), } } // ============= Funciones Internas ============= async fn check_course_completion( user_id: Uuid, course_id: Uuid, pool: &PgPool, ) -> Result)> { calculate_course_completion(pool, user_id, course_id) .await .map_err(|e: sqlx::Error| { tracing::error!("Error al calcular completitud del curso: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) }) } async fn issue_certificate_internal( user_id: Uuid, course_id: Uuid, pool: &PgPool, _certificate_template_override: Option<&str>, ) -> Result, (StatusCode, Json)> { // Obtener datos necesarios let course_row = sqlx::query( r#" SELECT title, certificate_template, organization_id FROM courses WHERE id = $1 "# ) .bind(course_id) .fetch_optional(pool) .await .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener datos del curso: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })?; let course_row = course_row.ok_or(( StatusCode::NOT_FOUND, Json(json!({"error": "Curso no encontrado"})), ))?; let user_name = sqlx::query( "SELECT full_name FROM users WHERE id = $1" ) .bind(user_id) .fetch_one(pool) .await .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener nombre del usuario: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })? .get::("full_name"); let organization_id: Uuid = course_row.get("organization_id"); let org_data = sqlx::query( r#" SELECT name, platform_name, logo_url, primary_color, secondary_color, certificate_template FROM organizations WHERE id = $1 "# ) .bind(organization_id) .fetch_optional(pool) .await .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener datos de la organización: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })?; // Determinar template a usar (curso > organización > default) let course_title = course_row.get::("title"); let template = course_row .get::, _>("certificate_template") .or_else(|| org_data.as_ref().and_then(|o| o.get::, _>("certificate_template"))) .unwrap_or_else(|| get_default_certificate_template()); 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(); let certificate_html = template .replace("{{student_name}}", &user_name) .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 let verification_code = format!( "VER-{}-{}", now.format("%Y%m%d"), &uuid::Uuid::new_v4().to_string()[..8].to_uppercase() ); // Reemplazar placeholder con código real let certificate_html = certificate_html.replace("VER-PLACEHOLDER", &verification_code); // Generar hash para verificación let mut hasher = Sha256::new(); hasher.update(certificate_html.as_bytes()); let certificate_hash = format!("{:x}", hasher.finalize()); // Obtener progreso final para metadata 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_percentage, "organization_id": organization_id.to_string(), }); let issued_cert = sqlx::query( r#" INSERT INTO issued_certificates (user_id, course_id, certificate_html, certificate_hash, verification_code, metadata) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, issued_at "# ) .bind(user_id.clone()) .bind(course_id.clone()) .bind(certificate_html.clone()) .bind(certificate_hash.clone()) .bind(verification_code.clone()) .bind(metadata.clone()) .fetch_one(pool) .await .map_err(|e: sqlx::Error| { tracing::error!("Error al emitir certificado: {}", e); // Manejar unique constraint violation if e.to_string().contains("issued_certificates_user_course_unique") { return ( StatusCode::CONFLICT, Json(json!({"error": "Ya existe un certificado para este usuario y curso"})), ); } ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })?; // 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.clone(), student_name: user_name.clone(), certificate_html, issued_at: issued_cert.get::, _>("issued_at").to_string(), verification_code, metadata, })) } // 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#"
🎓

Certificado

Se otorga este certificado a

{{student_name}}

Por haber completado exitosamente el curso

{{course_title}}

Fecha: {{date}}

Estado: {{score}}

Código de verificación: {{verification_code}}

"#.to_string() }