feat: implement course certificate generation system with organization-level toggles

This commit is contained in:
2026-04-14 11:30:00 -04:00
parent c750ad0423
commit e0e6655b91
14 changed files with 1027 additions and 7 deletions
+4 -2
View File
@@ -764,8 +764,8 @@ pub async fn ingest_course(
// 1. Insertar o actualizar (Upsert) Organización
let org_id = payload.course.organization_id;
sqlx::query(
"INSERT INTO organizations (id, name, domain, logo_url, primary_color, secondary_color, certificate_template, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"INSERT INTO organizations (id, name, domain, logo_url, primary_color, secondary_color, certificate_template, certificates_enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
domain = EXCLUDED.domain,
@@ -773,6 +773,7 @@ pub async fn ingest_course(
primary_color = EXCLUDED.primary_color,
secondary_color = EXCLUDED.secondary_color,
certificate_template = EXCLUDED.certificate_template,
certificates_enabled = EXCLUDED.certificates_enabled,
updated_at = EXCLUDED.updated_at"
)
.bind(payload.organization.id)
@@ -782,6 +783,7 @@ pub async fn ingest_course(
.bind(&payload.organization.primary_color)
.bind(&payload.organization.secondary_color)
.bind(&payload.organization.certificate_template)
.bind(payload.organization.certificates_enabled)
.bind(payload.organization.created_at)
.bind(payload.organization.updated_at)
.execute(&mut *tx)
@@ -0,0 +1,657 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use common::auth::Claims;
use serde::{Deserialize, Serialize};
use sha2::{Sha256, Digest};
use sqlx::PgPool;
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<CertificateResponse>,
pub message: String,
}
#[derive(Deserialize)]
pub struct IssueCertificateRequest {
pub force_reissue: Option<bool>, // Para re-emitir si ya existe
}
// ============= 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<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<CertificateResponse>, (StatusCode, Json<serde_json::Value>)> {
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
)",
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.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!(
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
"#,
user_id,
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",
course_id
)
.fetch_optional(&pool)
.await
.map_err(|e| {
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",
user_id
)
.fetch_optional(&pool)
.await
.map_err(|e| {
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.title).unwrap_or_default(),
student_name: user_info.map(|u| u.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,
"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<PgPool>,
Path(course_id): Path<Uuid>,
Json(payload): Json<Option<IssueCertificateRequest>>,
) -> Result<Json<CertificateResponse>, (StatusCode, Json<serde_json::Value>)> {
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
)",
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.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",
user_id,
course_id
)
.fetch_optional(&pool)
.await
.map_err(|e| {
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,
"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<String>,
State(pool): State<PgPool>,
) -> Result<Json<CertificateVerificationResponse>, 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
"#,
code
)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Error al verificar certificado: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
match cert {
Some(cert) => Ok(Json(CertificateVerificationResponse {
valid: true,
certificate: Some(CertificateResponse {
id: cert.id,
user_id: cert.user_id,
course_id: cert.course_id,
course_title: cert.course_title,
student_name: cert.student_name,
certificate_html: cert.certificate_html,
issued_at: cert.issued_at.to_string(),
verification_code: cert.verification_code,
metadata: cert.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 =============
struct CourseCompletion {
completed: bool,
progress: f64,
}
async fn check_course_completion(
user_id: Uuid,
course_id: Uuid,
pool: &PgPool,
) -> Result<CourseCompletion, (StatusCode, Json<serde_json::Value>)> {
// 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
"#,
course_id
)
.fetch_all(pool)
.await
.map_err(|e| {
tracing::error!("Error al obtener lecciones graduables: {}", 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",
user_id,
course_id
)
.fetch_optional(pool)
.await
.map_err(|e| {
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.passing_percentage.unwrap_or(60.0);
let best_score = sqlx::query!(
r#"
SELECT MAX(score_percentage) as max_score
FROM grades
WHERE user_id = $1 AND lesson_id = $2
"#,
user_id,
lesson.id
)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!("Error al obtener calificaciones: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Error interno del servidor"})),
)
})?;
if let Some(score) = best_score {
if score.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,
})
}
async fn issue_certificate_internal(
user_id: Uuid,
course_id: Uuid,
pool: &PgPool,
_certificate_template_override: Option<&str>,
) -> Result<Json<CertificateResponse>, (StatusCode, Json<serde_json::Value>)> {
// Obtener datos necesarios
let course_data = sqlx::query!(
r#"
SELECT title, certificate_template, organization_id
FROM courses
WHERE id = $1
"#,
course_id
)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!("Error al obtener datos del curso: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Error interno del servidor"})),
)
})?;
let course_data = course_data.ok_or((
StatusCode::NOT_FOUND,
Json(json!({"error": "Curso no encontrado"})),
))?;
let user_name = sqlx::query!(
"SELECT full_name FROM users WHERE id = $1",
user_id
)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Error al obtener nombre del usuario: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Error interno del servidor"})),
)
})?
.full_name;
let org_data = sqlx::query!(
r#"
SELECT name, certificate_template
FROM organizations
WHERE id = $1
"#,
course_data.organization_id
)
.fetch_optional(pool)
.await
.map_err(|e| {
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 template = course_data
.certificate_template
.or_else(|| org_data.and_then(|o| o.certificate_template))
.unwrap_or_else(|| get_default_certificate_template());
// Reemplazar variables en el template
let now = chrono::Utc::now();
let certificate_html = template
.replace("{{student_name}}", &user_name)
.replace("{{course_title}}", &course_data.title)
.replace("{{date}}", &now.format("%d/%m/%Y").to_string())
.replace("{{score}}", "Aprobado")
.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
.unwrap_or(CourseCompletion {
completed: true,
progress: 100.0,
});
// Insertar certificado en BD
let metadata = serde_json::json!({
"completion_date": now.to_rfc3339(),
"final_score": course_completion.progress,
"organization_id": course_data.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
"#,
user_id,
course_id,
certificate_html,
certificate_hash,
verification_code,
metadata
)
.fetch_one(pool)
.await
.map_err(|e| {
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"})),
)
})?;
Ok(Json(CertificateResponse {
id: issued_cert.id,
user_id,
course_id,
course_title: course_data.title,
student_name: user_name,
certificate_html,
issued_at: issued_cert.issued_at.to_string(),
verification_code,
metadata,
}))
}
/// Template de certificado por defecto
fn get_default_certificate_template() -> String {
r#"<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<style>
body {
font-family: 'Georgia', serif;
text-align: center;
padding: 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
}
.certificate {
background: white;
padding: 50px;
border: 20px solid #f0f0f0;
border-radius: 10px;
max-width: 800px;
margin: 0 auto;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
h1 {
color: #667eea;
font-size: 42px;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 3px;
}
.subtitle {
font-size: 18px;
color: #666;
margin-bottom: 40px;
}
.recipient {
font-size: 16px;
margin-bottom: 10px;
}
.student-name {
font-size: 36px;
color: #764ba2;
font-weight: bold;
margin: 20px 0;
font-style: italic;
}
.course-name {
font-size: 28px;
color: #667eea;
font-weight: bold;
margin: 20px 0;
padding: 15px;
border-top: 2px solid #667eea;
border-bottom: 2px solid #667eea;
display: inline-block;
}
.date {
margin-top: 40px;
font-size: 16px;
color: #666;
}
.verification {
margin-top: 30px;
font-size: 12px;
color: #999;
}
.seal {
width: 100px;
height: 100px;
border: 4px solid #667eea;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 30px auto;
font-size: 40px;
color: #667eea;
}
</style>
</head>
<body>
<div class="certificate">
<div class="seal">🎓</div>
<h1>Certificado</h1>
<p class="subtitle">Se otorga este certificado a</p>
<p class="student-name">{{student_name}}</p>
<p class="recipient">Por haber completado exitosamente el curso</p>
<p class="course-name">{{course_title}}</p>
<p class="date">Fecha: {{date}}</p>
<p class="date">Estado: {{score}}</p>
<p class="verification">Código de verificación: {{verification_code}}</p>
</div>
</body>
</html>"#.to_string()
}
+5
View File
@@ -8,6 +8,7 @@ mod handlers_payments;
mod handlers_peer_review;
mod handlers_embeddings;
mod handlers_faq;
mod handlers_certificates;
mod lti;
mod jwks;
mod predictive;
@@ -177,6 +178,10 @@ async fn main() {
.route("/profile/{user_id}", get(portfolio::get_public_profile))
.route("/my/badges", get(portfolio::get_my_badges))
.route("/badges/award", post(portfolio::award_badge))
// Certificados
.route("/courses/{id}/certificate", get(handlers_certificates::get_certificate))
.route("/courses/{id}/certificate/issue", post(handlers_certificates::issue_certificate))
.route("/certificates/verify/{code}", get(handlers_certificates::verify_certificate))
.route(
"/users/{id}/gamification",
get(handlers::get_user_gamification),