feat: implement course certificate generation system with organization-level toggles
This commit is contained in:
+12
-1
@@ -16,7 +16,18 @@
|
|||||||
"Bash(git push *)",
|
"Bash(git push *)",
|
||||||
"Bash(scp *)",
|
"Bash(scp *)",
|
||||||
"Bash(curl *)",
|
"Bash(curl *)",
|
||||||
"Bash(sudo *)"
|
"Bash(sudo *)",
|
||||||
|
"Bash(grep *)",
|
||||||
|
"Bash(python3)",
|
||||||
|
"Bash(with)",
|
||||||
|
"Bash(as *)",
|
||||||
|
"Bash(content *)",
|
||||||
|
"Bash(old_text *)",
|
||||||
|
"Bash(new_text *)",
|
||||||
|
"Bash(old_text, *)",
|
||||||
|
"Bash(f.write)",
|
||||||
|
"Bash(print)",
|
||||||
|
"Bash(eof)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"$version": 3
|
"$version": 3
|
||||||
|
|||||||
Generated
+1
@@ -2174,6 +2174,7 @@ dependencies = [
|
|||||||
"reqwest 0.12.26",
|
"reqwest 0.12.26",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -620,6 +620,54 @@ OpenCCB está diseñado como un módulo premium single-tenant. Todas las operaci
|
|||||||
- **Premium Course Summaries**: Presentación de cursos con diseño de alta fidelidad y desgloses de objetivos de aprendizaje.
|
- **Premium Course Summaries**: Presentación de cursos con diseño de alta fidelidad y desgloses de objetivos de aprendizaje.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Estado de Funcionalidades
|
||||||
|
|
||||||
|
OpenCCB es una plataforma madura con la mayoría de sus funcionalidades core implementadas y operativas. Esta sección documenta transparentemente el estado actual de las características principales para que desarrolladores y administradores puedan planificar su uso.
|
||||||
|
|
||||||
|
### ✅ Completamente Implementado
|
||||||
|
|
||||||
|
| Categoría | Funcionalidades |
|
||||||
|
|-----------|-----------------|
|
||||||
|
| **Gestión de Cursos** | CRUD completo, AI generation, export/import JSON, templates, marketing metadata, teams, preview tokens |
|
||||||
|
| **Contenidos** | 16 tipos de bloques (video, quiz, hotspot, memory, mermaid, code lab, etc.) |
|
||||||
|
| **IA Integrada** | Transcripción, traducción, resúmenes, quiz generation, tutor RAG, audio evaluation, diagramas |
|
||||||
|
| **Question Bank** | CRUD completo, semantic search (PGVector), duplicate detection, AI generation con 4 skills |
|
||||||
|
| **Calificaciones** | Rubrics, weighted categories, drop-lowest policy, gradebook con cohortes, export CSV |
|
||||||
|
| **Foros** | Hilos, respuestas anidadas, votos, endorsements, moderación, suscripciones |
|
||||||
|
| **Gamificación** | XP, niveles, badges, leaderboards, Open Badges |
|
||||||
|
| **Monetización** | Mercado Pago integration, pricing, webhooks, auto-enrollment |
|
||||||
|
| **LTI 1.3** | Tool Provider con Deep Linking, JWKS, autoprovisionamiento |
|
||||||
|
| **Live Learning** | Integración Jitsi, scheduling desde Studio |
|
||||||
|
| **Analíticas** | Dashboard instructores, heatmaps, dropout risk prediction, advanced analytics |
|
||||||
|
| **Single-Tenant** | White-label branding, SSO/OIDC, exercise settings |
|
||||||
|
| **Responsive UI** | Mobile-first, dynamic API resolution, fluid typography |
|
||||||
|
|
||||||
|
### ⚠️ En Progreso / Pendiente de Finalización
|
||||||
|
|
||||||
|
| Funcionalidad | Estado Actual | Impacto |
|
||||||
|
|---------------|---------------|---------|
|
||||||
|
| **Generación de Certificados** | Schema de BD existe, falta implementación de generación y UI | Los estudiantes que completan cursos no reciben certificado |
|
||||||
|
| **Tracking de Progreso** | Hardcodeado a 0% en `my-learning/page.tsx` | Los estudiantes no ven su progreso real en el catálogo |
|
||||||
|
| **Notificaciones de Foros** | Suscripciones existen pero no se envían alertas | Usuarios suscritos no reciben notificaciones de respuestas |
|
||||||
|
| **Importación Excel (Question Bank)** | Código comentado con `unimplemented!()` | Solo se puede importar manualmente o vía AI |
|
||||||
|
| **Rate Limiting** | Deshabilitado por compatibilidad con middleware | APIs sin protección contra abuso en producción |
|
||||||
|
|
||||||
|
### 📋 Planned (Ver roadmap.md para detalles)
|
||||||
|
|
||||||
|
| Funcionalidad | Descripción |
|
||||||
|
|---------------|-------------|
|
||||||
|
| **Email/SMTP Integration** | Notificaciones por email, password reset, welcome emails |
|
||||||
|
| **Búsqueda Global** | Search unificado en cursos, lecciones, contenidos |
|
||||||
|
| **SCORM/xAPI Support** | Importación de paquetes SCORM, tracking xAPI |
|
||||||
|
| **Accesibilidad WCAG 2.1** | Auditoría y correcciones de contraste, navegación por teclado |
|
||||||
|
| **PWA y Offline** | Service workers, descarga de lecciones, sync offline |
|
||||||
|
| **Integraciones Empresariales** | HRIS (Workday, SAP), LDAP/Active Directory, webhooks salientes |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Si encuentras alguna funcionalidad marcada como "En Progreso" que necesitas urgentemente, por favor abre un issue en el repositorio o contribuye con un PR. ¡Las contribuciones son bienvenidas!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Documentación
|
## 📚 Documentación
|
||||||
|
|||||||
+239
@@ -253,3 +253,242 @@
|
|||||||
1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
|
1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
|
||||||
2. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos.
|
2. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos.
|
||||||
3. **Optimización de Performance**: Refactorización de componentes críticos y carga diferida (lazy loading).
|
3. **Optimización de Performance**: Refactorización de componentes críticos y carga diferida (lazy loading).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 22: Finalización de Funcionalidades Pendientes 🛠️
|
||||||
|
- [ ] **Generación de Certificados**: Implementación de lógica de generación automática al completar curso.
|
||||||
|
- [ ] Backend: Endpoint para verificar completitud y generar certificado (PDF)
|
||||||
|
- [ ] Frontend: UI para configurar templates de certificados en Studio
|
||||||
|
- [ ] Frontend: Botón de descarga en Experience al completar curso
|
||||||
|
- [ ] Schema de BD ya existe (`certificate_template`), solo falta implementación
|
||||||
|
- [ ] **Tracking de Progreso Real**: Cálculo y visualización de progreso real del estudiante.
|
||||||
|
- [ ] Backend: Endpoint para calcular % de completitud basado en lecciones completadas
|
||||||
|
- [ ] Frontend: Reemplazar hardcoded `progress = 0` en `my-learning/page.tsx`
|
||||||
|
- [ ] Frontend: Barra de progreso real en catálogo de cursos
|
||||||
|
- [ ] **Notificaciones de Foros**: Despacho de alertas cuando hay respuestas en hilos suscritos.
|
||||||
|
- [ ] Backend: Implementar lógica de notificación en `handlers_discussions.rs` (línea 352 tiene TODO)
|
||||||
|
- [ ] Frontend: Ver que las notificaciones se muestran correctamente en NotificationCenter
|
||||||
|
- [ ] **Importación Excel para Question Bank**: Fix del código comentado.
|
||||||
|
- [ ] Backend: Descomentar y implementar `import_from_excel` en `handlers_question_bank.rs`
|
||||||
|
- [ ] Frontend: Agregar botón de importación Excel en UI de Question Bank
|
||||||
|
- [ ] Documentación: Formato esperado del archivo Excel
|
||||||
|
- [ ] **Re-habilitar Rate Limiting**: Solución de incompatibilidad con middleware de auth.
|
||||||
|
- [ ] Backend: Fix de `GovernorLayer` para que funcione después del middleware de auth
|
||||||
|
- [ ] Backend: Configurar límites apropiados por tipo de ruta (pública vs protegida)
|
||||||
|
- [ ] Testing: Verificar que no bloquea peticiones legítimas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 23: Integración de Email/SMTP 📧
|
||||||
|
- [ ] **Configuración SMTP**: Variables de entorno para configuración de servidor de correo.
|
||||||
|
- [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
|
||||||
|
- [ ] Soporte para providers: SendGrid, AWS SES, SMTP genérico
|
||||||
|
- [ ] **Notificaciones por Email**:
|
||||||
|
- [ ] Recordatorios de deadlines próximos
|
||||||
|
- [ ] Alertas de respuestas en foros suscritos
|
||||||
|
- [ ] Notificaciones de nuevas calificaciones
|
||||||
|
- [ ] Anuncios de curso de instructores
|
||||||
|
- [ ] **Autenticación por Email**:
|
||||||
|
- [ ] Password reset vía link de email
|
||||||
|
- [ ] Verificación de email al registrar cuenta
|
||||||
|
- [ ] Magic link login (opcional)
|
||||||
|
- [ ] **Emails Transaccionales**:
|
||||||
|
- [ ] Welcome email para nuevos usuarios
|
||||||
|
- [ ] Notificación de inscripción exitosa
|
||||||
|
- [ ] Certificado de completación por email
|
||||||
|
- [ ] Recibo de pago de Mercado Pago
|
||||||
|
- [ ] **Template de Emails**:
|
||||||
|
- [ ] Templates HTML responsivos con branding personalizado
|
||||||
|
- [ ] Soporte multi-idioma (EN, ES, PT)
|
||||||
|
- [ ] Preview de templates en panel de administración
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 24: Búsqueda Global 🔍
|
||||||
|
- [ ] **Search Endpoint Unificado**: API de búsqueda across cursos, lecciones, contenidos.
|
||||||
|
- [ ] Backend: Endpoint `/search` con filtros por tipo, categoría, instructor
|
||||||
|
- [ ] Backend: Búsqueda full-text con PostgreSQL (tsvector)
|
||||||
|
- [ ] Backend: Opcionalmente agregar búsqueda semántica con embeddings (similar a question bank)
|
||||||
|
- [ ] **UI de Búsqueda**:
|
||||||
|
- [ ] Frontend: Barra de búsqueda global en navbar de Studio y Experience
|
||||||
|
- [ ] Frontend: Resultados en tiempo real con autocompletado
|
||||||
|
- [ ] Frontend: Filtros avanzados (fecha, instructor, tipo de contenido, nivel)
|
||||||
|
- [ ] **Búsqueda en Contenidos**:
|
||||||
|
- [ ] Indexación de transcripciones de video para búsqueda
|
||||||
|
- [ ] Búsqueda en documentos PDF y DOCX
|
||||||
|
- [ ] Búsqueda en preguntas de quizzes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 25: Soporte SCORM/xAPI 📦
|
||||||
|
- [ ] **SCORM 1.2/2004 Player**:
|
||||||
|
- [ ] Backend: Endpoint para subir paquetes SCORM (.zip)
|
||||||
|
- [ ] Backend: Extracción y almacenamiento de metadatos SCORM
|
||||||
|
- [ ] Frontend: Player SCORM embebido en Experience
|
||||||
|
- [ ] Frontend: Preview de paquetes SCORM en Studio
|
||||||
|
- [ ] **xAPI (Tin Can) Tracking**:
|
||||||
|
- [ ] Backend: Endpoint para recibir statements xAPI
|
||||||
|
- [ ] Backend: Almacenamiento de statements en base de datos separada
|
||||||
|
- [ ] Backend: Endpoint para consultar historial xAPI por usuario/curso
|
||||||
|
- [ ] Frontend: Dashboard de analíticas xAPI para instructores
|
||||||
|
- [ ] **Import/Export SCORM**:
|
||||||
|
- [ ] Backend: Generador de paquetes SCORM desde cursos OpenCCB
|
||||||
|
- [ ] Backend: Validador de paquetes SCORM importados
|
||||||
|
- [ ] Frontend: UI para import/export SCORM en Studio
|
||||||
|
- [ ] **Compatibilidad con Contenidos de Terceros**:
|
||||||
|
- [ ] Soporte para H5P, Articulate, Adobe Captivate
|
||||||
|
- [ ] Tracking de progreso y calificaciones desde contenidos SCORM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 26: Accesibilidad WCAG 2.1 ♿
|
||||||
|
- [ ] **Auditoría de Accesibilidad**:
|
||||||
|
- [ ] Herramientas: axe-core, Lighthouse, WAVE
|
||||||
|
- [ ] Reporte de problemas de contraste de color
|
||||||
|
- [ ] Verificación de navegación por teclado
|
||||||
|
- [ ] Testing con screen readers (NVDA, VoiceOver, JAWS)
|
||||||
|
- [ ] **Correcciones de Accesibilidad**:
|
||||||
|
- [ ] Ajustes de contraste para cumplir WCAG AA (4.5:1 para texto normal)
|
||||||
|
- [ ] Labels ARIA en todos los componentes interactivos
|
||||||
|
- [ ] Navegación por teclado completa (Tab, Enter, Escape, flechas)
|
||||||
|
- [ ] Focus indicators visibles en todos los elementos
|
||||||
|
- [ ] Skip links para saltar a contenido principal
|
||||||
|
- [ ] **Accesibilidad de Formularios**:
|
||||||
|
- [ ] Labels asociados correctamente a inputs
|
||||||
|
- [ ] Mensajes de error accesibles por screen reader
|
||||||
|
- [ ] Autocomplete en campos apropiados
|
||||||
|
- [ ] **Accesibilidad de Multimedia**:
|
||||||
|
- [ ] Subtítulos obligatorios para videos
|
||||||
|
- [ ] Transcripciones para contenido de audio
|
||||||
|
- [ ] Alt text para imágenes importantes
|
||||||
|
- [ ] **Testing Continuo**:
|
||||||
|
- [ ] Integración de axe-core en CI/CD
|
||||||
|
- [ ] Testing manual regular con usuarios de screen readers
|
||||||
|
- [ ] Documentación de accesibilidad para contribuidores
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 27: PWA y Soporte Offline 📱
|
||||||
|
- [ ] **Progressive Web App (PWA)**:
|
||||||
|
- [ ] Service worker para caching de assets estáticos
|
||||||
|
- [ ] Manifest.json para instalación en móviles/desktop
|
||||||
|
- [ ] Offline fallback page
|
||||||
|
- [ ] Push notifications para alertas críticas
|
||||||
|
- [ ] **Descarga de Lecciones**:
|
||||||
|
- [ ] Frontend: Botón de descarga por lección en Experience
|
||||||
|
- [ ] Frontend: UI para gestionar descargas (ver/eliminar)
|
||||||
|
- [ ] Service worker: Caching de contenido de video/audio
|
||||||
|
- [ ] IndexedDB: Almacenamiento de progreso offline
|
||||||
|
- [ ] **Sincronización Offline**:
|
||||||
|
- [ ] Service worker: Queue de acciones offline (calificaciones, notas, progreso)
|
||||||
|
- [ ] Frontend: Sync automático al reconectar
|
||||||
|
- [ ] Backend: Endpoint para recibir sync data y reconciliar
|
||||||
|
- [ ] Manejo de conflictos (última escritura gana, o merge)
|
||||||
|
- [ ] **Experiencia Offline**:
|
||||||
|
- [ ] Indicador de estado de conexión en UI
|
||||||
|
- [ ] Contenido disponible sin conexión claramente marcado
|
||||||
|
- [ ] Límite de almacenamiento configurable
|
||||||
|
- [ ] Limpieza automática de caché antiguo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 28: Sistema de Mentoría 🎓
|
||||||
|
- [ ] **Gestión de Mentores**:
|
||||||
|
- [ ] Backend: CRUD de asignaciones mentor-estudiante
|
||||||
|
- [ ] Backend: Endpoints para disponibilidad de mentores
|
||||||
|
- [ ] Frontend: UI para asignar mentores en panel de administración
|
||||||
|
- [ ] Frontend: Perfil de mentor con bio, especialidades, disponibilidad
|
||||||
|
- [ ] **Sesiones 1-a-1**:
|
||||||
|
- [ ] Backend: Sistema de agendamiento de sesiones
|
||||||
|
- [ ] Backend: Integración con Jitsi para sesiones virtuales
|
||||||
|
- [ ] Frontend: Calendario de sesiones para mentores y estudiantes
|
||||||
|
- [ ] Frontend: Recordatorios por email/notificación
|
||||||
|
- [ ] **Seguimiento Personalizado**:
|
||||||
|
- [ ] Backend: Notas de mentor por estudiante
|
||||||
|
- [ ] Backend: Plan de aprendizaje personalizado por estudiante
|
||||||
|
- [ ] Frontend: Dashboard de mentor con vista de estudiantes asignados
|
||||||
|
- [ ] Frontend: Reportes de progreso para mentores
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 29: Integraciones Empresariales 🏢
|
||||||
|
- [ ] **HRIS Connectors**:
|
||||||
|
- [ ] Integración con Workday (API REST)
|
||||||
|
- [ ] Integración con SAP SuccessFactors (OData)
|
||||||
|
- [ ] Integración con BambooHR
|
||||||
|
- [ ] Sync automático de usuarios, departamentos, managers
|
||||||
|
- [ ] **LDAP/Active Directory**:
|
||||||
|
- [ ] Backend: Autenticación vía LDAP
|
||||||
|
- [ ] Backend: Sync de atributos de usuario desde AD
|
||||||
|
- [ ] Frontend: Configuración de LDAP en panel de administración
|
||||||
|
- [ ] Soporte para SSO híbrido (LDAP + OIDC)
|
||||||
|
- [ ] **Webhooks Salientes**:
|
||||||
|
- [ ] Backend: Sistema de webhooks configurables por evento
|
||||||
|
- [ ] Backend: Retry logic con exponential backoff
|
||||||
|
- [ ] Backend: Dashboard de entrega de webhooks (éxito/fallo)
|
||||||
|
- [ ] Frontend: UI para gestionar webhooks en Studio
|
||||||
|
- [ ] Frontend: Test de webhook desde UI
|
||||||
|
- [ ] **ERP Integrations**:
|
||||||
|
- [ ] Integración con sistemas de facturación
|
||||||
|
- [ ] Sync de inscripciones y pagos con ERP
|
||||||
|
- [ ] Reportes financieros para administradores
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 30: Reportes Avanzados y Business Intelligence 📊
|
||||||
|
- [ ] **Reportes para Estudiantes**:
|
||||||
|
- [ ] Dashboard de analíticas personales (tiempo de estudio, skills, tendencias)
|
||||||
|
- [ ] Reporte de progreso semanal/mensual por email
|
||||||
|
- [ ] Exportación de historial de aprendizaje (PDF, Excel)
|
||||||
|
- [ ] **Reportes para Instructores**:
|
||||||
|
- [ ] Reportes automáticos programables (diario, semanal, mensual)
|
||||||
|
- [ ] Exportación en múltiples formatos (PDF, Excel, CSV)
|
||||||
|
- [ ] Reportes comparativos entre cohortes
|
||||||
|
- [ ] Análisis de efectividad de contenido (qué funciona mejor)
|
||||||
|
- [ ] **Reportes para Administradores**:
|
||||||
|
- [ ] Dashboard de uso de la plataforma (DAU, WAU, MAU)
|
||||||
|
- [ ] Análisis de ROI de cursos (costo vs completitud)
|
||||||
|
- [ ] Reportes de uso de IA (tokens, costo, efectividad)
|
||||||
|
- [ ] Exportación de datos completos de la plataforma
|
||||||
|
- [ ] **Business Intelligence**:
|
||||||
|
- [ ] Integración con herramientas BI externas (Metabase, Tableau)
|
||||||
|
- [ ] API de datos para dashboards externos
|
||||||
|
- [ ] Data warehouse schema optimizado para queries analíticos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 31: Mejoras de Seguridad y GDPR 🛡️
|
||||||
|
- [ ] **Two-Factor Authentication (2FA)**:
|
||||||
|
- [ ] Backend: Soporte para TOTP (Google Authenticator, Authy)
|
||||||
|
- [ ] Backend: Backup codes para recuperación
|
||||||
|
- [ ] Frontend: Setup de 2FA en perfil de usuario
|
||||||
|
- [ ] Frontend: Login con 2FA
|
||||||
|
- [ ] **GDPR Compliance**:
|
||||||
|
- [ ] Backend: Endpoint para exportar todos los datos de un usuario
|
||||||
|
- [ ] Backend: Endpoint para eliminar todos los datos de un usuario (right to be forgotten)
|
||||||
|
- [ ] Frontend: UI para solicitar exportación/eliminación
|
||||||
|
- [ ] Frontend: Consent management para cookies y tracking
|
||||||
|
- [ ] **Audit Logs Mejorados**:
|
||||||
|
- [ ] Backend: Exportación de audit logs (CSV, JSON)
|
||||||
|
- [ ] Backend: Alertas de actividad sospechosa
|
||||||
|
- [ ] Frontend: Filtros avanzados de audit logs
|
||||||
|
- [ ] Frontend: Dashboard de seguridad
|
||||||
|
- [ ] **Mejoras de Seguridad**:
|
||||||
|
- [ ] CSRF protection en todos los endpoints
|
||||||
|
- [ ] Content Security Policy headers
|
||||||
|
- [ ] Rate limiting por IP y por usuario
|
||||||
|
- [ ] IP allowlisting para admin endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos**, **Landing Pages de Cursos (Marketing) automatizadas**, **Diagramas de Mermaid Dinámicos**, **Laboratorios de Código con Hints de IA**, y **Búsqueda Semántica con PGVector**.
|
||||||
|
|
||||||
|
**Próximas Prioridades**:
|
||||||
|
1. **Finalización de Funcionalidades Pendientes**: Certificados, progreso real, notificaciones de foros, importación Excel, rate limiting.
|
||||||
|
2. **Integración de Email/SMTP**: Notificaciones por email, password reset, emails transaccionales.
|
||||||
|
3. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1.
|
||||||
|
4. **Búsqueda Global**: Search unificado en cursos, lecciones y contenidos.
|
||||||
|
5. **Integraciones Empresariales**: Conectividad con HRIS, LDAP/Active Directory, webhooks salientes.
|
||||||
|
6. **PWA y Offline**: Service workers, descarga de lecciones, sync offline.
|
||||||
|
7. **Seguridad**: 2FA, GDPR compliance, rate limiting re-habilitado.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Add certificate generation toggle to organization exercise settings
|
||||||
|
-- Allows organizations to disable built-in certificate generation if using external systems
|
||||||
|
|
||||||
|
ALTER TABLE organization_exercise_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS certificates_enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN organization_exercise_settings.certificates_enabled
|
||||||
|
IS 'Enable/disable built-in certificate generation. When false, students cannot generate/download certificates from the platform.';
|
||||||
@@ -21,6 +21,7 @@ pub struct OrganizationExerciseSettings {
|
|||||||
pub role_playing_enabled: bool,
|
pub role_playing_enabled: bool,
|
||||||
pub mermaid_enabled: bool,
|
pub mermaid_enabled: bool,
|
||||||
pub code_lab_enabled: bool,
|
pub code_lab_enabled: bool,
|
||||||
|
pub certificates_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OrganizationExerciseSettings {
|
impl OrganizationExerciseSettings {
|
||||||
@@ -34,6 +35,7 @@ impl OrganizationExerciseSettings {
|
|||||||
role_playing_enabled: true,
|
role_playing_enabled: true,
|
||||||
mermaid_enabled: false,
|
mermaid_enabled: false,
|
||||||
code_lab_enabled: true,
|
code_lab_enabled: true,
|
||||||
|
certificates_enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ impl OrganizationExerciseSettings {
|
|||||||
"role-playing" => self.role_playing_enabled,
|
"role-playing" => self.role_playing_enabled,
|
||||||
"mermaid" => self.mermaid_enabled,
|
"mermaid" => self.mermaid_enabled,
|
||||||
"code-lab" => self.code_lab_enabled,
|
"code-lab" => self.code_lab_enabled,
|
||||||
|
"certificates" => self.certificates_enabled,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +63,7 @@ pub struct UpdateOrganizationExerciseSettingsPayload {
|
|||||||
pub role_playing_enabled: bool,
|
pub role_playing_enabled: bool,
|
||||||
pub mermaid_enabled: bool,
|
pub mermaid_enabled: bool,
|
||||||
pub code_lab_enabled: bool,
|
pub code_lab_enabled: bool,
|
||||||
|
pub certificates_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_organization_exercise_settings(
|
pub async fn load_organization_exercise_settings(
|
||||||
@@ -76,7 +80,8 @@ pub async fn load_organization_exercise_settings(
|
|||||||
peer_review_enabled,
|
peer_review_enabled,
|
||||||
role_playing_enabled,
|
role_playing_enabled,
|
||||||
mermaid_enabled,
|
mermaid_enabled,
|
||||||
code_lab_enabled
|
code_lab_enabled,
|
||||||
|
certificates_enabled
|
||||||
FROM organization_exercise_settings
|
FROM organization_exercise_settings
|
||||||
WHERE organization_id = $1
|
WHERE organization_id = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -104,9 +109,10 @@ async fn upsert_organization_exercise_settings(
|
|||||||
role_playing_enabled,
|
role_playing_enabled,
|
||||||
mermaid_enabled,
|
mermaid_enabled,
|
||||||
code_lab_enabled,
|
code_lab_enabled,
|
||||||
|
certificates_enabled,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
||||||
ON CONFLICT (organization_id) DO UPDATE SET
|
ON CONFLICT (organization_id) DO UPDATE SET
|
||||||
audio_response_enabled = EXCLUDED.audio_response_enabled,
|
audio_response_enabled = EXCLUDED.audio_response_enabled,
|
||||||
hotspot_enabled = EXCLUDED.hotspot_enabled,
|
hotspot_enabled = EXCLUDED.hotspot_enabled,
|
||||||
@@ -115,6 +121,7 @@ async fn upsert_organization_exercise_settings(
|
|||||||
role_playing_enabled = EXCLUDED.role_playing_enabled,
|
role_playing_enabled = EXCLUDED.role_playing_enabled,
|
||||||
mermaid_enabled = EXCLUDED.mermaid_enabled,
|
mermaid_enabled = EXCLUDED.mermaid_enabled,
|
||||||
code_lab_enabled = EXCLUDED.code_lab_enabled,
|
code_lab_enabled = EXCLUDED.code_lab_enabled,
|
||||||
|
certificates_enabled = EXCLUDED.certificates_enabled,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING
|
RETURNING
|
||||||
organization_id,
|
organization_id,
|
||||||
@@ -124,7 +131,8 @@ async fn upsert_organization_exercise_settings(
|
|||||||
peer_review_enabled,
|
peer_review_enabled,
|
||||||
role_playing_enabled,
|
role_playing_enabled,
|
||||||
mermaid_enabled,
|
mermaid_enabled,
|
||||||
code_lab_enabled
|
code_lab_enabled,
|
||||||
|
certificates_enabled
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(organization_id)
|
.bind(organization_id)
|
||||||
@@ -135,6 +143,7 @@ async fn upsert_organization_exercise_settings(
|
|||||||
.bind(payload.role_playing_enabled)
|
.bind(payload.role_playing_enabled)
|
||||||
.bind(payload.mermaid_enabled)
|
.bind(payload.mermaid_enabled)
|
||||||
.bind(payload.code_lab_enabled)
|
.bind(payload.code_lab_enabled)
|
||||||
|
.bind(payload.certificates_enabled)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -189,6 +198,7 @@ pub async fn update_organization_exercise_settings(
|
|||||||
"role_playing_enabled": settings.role_playing_enabled,
|
"role_playing_enabled": settings.role_playing_enabled,
|
||||||
"mermaid_enabled": settings.mermaid_enabled,
|
"mermaid_enabled": settings.mermaid_enabled,
|
||||||
"code_lab_enabled": settings.code_lab_enabled,
|
"code_lab_enabled": settings.code_lab_enabled,
|
||||||
|
"certificates_enabled": settings.certificates_enabled,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ http.workspace = true
|
|||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
aws-config = "1"
|
aws-config = "1"
|
||||||
aws-sdk-s3 = "1"
|
aws-sdk-s3 = "1"
|
||||||
|
sha2.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Issued Certificates Table
|
||||||
|
-- Stores certificates generated when students complete courses
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS issued_certificates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
course_id UUID NOT NULL,
|
||||||
|
certificate_html TEXT NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
certificate_hash TEXT NOT NULL, -- For dedup and verification
|
||||||
|
verification_code TEXT NOT NULL UNIQUE, -- Public verification code
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb -- Extra data: score, completion_date, etc.
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX idx_issued_certificates_user_id ON issued_certificates(user_id);
|
||||||
|
CREATE INDEX idx_issued_certificates_course_id ON issued_certificates(course_id);
|
||||||
|
CREATE INDEX idx_issued_certificates_verification_code ON issued_certificates(verification_code);
|
||||||
|
|
||||||
|
-- Unique constraint: one certificate per user per course
|
||||||
|
CREATE UNIQUE INDEX idx_issued_certificates_user_course_unique
|
||||||
|
ON issued_certificates(user_id, course_id);
|
||||||
|
|
||||||
|
-- Comments
|
||||||
|
COMMENT ON TABLE issued_certificates IS 'Certificates issued to students upon course completion';
|
||||||
|
COMMENT ON COLUMN issued_certificates.certificate_hash IS 'SHA256 hash of the certificate HTML for verification';
|
||||||
|
COMMENT ON COLUMN issued_certificates.verification_code IS 'Public code for certificate verification (e.g., VER-ABC123)';
|
||||||
|
COMMENT ON COLUMN issued_certificates.metadata IS 'Additional data: final_score, completion_date, instructor_signature, etc';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Add certificates_enabled flag to organizations table in LMS
|
||||||
|
-- This is synced from CMS exercise_settings when organization data is ingested
|
||||||
|
|
||||||
|
ALTER TABLE organizations
|
||||||
|
ADD COLUMN IF NOT EXISTS certificates_enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN organizations.certificates_enabled
|
||||||
|
IS 'Whether built-in certificate generation is enabled for this organization (synced from CMS).';
|
||||||
@@ -764,8 +764,8 @@ pub async fn ingest_course(
|
|||||||
// 1. Insertar o actualizar (Upsert) Organización
|
// 1. Insertar o actualizar (Upsert) Organización
|
||||||
let org_id = payload.course.organization_id;
|
let org_id = payload.course.organization_id;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO organizations (id, name, domain, logo_url, primary_color, secondary_color, certificate_template, created_at, updated_at)
|
"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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
domain = EXCLUDED.domain,
|
domain = EXCLUDED.domain,
|
||||||
@@ -773,6 +773,7 @@ pub async fn ingest_course(
|
|||||||
primary_color = EXCLUDED.primary_color,
|
primary_color = EXCLUDED.primary_color,
|
||||||
secondary_color = EXCLUDED.secondary_color,
|
secondary_color = EXCLUDED.secondary_color,
|
||||||
certificate_template = EXCLUDED.certificate_template,
|
certificate_template = EXCLUDED.certificate_template,
|
||||||
|
certificates_enabled = EXCLUDED.certificates_enabled,
|
||||||
updated_at = EXCLUDED.updated_at"
|
updated_at = EXCLUDED.updated_at"
|
||||||
)
|
)
|
||||||
.bind(payload.organization.id)
|
.bind(payload.organization.id)
|
||||||
@@ -782,6 +783,7 @@ pub async fn ingest_course(
|
|||||||
.bind(&payload.organization.primary_color)
|
.bind(&payload.organization.primary_color)
|
||||||
.bind(&payload.organization.secondary_color)
|
.bind(&payload.organization.secondary_color)
|
||||||
.bind(&payload.organization.certificate_template)
|
.bind(&payload.organization.certificate_template)
|
||||||
|
.bind(payload.organization.certificates_enabled)
|
||||||
.bind(payload.organization.created_at)
|
.bind(payload.organization.created_at)
|
||||||
.bind(payload.organization.updated_at)
|
.bind(payload.organization.updated_at)
|
||||||
.execute(&mut *tx)
|
.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()
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ mod handlers_payments;
|
|||||||
mod handlers_peer_review;
|
mod handlers_peer_review;
|
||||||
mod handlers_embeddings;
|
mod handlers_embeddings;
|
||||||
mod handlers_faq;
|
mod handlers_faq;
|
||||||
|
mod handlers_certificates;
|
||||||
mod lti;
|
mod lti;
|
||||||
mod jwks;
|
mod jwks;
|
||||||
mod predictive;
|
mod predictive;
|
||||||
@@ -177,6 +178,10 @@ async fn main() {
|
|||||||
.route("/profile/{user_id}", get(portfolio::get_public_profile))
|
.route("/profile/{user_id}", get(portfolio::get_public_profile))
|
||||||
.route("/my/badges", get(portfolio::get_my_badges))
|
.route("/my/badges", get(portfolio::get_my_badges))
|
||||||
.route("/badges/award", post(portfolio::award_badge))
|
.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(
|
.route(
|
||||||
"/users/{id}/gamification",
|
"/users/{id}/gamification",
|
||||||
get(handlers::get_user_gamification),
|
get(handlers::get_user_gamification),
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ pub struct Organization {
|
|||||||
pub primary_color: Option<String>,
|
pub primary_color: Option<String>,
|
||||||
pub secondary_color: Option<String>,
|
pub secondary_color: Option<String>,
|
||||||
pub certificate_template: Option<String>,
|
pub certificate_template: Option<String>,
|
||||||
|
pub certificates_enabled: bool,
|
||||||
pub platform_name: Option<String>,
|
pub platform_name: Option<String>,
|
||||||
pub favicon_url: Option<String>,
|
pub favicon_url: Option<String>,
|
||||||
pub logo_variant: Option<String>,
|
pub logo_variant: Option<String>,
|
||||||
@@ -454,6 +455,7 @@ impl Default for Organization {
|
|||||||
primary_color: None,
|
primary_color: None,
|
||||||
secondary_color: None,
|
secondary_color: None,
|
||||||
certificate_template: None,
|
certificate_template: None,
|
||||||
|
certificates_enabled: true,
|
||||||
platform_name: None,
|
platform_name: None,
|
||||||
favicon_url: None,
|
favicon_url: None,
|
||||||
logo_variant: None,
|
logo_variant: None,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user