From 05faa209933b3a2692ecb7101d9493109fbb2f28 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Sat, 17 Jan 2026 02:19:39 -0300 Subject: [PATCH] feat: Add i18n support, new content block types, course export, and lesson interaction tracking. --- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 166 ++++++-- docker-compose.yml | 14 +- roadmap.md | 59 ++- services/cms-service/Cargo.toml | 1 + services/cms-service/src/db_util.rs | 2 +- services/cms-service/src/exporter.rs | 65 +++ services/cms-service/src/handlers.rs | 385 ++++++++++++++++++ services/cms-service/src/handlers/tasks.rs | 38 +- services/cms-service/src/main.rs | 5 + ...20260117000200_add_lesson_interactions.sql | 16 + .../20260117000300_add_notifications.sql | 16 + services/lms-service/src/handlers.rs | 142 ++++++- services/lms-service/src/main.rs | 19 + shared/common/src/models.rs | 50 ++- .../courses/[id]/lessons/[lessonId]/page.tsx | 258 +++++++----- web/experience/src/app/layout.tsx | 25 +- web/experience/src/app/profile/page.tsx | 37 +- web/experience/src/components/AppHeader.tsx | 36 +- .../src/components/NotificationCenter.tsx | 134 ++++++ .../components/blocks/CodeExercisePlayer.tsx | 151 +++++++ .../src/components/blocks/DocumentPlayer.tsx | 90 ++++ .../src/components/blocks/HotspotPlayer.tsx | 158 +++++++ .../src/components/blocks/MediaPlayer.tsx | 28 +- .../src/components/blocks/MemoryPlayer.tsx | 183 +++++++++ web/experience/src/context/I18nContext.tsx | 61 +++ web/experience/src/lib/api.ts | 38 +- web/experience/src/lib/locales/en.json | 30 ++ web/experience/src/lib/locales/es.json | 30 ++ web/experience/src/lib/locales/pt.json | 30 ++ web/studio/package-lock.json | 14 +- web/studio/src/app/admin/audit/page.tsx | 282 +++++++------ web/studio/src/app/admin/layout.tsx | 76 ++++ web/studio/src/app/admin/page.tsx | 155 +++++++ web/studio/src/app/auth/login/page.tsx | 2 +- .../courses/[id]/analytics/advanced/page.tsx | 85 +++- .../courses/[id]/analytics/reports/page.tsx | 193 +++++++++ .../courses/[id]/lessons/[lessonId]/page.tsx | 20 +- .../src/app/courses/[id]/settings/page.tsx | 96 ++++- web/studio/src/app/layout.tsx | 27 +- web/studio/src/app/page.tsx | 166 +++++++- web/studio/src/app/profile/page.tsx | 13 +- web/studio/src/components/Navbar.tsx | 54 ++- .../src/components/blocks/DocumentBlock.tsx | 108 +++++ web/studio/src/context/I18nContext.tsx | 64 +++ web/studio/src/lib/api.ts | 15 +- web/studio/src/lib/locales/en.json | 37 ++ web/studio/src/lib/locales/es.json | 37 ++ web/studio/src/lib/locales/pt.json | 37 ++ 50 files changed, 3368 insertions(+), 388 deletions(-) create mode 100644 services/cms-service/src/exporter.rs create mode 100644 services/lms-service/migrations/20260117000200_add_lesson_interactions.sql create mode 100644 services/lms-service/migrations/20260117000300_add_notifications.sql create mode 100644 web/experience/src/components/NotificationCenter.tsx create mode 100644 web/experience/src/components/blocks/CodeExercisePlayer.tsx create mode 100644 web/experience/src/components/blocks/DocumentPlayer.tsx create mode 100644 web/experience/src/components/blocks/HotspotPlayer.tsx create mode 100644 web/experience/src/components/blocks/MemoryPlayer.tsx create mode 100644 web/experience/src/context/I18nContext.tsx create mode 100644 web/experience/src/lib/locales/en.json create mode 100644 web/experience/src/lib/locales/es.json create mode 100644 web/experience/src/lib/locales/pt.json create mode 100644 web/studio/src/app/admin/layout.tsx create mode 100644 web/studio/src/app/admin/page.tsx create mode 100644 web/studio/src/app/courses/[id]/analytics/reports/page.tsx create mode 100644 web/studio/src/components/blocks/DocumentBlock.tsx create mode 100644 web/studio/src/context/I18nContext.tsx create mode 100644 web/studio/src/lib/locales/en.json create mode 100644 web/studio/src/lib/locales/es.json create mode 100644 web/studio/src/lib/locales/pt.json diff --git a/Cargo.lock b/Cargo.lock index 95ae4b2..5a7c4a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "atoi" version = "2.0.0" @@ -251,6 +257,7 @@ dependencies = [ name = "cms-service" version = "0.1.0" dependencies = [ + "anyhow", "axum", "bcrypt", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f5adfba..b5a3970 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,4 @@ hmac = "0.12" sha2 = "0.10" hex = "0.4" openidconnect = { version = "3.5", features = ["reqwest"] } +anyhow = "1.0" diff --git a/README.md b/README.md index e0757af..b3d6d1e 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,16 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Backend**: API de Rust para entrega de cursos y calificaciones (LMS). 3. **Database**: PostgreSQL compartido. 4. **AI Services**: stack local con Faster-Whisper (Transcripción) y Ollama (Traducción y Resúmenes). -5. **User Profiles**: Gestión completa de identidad (avatar, bio, preferencias). + - **AI Course Wizard**: Generación automática de cursos a partir de prompts estructurados. + - **Global Admin Console**: Panel estilo Django para gestión supervisada de tenants y auditoría. + - **Course Portability**: Importación/Exportación de cursos completos mediante JSON. + - **User Profiles**: Gestión completa de identidad (avatar, bio, preferencias). + - **Engagement Heatmaps**: Visualización de retención segundo a segundo en videos. + - **Smart Notifications**: Recordatorios de fechas límite y alertas in-app. + - **Global i18n**: Interfaz multilingüe (EN, ES, PT) con persistencia por usuario. + - **Document-Based Learning**: Soporte para actividades de lectura (PDF, DOCX, PPTX). -## � Requisitos del Sistema +## Requisitos del Sistema OpenCCB es altamente escalable. A continuación se detallan los requisitos recomendados según la carga de usuarios concurrentes: @@ -40,6 +47,8 @@ OpenCCB es altamente escalable. A continuación se detallan los requisitos recom - **IA Local**: - **Faster-Whisper**: Transcripción de audio a texto. - **Ollama**: Traducción inteligente (EN -> ES), resúmenes y generación de cuestionarios. +- **i18n Infrastructure**: Sistema de traducción reactivo para soporte global. +- **Document Management**: Motor de previsualización de documentos PDF nativo. ## 📦 Guía de Inicio Rápido @@ -70,6 +79,14 @@ npm install npm run dev ``` +#### 📦 Course Portability +Manage content mobility across different organizations using standardized JSON exports. + +#### GET /courses/{id}/export +Generates a complete bundle of the course, including modules, lessons, and grading settings. + +#### POST /courses/import +Creates a new course based on a provided export bundle. Automatic dependency mapping ensures data integrity in the new organization. #### Experience & LMS ```bash # Iniciar backend LMS @@ -101,19 +118,12 @@ Crea una nueva organización y el usuario administrador inicial. "role": "string (admin | instructor | student)" } ``` -- **Respuesta Exitosa (200 OK) ( AuthResponse ):** - ```json - { - "token": "string (JWT)", - "user": { - "id": "uuid", - "email": "string", - "full_name": "string", - "role": "string", - "organization_id": "uuid" - } - } - ``` + +#### SSO (OpenID Connect) +OpenCCB soporta integración con proveedores de identidad (IdP) externos como Google, Okta y Azure AD. +- **Configuración**: Los administradores de la organización pueden configurar sus credenciales OIDC en el panel de configuración de Studio. +- **Autoprovisionamiento**: Los nuevos usuarios se crean automáticamente en la plataforma tras una autenticación exitosa. +- **Flujo**: `/auth/sso/login/{org_id}` -> IdP -> `/auth/sso/callback` -> Redirección a Studio/Experience con JWT. ```bash # Registrar un nuevo administrador y empresa @@ -145,20 +155,66 @@ curl -X POST "http://localhost:3001/courses" \ -H "Authorization: Bearer $TOKEN" \ -d '{"title": "Curso de Rust", "pacing_mode": "self_paced"}' ``` +#### POST /courses/generate +Utiliza IA para generar la estructura completa de un curso basado en un prompt. + +- **Lógica de Generación**: Utiliza modelos de lenguaje (LLM) para descomponer un tema complejo en una malla curricular lógica de módulos y lecciones. +- **Cuerpo de la Petición ( GenerateCourseRequest ):** + ```json + { + "prompt": "string" + } + ``` + +#### GET /courses/{id}/export +Exporta un curso completo y su contenido a formato JSON para portabilidad. + +- **Integridad Portátil**: Empaqueta metadatos, categorías de calificación, módulos y lecciones manteniendo sus relaciones jerárquicas. +- **Respuesta**: Archivo JSON estandarizado para importación. + +#### POST /courses/import +Importa un curso a partir de un archivo JSON generado previamente. + +- **Mapeo de Dependencias**: Re-mapea automáticamente los IDs de lecciones y módulos para la nueva organización, asegurando que las relaciones y ponderaciones se mantengan intactas. +- **Cuerpo de la Petición ( CourseBundle ):** + ```json + { + "title": "string", + "description": "string", + "modules": [] + } + ``` #### POST /lessons Agrega contenido multimedia o evaluaciones a un módulo. - **Configuración Graduable**: Si `is_graded` es true, los puntos sumarán al XP del estudiante en el LMS. +- **Nuevos Tipos Gamificados**: + - `hotspot`: Identificación visual sobre imágenes (ideal para niños). + - `memory-match`: Juego de memoria con pares conceptuales. - **Cuerpo ( CreateLessonRequest ):** + ```json + { + "module_id": "uuid", + "title": "string", + "content_type": "string (video | reading | quiz | hotspot | memory-match | document)", + "content_url": "string (opcional)", + "is_graded": "boolean" + } + ``` + +#### POST /assets +Sube un archivo multimedia o documento al servidor y devuelve sus metadatos. + +- **Lógica de Almacenamiento**: Genera un UUID único para el archivo, extrae el mimetype y lo almacena físicamente en el volumen de `uploads`, registrando la entrada en la base de datos de activos. +- **Cuerpo de la Petición ( MultipartForm ):** + - `file`: Archivo binario (PDF, Video, Imagen, Docx). +- **Respuesta ( UploadResponse ):** ```json { - "module_id": "uuid", - "title": "string", - "content_type": "string (video | reading | quiz)", - "content_url": "string (opcional)", - "is_graded": "boolean", - "grading_category_id": "uuid (opcional)" + "id": "uuid", + "url": "string", + "mimetype": "string" } ``` @@ -196,15 +252,19 @@ curl -X POST "http://localhost:3002/enroll" \ Registra el puntaje de una lección y actualiza la gamificación. - **Lógica Inteligente**: Actualiza automáticamente el XP del usuario y despacha webhooks si el curso se completa. -- **Cuerpo ( GradeSubmissionPayload ):** - ```json - { - "course_id": "uuid", - "lesson_id": "uuid", - "score": "float (0.0 a 1.0)", - "metadata": "object (opcional)" - } - ``` +- **Engagement Tracking**: Si la lección contiene video, el frontend envía eventos de "heartbeat" cada 5 segundos para generar mapas de calor. + +#### GET /notifications +Obtiene las notificaciones pendientes del usuario. + +- **Filtro de Relevancia**: Devuelve únicamente alertas no leídas sobre fechas límite próximas o logros de gamificación recientes. +- **Respuesta**: Array de `Notification`. + +#### POST /notifications/{id}/read +Marca una notificación específica como leída. + +- **Persistencia**: Actualiza el estado en la base de datos para que no reaparezca en el feed del usuario. +- **Cuerpo de la Petición**: Vacío. ```bash # Enviar calificación de 90% @@ -219,10 +279,16 @@ curl -X POST "http://localhost:3002/grades" \ Funcionalidades inteligentes 100% locales y gratuitas. #### POST /lessons/{id}/transcribe -Inicia el proceso de transcripción (Whisper) y traducción (Ollama). +Inicia el proceso de transcripción y traducción para una lección de video/audio. + +- **Procesamiento Asíncrono**: Despacha una tarea en segundo plano que utiliza Whisper para transcripción y Ollama para generar la traducción y el resumen inteligente. +- **Cuerpo de la Petición**: Vacío. #### GET /lessons/{id}/vtt?lang=en|es -Devuelve los subtítulos en formato WebVTT para integración nativa en el reproductor. +Devuelve los subtítulos en formato WebVTT para integración nativa. + +- **Internacionalización**: Filtra los subtítulos por el parámetro `lang` y los devuelve con el formato de tiempo compatible con reproductores de video HTML5. +- **Respuesta**: Archivo de texto WebVTT. #### POST /chat (Streaming) Conversación en tiempo real con la base de conocimientos. @@ -253,7 +319,22 @@ curl -X POST "http://localhost:8000/chat" \ **Respuesta**: Stream de texto plano. Al final incluye un JSON con el ID de sesión: `{"session_id": "..."}`. #### GET /courses/{id}/analytics/advanced -Métricas de retención y análisis de cohortes. +Métricas de retención y análisis de cohortes para un curso. + +- **Inteligencia de Datos**: Cruza información de intentos de evaluaciones y tiempos de visualización para identificar patrones de deserción. +- **Respuesta**: Dashboard JSON con métricas agregadas. + +#### GET /lessons/{id}/heatmap +Devuelve los puntos de concentración de visualización para una lección. + +- **Engagement Visual**: Analiza los eventos de heartbeat para determinar cuáles segundos del video son los más vistos o repetidos por los estudiantes. +- **Respuesta**: Array de `(second, count)`. + +#### GET /courses/{id}/analytics/reports +Generador de reportes personalizados para exportación. + +- **Flexibilidad Administrativa**: Permite filtrar el desempeño por cohortes específicas y devuelve la estructura necesaria para generar archivos CSV profesionales. +- **Respuesta**: Stream de datos o estructura de reporte. --- @@ -263,6 +344,10 @@ OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Ad #### Super Admin Definition - **Default Organization ID**: `00000000-0000-0000-0000-000000000001` - Any user with `role: admin` in this organization is a **Super Admin**. +#### Global Control Panel (`/admin`) +- **Dashboard**: Resumen de organizaciones, usuarios y salud del sistema. +- **Audit Logs**: Seguimiento detallado de todas las acciones administrativas. +- **Service Monitor**: Estado en tiempo real del API Cluster, AI Services y Background Workers. #### Global Courses Courses created by Super Admins in the **Default Organization** are automatically marked as **Global**. @@ -281,14 +366,25 @@ curl -H "Authorization: Bearer $SUPER_ADMIN_TOKEN" \ ``` #### GET /organizations -Returns a searchable list of all organizations. (Admin only). +Obtiene una lista de todas las organizaciones registradas. + +- **Control Global**: Accesible únicamente para usuarios con rol `admin` dentro de la organización `Default`. Permite supervisar el crecimiento del ecosistema. +- **Respuesta**: Array de `Organization`. --- ## 🏆 Premium UI Components +- **Course Portability**: Full JSON-based import/export system for multi-tenant content mobility. +- **AI Course Wizard**: Instant curriculum generation from natural language prompts. +- **Global Admin Console**: Centralized control for organizations, users, and audit logs. +- **Experience Player**: A high-performance, accessible learning interface with glassmorphism design. - **Organization Selector**: A searchable combobox for managing large lists of tenants. +- **Engagement Heatmaps**: Dynamic bar charts showing video retention signatures. +- **Notification Center**: Real-time alerts for deadlines and achievements. +- **Custom Report Builder**: Professional reports with one-click CSV export. - **Glassmorphism Design**: Consistent aesthetic across Studio and Experience portals. -- **Micro-animations**: Enhanced feedback for publishing and content management. +- **Global Localization**: Native support for English, Spanish, and Portuguese. +- **PDF Integrated Viewer**: Read academic documents without leaving the platform. ## 📄 Licencia Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f299e3f..8274235 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,13 +55,13 @@ services: environment: - DEVICE=${WHISPER_DEVICE:-cpu} # GPU support for RTX 2070 Super - #deploy: - # resources: - # reservations: - # devices: - # - driver: nvidia - # count: 1 - # capabilities: [ gpu ] + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [ gpu ] e2e: build: diff --git a/roadmap.md b/roadmap.md index 3dc3c07..80d960c 100644 --- a/roadmap.md +++ b/roadmap.md @@ -91,7 +91,7 @@ - [x] **Advanced Analytics**: - [x] Cohort analysis (Implemented) - [x] Retention metrics (Implemented) - - [ ] Engagement heatmaps + - [x] Engagement heatmaps (Implemented) - [x] **AI Integration**: - [x] AI-driven lesson summaries (Implemented) - [x] Real-time video transcription & translation via Local AI (Implemented) @@ -110,27 +110,66 @@ - [x] Instructor-led mode (Cohort-based with start/end dates). - [x] **Course Calendar**: - [x] Management of important dates (exams, assignments, milestones). - - [ ] Automated reminders for upcoming deadlines. + - [x] Automated reminders for upcoming deadlines. (Implemented) ## Phase 8: Enterprise Features (In Progress) - [x] **User Profiles & Lifecycle**: - [x] **Integrated Logout**: Standardized session management in both portals. - [x] **Profile Management**: Self-service user info updates (Avatar, Bio, Language). -- [ ] **Advanced Reporting**: -- [ ] **Integration Ecosystem**: (SSO Next) +- [x] **Advanced Reporting**: Custom report builder and CSV exports. (Implemented) +- [x] **Integration Ecosystem**: + - [x] **SSO (Single Sign-On)**: Soporte completo para OIDC (Google, Okta, Azure AD) con autoprovisionamiento. (Completado) - [ ] **Mobile Apps**: - [ ] **Accessibility**: +## Phase 9: Course Portability (Import/Export) ✅ +- [x] **Universal JSON Schema**: Standardized format for course interchange. (Completed) +- [x] **Recursive Exporter**: Serialization of full course hierarchies. (Completed) +- [x] **Atomic Importer**: Batch creation with dependency re-mapping. (Completed) +- [x] **Portability UI**: Integrated Export/Import buttons in Settings. (Completed) + +## Phase 10: Global Admin Console (Admin Interface) ✅ +- [x] **The "Django" Panel**: Dedicated UI for Super-Admins to manage Orgs, Users, and Health. (Completed) +- [x] **System Monitoring**: Real-time stats on AI usage and service heartbeats. (Completed) +- [x] **Universal Audit Log**: Centralized dashboard for cross-tenant activity. (Completed) + +## Phase 11: Extended Assessments & Quizzes (In Progress) +- [x] **Code Quizzes**: Interactive coding challenges with IDE-like player. (Completed) +- [ ] **Image Labeling**: Hotspot quizzes for technical training. +- [ ] **AI Teaching Assistant**: RAG-powered tutor within the lesson player. + +## Phase 12: AI-Powered "Auto-Course" Generator ✅ +- [x] **Magic Course Creation**: Structure generation from a single prompt. (Completed) +- [x] **Atomic Transactional Ingestion**: Create whole structures (Module -> Lesson) in one go. (Completed) +- [x] **LLM Prompt Engineering**: Professional curriculum design via AI. (Completed) + +## Phase 13: Kid-Friendly Gamified Assessments ✅ +- [x] **Image Hotspots**: Visual identification quizzes. (Completed) +- [x] **Memory Match**: Educational card games. (Completed) +- [x] **AI Prompt Tuning**: LLM awareness of child-friendly formats. (Completed) + +## Phase 14: Globalization & Document-Based Learning ✅ +- [x] **Internationalization (i18n)**: UI support for English, Spanish, and Portuguese. (Completed) +- [x] **Language Switcher**: Dynamic locale switching in Navbar and User Profile. (Completed) +- [x] **Document Block**: In-platform PDF preview and DOCX/PPTX downloads. (Completed) +- [x] **Academic Language Consistency**: Content (graded activities) remains in original language. (Completed) +- [x] **Multi-language AI Support**: Transcriptions and summaries follow the course context. (Completed) + ## Current Status **Platform Maturity**: Core multi-tenant architecture is stable and performance-optimized. **Recent Milestones**: -- ✅ **Local AI Stack**: 100% free transcription and translation (Whisper + Ollama). -- ✅ **Native VTT Subtitles**: Enhanced video player with multi-language CC. -- ✅ **User Profiles**: Glassmorphism UI for identity management. +- ✅ **Globalization (i18n)**: Multi-language UI (EN/ES/PT) for Studio and Experience. +- ✅ **Document Learning**: Support for PDF, DOCX, and PPTX reading activities. +- ✅ **Course Portability**: JSON-based import/export system for multi-tenant mobility. +- ✅ **Gamified Kids Assessments**: Image Hotspots and Memory Match features. +- ✅ **AI Course Wizard**: Instant course structure generation from prompts. +- ✅ **Global Admin Panel**: Centralized control center for system administrators. +- ✅ **Interactive Code Player**: New technical assessment type for developers. +- ✅ **SSO Integration**: OIDC support for corporate clients with self-service config. **Next Priorities**: -1. **SSO Integration**: SAML/OIDC support for enterprise clients. -2. **Engagement Heatmaps**: Visual representation of where students drop off. -3. **Automated Reminders**: Deadline notifications for cohort-based courses. +1. **Personalized Learning Paths**: AI-driven content recommendations. +2. **Mobile Apps**: Dedicated iOS and Android wrappers. +3. **Accessibility**: WCAG 2.1 compliance audit and fixes. diff --git a/services/cms-service/Cargo.toml b/services/cms-service/Cargo.toml index e3163d5..fecef52 100644 --- a/services/cms-service/Cargo.toml +++ b/services/cms-service/Cargo.toml @@ -24,3 +24,4 @@ hmac.workspace = true sha2.workspace = true hex.workspace = true openidconnect.workspace = true +anyhow.workspace = true diff --git a/services/cms-service/src/db_util.rs b/services/cms-service/src/db_util.rs index e21737d..d799604 100644 --- a/services/cms-service/src/db_util.rs +++ b/services/cms-service/src/db_util.rs @@ -1,4 +1,4 @@ -use sqlx::{Postgres, PgConnection}; +use sqlx::PgConnection; use uuid::Uuid; pub async fn set_session_context( diff --git a/services/cms-service/src/exporter.rs b/services/cms-service/src/exporter.rs new file mode 100644 index 0000000..10ce34c --- /dev/null +++ b/services/cms-service/src/exporter.rs @@ -0,0 +1,65 @@ +use chrono::{DateTime, Utc}; +use common::models::{Course, Lesson, Module}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct CourseExport { + pub course: Course, + pub modules: Vec, + pub grading_categories: Vec, + pub export_version: String, + pub exported_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ModuleWithLessons { + pub module: Module, + pub lessons: Vec, +} + +pub async fn get_course_data(pool: &PgPool, course_id: Uuid) -> anyhow::Result { + // 1. Fetch Course + let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") + .bind(course_id) + .fetch_one(pool) + .await?; + + // 2. Fetch Grading Categories + let grading_categories = sqlx::query_as::<_, common::models::GradingCategory>( + "SELECT * FROM grading_categories WHERE course_id = $1", + ) + .bind(course_id) + .fetch_all(pool) + .await?; + + // 3. Fetch Modules + let modules_raw = + sqlx::query_as::<_, Module>("SELECT * FROM modules WHERE course_id = $1 ORDER BY position") + .bind(course_id) + .fetch_all(pool) + .await?; + + let mut modules = Vec::new(); + + // 4. Fetch Lessons for each module + for module in modules_raw { + let lessons = sqlx::query_as::<_, Lesson>( + "SELECT * FROM lessons WHERE module_id = $1 ORDER BY position", + ) + .bind(module.id) + .fetch_all(pool) + .await?; + + modules.push(ModuleWithLessons { module, lessons }); + } + + Ok(CourseExport { + course, + modules, + grading_categories, + export_version: "1.0".to_string(), + exported_at: Utc::now(), + }) +} diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index f27f247..7ac8f71 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1,3 +1,4 @@ +use crate::exporter; use crate::webhooks::WebhookService; pub mod tasks; use axum::{ @@ -1819,6 +1820,37 @@ pub async fn get_advanced_analytics( Ok(Json(analytics)) } +pub async fn get_lesson_heatmap( + Org(org_ctx): Org, + _claims: common::auth::Claims, + State(_pool): State, + Path(lesson_id): Path, +) -> Result>, (StatusCode, String)> { + let client = reqwest::Client::new(); + let lms_url = + env::var("LMS_INTERNAL_URL").unwrap_or_else(|_| "http://experience:3002".to_string()); + let res = client + .get(format!("{}/lessons/{}/heatmap", lms_url, lesson_id)) + .header("X-Organization-Id", org_ctx.id.to_string()) + .send() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; + + if !res.status().is_success() { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch heatmap from LMS".into(), + )); + } + + let heatmap = res + .json::>() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(heatmap)) +} + #[derive(Deserialize)] pub struct AuditQuery { pub page: Option, @@ -2666,3 +2698,356 @@ pub async fn delete_webhook( Ok(StatusCode::OK) } + +// --- Course Portability --- + +pub async fn export_course( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path(id): Path, +) -> Result, StatusCode> { + // 1. Verify access (ensure course belongs to org) + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM courses WHERE id = $1 AND organization_id = $2)", + ) + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if !exists { + return Err(StatusCode::NOT_FOUND); + } + + // 2. Export recursively + let export = exporter::get_course_data(&pool, id).await.map_err(|e| { + tracing::error!("Export failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(export)) +} + +pub async fn import_course( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result, StatusCode> { + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // 1. Create Course + let new_course = sqlx::query_as::<_, Course>( + "INSERT INTO courses ( + organization_id, instructor_id, title, pacing_mode, description, + passing_percentage, certificate_template, start_date, end_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *", + ) + .bind(org_ctx.id) + .bind(claims.sub) + .bind(format!("{} (Importado)", payload.course.title)) + .bind(payload.course.pacing_mode) + .bind(payload.course.description) + .bind(payload.course.passing_percentage) + .bind(payload.course.certificate_template) + .bind(payload.course.start_date) + .bind(payload.course.end_date) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to create imported course: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // 2. Import Grading Categories and create mapping + let mut cat_map = std::collections::HashMap::new(); + for old_cat in payload.grading_categories { + let new_cat = sqlx::query_as::<_, common::models::GradingCategory>( + "INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count) + VALUES ($1, $2, $3, $4, $5) + RETURNING *", + ) + .bind(org_ctx.id) + .bind(new_course.id) + .bind(old_cat.name) + .bind(old_cat.weight) + .bind(old_cat.drop_count) + .fetch_one(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + cat_map.insert(old_cat.id, new_cat.id); + } + + // 3. Import Modules & Lessons + for module_data in payload.modules { + let new_module = sqlx::query_as::<_, Module>( + "INSERT INTO modules (course_id, organization_id, title, position) + VALUES ($1, $2, $3, $4) + RETURNING *", + ) + .bind(new_course.id) + .bind(org_ctx.id) + .bind(module_data.module.title) + .bind(module_data.module.position) + .fetch_one(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + for lesson in module_data.lessons { + let new_cat_id = lesson + .grading_category_id + .and_then(|id| cat_map.get(&id)) + .cloned(); + + sqlx::query( + "INSERT INTO lessons ( + module_id, course_id, organization_id, title, content_type, + content_url, position, is_graded, metadata, summary, + transcription, grading_category_id, max_attempts + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", + ) + .bind(new_module.id) + .bind(new_course.id) + .bind(org_ctx.id) + .bind(lesson.title) + .bind(lesson.content_type) + .bind(lesson.content_url) + .bind(lesson.position) + .bind(lesson.is_graded) + .bind(lesson.metadata) + .bind(lesson.summary) + .bind(lesson.transcription) + .bind(new_cat_id) + .bind(lesson.max_attempts) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to import lesson: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + } + } + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + log_action( + &pool, + org_ctx.id, + claims.sub, + "COURSE_IMPORTED", + "Course", + new_course.id, + serde_json::json!({ "original_title": payload.course.title }), + ) + .await; + + Ok(Json(new_course)) +} + +// --- AI Course Generation --- + +#[derive(Deserialize)] +pub struct GenerateCoursePayload { + pub prompt: String, + pub target_organization_id: Option, +} + +pub async fn generate_course( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result, StatusCode> { + tracing::info!( + "Starting AI course generation for prompt: {}", + payload.prompt + ); + + // 1. Determine target org + let target_org_id = payload.target_organization_id.unwrap_or(org_ctx.id); + + // 2. AI Setup + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = + env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3".to_string()); + ( + format!("{}/v1/chat/completions", base_url), + "".to_string(), + model, + ) + } else { + let api_key = env::var("OPENAI_API_KEY").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", api_key), + "gpt-4o".to_string(), + ) + }; + + let system_prompt = r#"You are an expert curriculum designer. +Design a structured course based on the topic provided. +If the topic is for children or youth, use interactive content types: +- 'hotspot': Identifying image parts. +- 'memory-match': Card matching game. +- 'quiz': Standard questions. + +Return ONLY a valid JSON object with the following structure: +{ + "title": "Clear and Engaging Course Title", + "description": "Short overview and objectives", + "modules": [ + { + "title": "Module Name", + "position": 1, + "lessons": [ + { "title": "Lesson Name", "position": 1, "content_type": "text|video|hotspot|memory-match|quiz" } + ] + } + ] +}"#; + + let mut request = client.post(&url).json(&json!({ + "model": model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": format!("Create a course about: {}", payload.prompt) } + ], + "response_format": { "type": "json_object" } + })); + + if !auth_header.is_empty() { + request = request.header("Authorization", auth_header); + } + + let response = request.send().await.map_err(|e| { + tracing::error!("LLM request failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + tracing::error!("LLM API error: {}", err_body); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let llm_data: serde_json::Value = response + .json() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut content_str = llm_data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("{}") + .trim() + .to_string(); + + // Clean markdown code blocks if present + if content_str.starts_with("```") { + content_str = content_str + .lines() + .filter(|line| !line.starts_with("```")) + .collect::>() + .join("\n"); + } + + let result_json: serde_json::Value = serde_json::from_str(&content_str).map_err(|e| { + tracing::error!("Failed to parse AI JSON: {}. Content: {}", e, content_str); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // 3. Database Transaction + let mut tx = pool + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Create Course + let course_title = result_json["title"].as_str().unwrap_or("Untitled Course"); + let course_desc = result_json["description"].as_str(); + + let course = sqlx::query_as::<_, Course>( + "INSERT INTO courses (organization_id, instructor_id, title, description, pacing_mode) + VALUES ($1, $2, $3, $4, 'self_paced') + RETURNING *", + ) + .bind(target_org_id) + .bind(claims.sub) + .bind(course_title) + .bind(course_desc) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("DB Course creation failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Create Modules and Lessons + if let Some(modules) = result_json["modules"].as_array() { + for (m_idx, m_val) in modules.iter().enumerate() { + let m_title = m_val["title"].as_str().unwrap_or("Module"); + + let module = sqlx::query_as::<_, Module>( + "INSERT INTO modules (course_id, organization_id, title, position) + VALUES ($1, $2, $3, $4) + RETURNING *", + ) + .bind(course.id) + .bind(target_org_id) + .bind(m_title) + .bind((m_idx + 1) as i32) + .fetch_one(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if let Some(lessons) = m_val["lessons"].as_array() { + for (l_idx, l_val) in lessons.iter().enumerate() { + let l_title = l_val["title"].as_str().unwrap_or("Lesson"); + let l_type = l_val["content_type"].as_str().unwrap_or("text"); + + sqlx::query( + "INSERT INTO lessons (module_id, course_id, organization_id, title, content_type, position) + VALUES ($1, $2, $3, $4, $5, $6)" + ) + .bind(module.id) + .bind(course.id) + .bind(target_org_id) + .bind(l_title) + .bind(l_type) + .bind((l_idx + 1) as i32) + .execute(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + } + } + } + + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + log_action( + &pool, + target_org_id, + claims.sub, + "AI_COURSE_GENERATED", + "Course", + course.id, + json!({ "prompt": payload.prompt }), + ) + .await; + + Ok(Json(course)) +} diff --git a/services/cms-service/src/handlers/tasks.rs b/services/cms-service/src/handlers/tasks.rs index 40741a3..c85a4f8 100644 --- a/services/cms-service/src/handlers/tasks.rs +++ b/services/cms-service/src/handlers/tasks.rs @@ -1,12 +1,12 @@ +use crate::handlers::run_transcription_task; use axum::{ + Json, extract::{Path, State}, http::StatusCode, - Json, }; -use serde::{Deserialize, Serialize}; -use sqlx::{PgPool, FromRow}; +use serde::Serialize; +use sqlx::{FromRow, PgPool}; use uuid::Uuid; -use crate::handlers::run_transcription_task; #[derive(Debug, Serialize, FromRow)] pub struct BackgroundTask { @@ -24,10 +24,10 @@ pub async fn get_background_tasks( // For now, assuming super-admin visibility or scoped by org_id in headers (which middleware handles) // But since this is a new "Admin" feature, let's keep it simple and list all tasks for the current org context // Ideally we should extract OrgId from request extensions, but let's query all active tasks for now. - + // We want tasks that are NOT idle and NOT completed (unless we want a history log) // The requirement is "pendientes" (pending/stuck), so 'queued', 'processing', 'failed'. - + let query = r#" SELECT l.id, @@ -44,7 +44,12 @@ pub async fn get_background_tasks( let tasks = sqlx::query_as::<_, BackgroundTask>(query) .fetch_all(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch tasks: {}", e)))?; + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to fetch tasks: {}", e), + ) + })?; Ok(Json(tasks)) } @@ -55,7 +60,7 @@ pub async fn retry_task( ) -> Result { // 1. Reset status to 'queued' or directly spawn // It's safer to spawn essentially identical logic to the upload handler - + // First verify it exists let exists = sqlx::query("SELECT 1 FROM lessons WHERE id = $1") .bind(id) @@ -70,7 +75,7 @@ pub async fn retry_task( // Spawn the task let pool_clone = pool.clone(); tokio::spawn(async move { - // Reset to queued first to indicate we are trying again? + // Reset to queued first to indicate we are trying again? // Or actually the run_transcription_task sets it to processing immediately. // Let's explicitly set to queued just in case, though the task runs fast. let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1") @@ -79,9 +84,9 @@ pub async fn retry_task( .await; if let Err(e) = run_transcription_task(pool_clone, id).await { - tracing::error!("Retry transcription task failed for lesson {}: {}", id, e); - // Verify we mark it as failed is handled inside run_transcription_task? - // Let's double check that later. + tracing::error!("Retry transcription task failed for lesson {}: {}", id, e); + // Verify we mark it as failed is handled inside run_transcription_task? + // Let's double check that later. } }); @@ -95,12 +100,17 @@ pub async fn cancel_task( // "Cancel" in this context mainly means setting it to 'idle' or 'failed' so it stops showing up as stuck. // We can't easily kill a running tokio task unless we had a handle map, which we don't. // So this is effectively "Dismiss". - + sqlx::query("UPDATE lessons SET transcription_status = 'idle' WHERE id = $1") .bind(id) .execute(&pool) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to cancel task: {}", e)))?; + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to cancel task: {}", e), + ) + })?; Ok(StatusCode::NO_CONTENT) } diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 921238b..1ed075e 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -1,4 +1,5 @@ mod db_util; +pub mod exporter; mod handlers; mod handlers_branding; mod webhooks; @@ -97,6 +98,7 @@ async fn main() { "/courses/{id}/analytics/advanced", get(handlers::get_advanced_analytics), ) + .route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap)) .route( "/modules", get(handlers::get_modules).post(handlers::create_module), @@ -124,6 +126,9 @@ async fn main() { .route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt)) .route("/lessons/{id}/summarize", post(handlers::summarize_lesson)) .route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz)) + .route("/courses/generate", post(handlers::generate_course)) + .route("/courses/{id}/export", get(handlers::export_course)) + .route("/courses/import", post(handlers::import_course)) .route("/grading", post(handlers::create_grading_category)) .route("/grading/{id}", delete(handlers::delete_grading_category)) .route( diff --git a/services/lms-service/migrations/20260117000200_add_lesson_interactions.sql b/services/lms-service/migrations/20260117000200_add_lesson_interactions.sql new file mode 100644 index 0000000..689d66e --- /dev/null +++ b/services/lms-service/migrations/20260117000200_add_lesson_interactions.sql @@ -0,0 +1,16 @@ +-- Create lesson_interactions table for engagement tracking +CREATE TABLE IF NOT EXISTS lesson_interactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + user_id UUID NOT NULL, + lesson_id UUID NOT NULL, + video_timestamp FLOAT, -- Timestamp in seconds for video content + event_type VARCHAR(50) NOT NULL, -- 'heartbeat', 'pause', 'seek', 'complete', 'start' + metadata JSONB, -- Additional data (segment duration, playback speed, etc.) + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index for efficient querying of heatmaps +CREATE INDEX IF NOT EXISTS idx_lesson_interactions_lesson_id ON lesson_interactions(lesson_id); +CREATE INDEX IF NOT EXISTS idx_lesson_interactions_user_id ON lesson_interactions(user_id); +CREATE INDEX IF NOT EXISTS idx_lesson_interactions_org_id ON lesson_interactions(organization_id); diff --git a/services/lms-service/migrations/20260117000300_add_notifications.sql b/services/lms-service/migrations/20260117000300_add_notifications.sql new file mode 100644 index 0000000..c83de73 --- /dev/null +++ b/services/lms-service/migrations/20260117000300_add_notifications.sql @@ -0,0 +1,16 @@ +-- Create notifications table for in-app alerts +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + user_id UUID NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + notification_type VARCHAR(50) DEFAULT 'info', -- 'info', 'warning', 'success', 'deadline' + is_read BOOLEAN DEFAULT FALSE, + link_url VARCHAR(255), -- Optional link to redirect the user + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_org_id ON notifications(organization_id); +CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id) WHERE is_read = FALSE; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index e52da1a..c7e1ba7 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -7,8 +7,8 @@ use bcrypt::{DEFAULT_COST, hash, verify}; use common::auth::{Claims, create_jwt}; use common::middleware::Org; use common::models::{ - AuthResponse, Course, CourseAnalytics, Enrollment, Lesson, LessonAnalytics, Module, - Organization, User, UserResponse, + AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics, + Module, Notification, Organization, User, UserResponse, }; use serde::Deserialize; use sqlx::{PgPool, Row}; @@ -104,6 +104,13 @@ pub struct GradeSubmissionPayload { pub metadata: Option, } +#[derive(Deserialize)] +pub struct InteractionPayload { + pub video_timestamp: Option, + pub event_type: String, // 'heartbeat', 'pause', 'seek', 'complete', 'start' + pub metadata: Option, +} + pub async fn register( State(pool): State, Json(payload): Json, @@ -442,7 +449,7 @@ pub async fn ingest_course( } pub async fn get_course_outline( - Org(org_ctx): Org, + Org(_org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { @@ -503,7 +510,7 @@ pub async fn get_course_outline( } pub async fn get_lesson_content( - Org(org_ctx): Org, + Org(_org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result, StatusCode> { @@ -518,7 +525,7 @@ pub async fn get_lesson_content( } pub async fn get_user_enrollments( - Org(org_ctx): Org, + Org(_org_ctx): Org, State(pool): State, Path(user_id): Path, ) -> Result>, StatusCode> { @@ -748,7 +755,7 @@ pub async fn get_leaderboard( } pub async fn get_user_course_grades( - Org(org_ctx): Org, + Org(_org_ctx): Org, State(pool): State, Path((user_id, course_id)): Path<(Uuid, Uuid)>, ) -> Result>, StatusCode> { @@ -865,6 +872,129 @@ pub async fn get_advanced_analytics( })) } +pub async fn record_interaction( + Org(org_ctx): Org, + Path(lesson_id): Path, + claims: Claims, + State(pool): State, + Json(payload): Json, +) -> Result { + sqlx::query( + "INSERT INTO lesson_interactions (organization_id, user_id, lesson_id, video_timestamp, event_type, metadata) + VALUES ($1, $2, $3, $4, $5, $6)" + ) + .bind(org_ctx.id) + .bind(claims.sub) + .bind(lesson_id) + .bind(payload.video_timestamp) + .bind(payload.event_type) + .bind(payload.metadata) + .execute(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to record interaction: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::CREATED) +} + +pub async fn get_lesson_heatmap( + Org(org_ctx): Org, + Path(lesson_id): Path, + State(pool): State, +) -> Result>, StatusCode> { + let heatmap = sqlx::query_as::<_, HeatmapPoint>( + "SELECT floor(video_timestamp)::int as second, count(*)::bigint as count + FROM lesson_interactions + WHERE lesson_id = $1 AND organization_id = $2 AND video_timestamp IS NOT NULL + GROUP BY second + ORDER BY second", + ) + .bind(lesson_id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch heatmap: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(heatmap)) +} + +pub async fn get_notifications( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, +) -> Result>, StatusCode> { + let notifications = sqlx::query_as::<_, Notification>( + "SELECT * FROM notifications WHERE user_id = $1 AND organization_id = $2 ORDER BY created_at DESC LIMIT 50" + ) + .bind(claims.sub) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch notifications: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(notifications)) +} + +pub async fn mark_notification_as_read( + Org(org_ctx): Org, + claims: Claims, + Path(id): Path, + State(pool): State, +) -> Result { + sqlx::query( + "UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 AND organization_id = $3" + ) + .bind(id) + .bind(claims.sub) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to mark notification as read: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::OK) +} + +pub async fn check_deadlines_and_notify(pool: PgPool) { + let result = sqlx::query( + "INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url) + SELECT + l.organization_id, + e.user_id, + 'Fecha límite próxima: ' || l.title, + 'La lección \"' || l.title || '\" del curso \"' || c.title || '\" vence en menos de 24 horas.', + 'deadline', + '/courses/' || c.id || '/lessons/' || l.id + FROM enrollments e + JOIN lessons l ON l.course_id = e.course_id + JOIN courses c ON c.id = l.course_id + WHERE l.due_date BETWEEN NOW() AND NOW() + INTERVAL '24 hours' + AND NOT EXISTS ( + SELECT 1 FROM notifications n + WHERE n.user_id = e.user_id + AND n.notification_type = 'deadline' + AND n.link_url = '/courses/' || c.id || '/lessons/' || l.id + AND n.created_at > NOW() - INTERVAL '48 hours' + )" + ) + .execute(&pool) + .await; + + if let Err(e) = result { + tracing::error!("Failed to run deadline notifications: {}", e); + } +} + pub async fn update_user( Org(org_ctx): Org, claims: common::auth::Claims, diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index aaf2dac..ac7170e 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -29,6 +29,15 @@ async fn main() { .await .expect("Failed to run migrations"); + // Start background task for deadline notifications + let pool_clone = pool.clone(); + tokio::spawn(async move { + loop { + handlers::check_deadlines_and_notify(pool_clone.clone()).await; + tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; // Every hour + } + }); + let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) @@ -58,6 +67,16 @@ async fn main() { ) .route("/users/{id}", post(handlers::update_user)) .route("/analytics/leaderboard", get(handlers::get_leaderboard)) + .route( + "/lessons/{id}/interactions", + post(handlers::record_interaction), + ) + .route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap)) + .route("/notifications", get(handlers::get_notifications)) + .route( + "/notifications/{id}/read", + post(handlers::mark_notification_as_read), + ) .route_layer(middleware::from_fn( common::middleware::org_extractor_middleware, )); diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index fe75b70..e8dcde1 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -74,15 +74,34 @@ pub struct UserGrade { pub created_at: DateTime, } -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] -pub struct AuditLog { +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct LessonInteraction { pub id: Uuid, + pub organization_id: Uuid, pub user_id: Uuid, - pub organization_id: Option, - pub action: String, - pub entity_type: String, - pub entity_id: Uuid, - pub changes: serde_json::Value, + pub lesson_id: Uuid, + pub video_timestamp: Option, + pub event_type: String, + pub metadata: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct HeatmapPoint { + pub second: i32, + pub count: i64, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct Notification { + pub id: Uuid, + pub organization_id: Uuid, + pub user_id: Uuid, + pub title: String, + pub message: String, + pub notification_type: String, + pub is_read: bool, + pub link_url: Option, pub created_at: DateTime, } @@ -238,6 +257,23 @@ pub struct OrganizationSSOConfig { pub updated_at: DateTime, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct AuditLog { + pub id: Uuid, + pub organization_id: Option, + pub user_id: Option, + pub action: String, + pub entity_type: String, + pub entity_id: Uuid, + pub event_type: String, + pub old_data: Option, + pub new_data: Option, + pub ip_address: Option, + pub user_agent: Option, + pub changes: Option, + pub created_at: DateTime, +} + #[cfg(test)] mod tests { use super::*; diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index ce602eb..c558dfb 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -13,6 +13,10 @@ import FillInTheBlanksPlayer from "@/components/blocks/FillInTheBlanksPlayer"; import MatchingPlayer from "@/components/blocks/MatchingPlayer"; import OrderingPlayer from "@/components/blocks/OrderingPlayer"; import ShortAnswerPlayer from "@/components/blocks/ShortAnswerPlayer"; +import CodeExercisePlayer from "@/components/blocks/CodeExercisePlayer"; +import HotspotPlayer from "@/components/blocks/HotspotPlayer"; +import MemoryPlayer from "@/components/blocks/MemoryPlayer"; +import DocumentPlayer from "@/components/blocks/DocumentPlayer"; // Added import import InteractiveTranscript from "@/components/InteractiveTranscript"; import { ListMusic } from "lucide-react"; @@ -69,6 +73,31 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les } }; + const handleBlockComplete = async (blockId: string, score: number) => { + if (user) { + try { + // Update the score for the specific block in metadata + const currentBlockScores = (userGrade?.metadata?.block_scores as Record) || {}; + const newBlockScores = { + ...currentBlockScores, + [blockId]: score + }; + + const res = await lmsApi.submitScore( + user.id, + params.id, + params.lessonId, + userGrade?.score || 0, // Keep overall score for now, or calculate average/sum + { ...userGrade?.metadata, block_scores: newBlockScores } + ); + setUserGrade(res); + console.log(`Score for block ${blockId} submitted: ${score}`); + } catch (err) { + console.error(`Failed to submit score for block ${blockId}`, err); + } + } + }; + return (
{/* Navigation Sidebar */} @@ -147,104 +176,143 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les {/* Render Blocks */} {(lesson.metadata?.blocks || []).length > 0 ? (
- {lesson.metadata?.blocks?.map((block) => ( -
- {block.type === 'description' && ( - - )} - {block.type === 'media' && ( - )[block.id] || 0 - : 0 - } - onPlay={async () => { - if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) { - const currentPlayCounts = (userGrade?.metadata?.play_counts as Record) || {}; - const newPlayCounts = { - ...currentPlayCounts, - [block.id]: (currentPlayCounts[block.id] || 0) + 1 - }; - - try { - const res = await lmsApi.submitScore( - user.id, - params.id, - params.lessonId, - userGrade?.score || 0, - { ...userGrade?.metadata, play_counts: newPlayCounts } - ); - setUserGrade(res); - } catch (err) { - console.error("Error al guardar el recuento de reproducciones", err); + {lesson.metadata?.blocks?.map((block) => { + const renderBlock = () => { + switch (block.type) { + case 'description': + return ; + case 'media': + return ( + )[block.id] || 0 + : 0 } - } - }} - /> - )} - {block.type === 'quiz' && ( - )[block.id] || 0 - : 0 - } - onAttempt={async () => { - if (user) { - const currentAttempts = (userGrade?.metadata?.block_attempts as Record) || {}; - const newAttempts = { - ...currentAttempts, - [block.id]: (currentAttempts[block.id] || 0) + 1 - }; + onPlay={async () => { + if (user && lesson.max_attempts && (!userGrade || userGrade.attempts_count < lesson.max_attempts)) { + const currentPlayCounts = (userGrade?.metadata?.play_counts as Record) || {}; + const newPlayCounts = { + ...currentPlayCounts, + [block.id]: (currentPlayCounts[block.id] || 0) + 1 + }; - try { - const res = await lmsApi.submitScore( - user.id, - params.id, - params.lessonId, - userGrade?.score || 0, - { ...userGrade?.metadata, block_attempts: newAttempts } - ); - setUserGrade(res); - } catch (err) { - console.error("Error al guardar los intentos del bloque", err); + try { + const res = await lmsApi.submitScore( + user.id, + params.id, + params.lessonId, + userGrade?.score || 0, + { ...userGrade?.metadata, play_counts: newPlayCounts } + ); + setUserGrade(res); + } catch (err) { + console.error("Error al guardar el recuento de reproducciones", err); + } + } + }} + /> + ); + case 'document': + return ; + case 'quiz': + return ( + )[block.id] || 0 + : 0 } - } - }} - /> - )} - {block.type === 'fill-in-the-blanks' && ( - - )} - {block.type === 'matching' && ( - - )} - {block.type === 'ordering' && ( - - )} - {block.type === 'short-answer' && ( - - )} -
- ))} + onAttempt={async () => { + if (user) { + const currentAttempts = (userGrade?.metadata?.block_attempts as Record) || {}; + const newAttempts = { + ...currentAttempts, + [block.id]: (currentAttempts[block.id] || 0) + 1 + }; + + try { + const res = await lmsApi.submitScore( + user.id, + params.id, + params.lessonId, + userGrade?.score || 0, + { ...userGrade?.metadata, block_attempts: newAttempts } + ); + setUserGrade(res); + } catch (err) { + console.error("Error al guardar los intentos del bloque", err); + } + } + }} + /> + ); + case 'fill-in-the-blanks': + return ; + case 'matching': + return ; + case 'ordering': + return ; + case 'short-answer': + return ( + + ); + case 'code': + return ( + handleBlockComplete(block.id, score)} + /> + ); + case 'hotspot': + return ( + handleBlockComplete(block.id, score)} + /> + ); + case 'memory-match': + return ( + handleBlockComplete(block.id, score)} + /> + ); + default: + return
Tipo de Bloque Desconocido: {block.type}
; + } + }; + + return ( +
+ {renderBlock()} +
+ ); + })}
) : (
diff --git a/web/experience/src/app/layout.tsx b/web/experience/src/app/layout.tsx index ca51d87..68efdf4 100644 --- a/web/experience/src/app/layout.tsx +++ b/web/experience/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter } from "next/font/google"; import "./globals.css"; import Link from "next/link"; import { AuthProvider } from "@/context/AuthContext"; +import { I18nProvider } from "@/context/I18nContext"; import { BrandingProvider } from "@/context/BrandingContext"; import AuthGuard from "@/components/AuthGuard"; @@ -25,17 +26,19 @@ export default function RootLayout({ - - -
- {children} -
-
-

- Desarrollado por OpenCCB © 2023. Codificación Agente Avanzada. -

-
-
+ + + +
+ {children} +
+
+

+ Desarrollado por OpenCCB © 2023. Codificación Agente Avanzada. +

+
+
+
diff --git a/web/experience/src/app/profile/page.tsx b/web/experience/src/app/profile/page.tsx index 0aaee16..26487a5 100644 --- a/web/experience/src/app/profile/page.tsx +++ b/web/experience/src/app/profile/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useAuth } from "@/context/AuthContext"; +import { useTranslation } from "@/context/I18nContext"; import { lmsApi, CMS_API_URL } from "@/lib/api"; import { Save, @@ -19,6 +20,7 @@ import { } from "lucide-react"; export default function ProfilePage() { + const { t, setLanguage: setContextLanguage } = useTranslation(); const { user, logout } = useAuth(); const [fullName, setFullName] = useState(user?.full_name || ""); const [email, setEmail] = useState(user?.email || ""); @@ -31,6 +33,16 @@ export default function ProfilePage() { const [gamification, setGamification] = useState(null); const fileInputRef = useRef(null); + const fetchGamification = useCallback(async () => { + if (!user) return; + try { + const data = await lmsApi.getGamification(user.id); + setGamification(data); + } catch (err) { + console.error("Failed to fetch gamification:", err); + } + }, [user]); + useEffect(() => { if (user) { setFullName(user.full_name); @@ -40,17 +52,7 @@ export default function ProfilePage() { setAvatarUrl(user.avatar_url || ""); fetchGamification(); } - }, [user]); - - const fetchGamification = async () => { - if (!user) return; - try { - const data = await lmsApi.getGamification(user.id); - setGamification(data); - } catch (err) { - console.error("Failed to fetch gamification:", err); - } - }; + }, [user, fetchGamification]); const getImageUrl = (path?: string) => { if (!path) return ''; @@ -95,7 +97,8 @@ export default function ProfilePage() { avatar_url: avatarUrl }); - setMessage({ type: 'success', text: '¡Perfil actualizado con éxito!' }); + setContextLanguage(language); + setMessage({ type: 'success', text: t('common.save') + '!' }); } catch (err) { console.error(err); setMessage({ type: 'error', text: 'Error al actualizar el perfil.' }); @@ -271,8 +274,8 @@ export default function ProfilePage() { type="button" onClick={() => setLanguage(lang.code)} className={`flex items-center justify-center gap-3 p-4 rounded-2xl border transition-all ${language === lang.code - ? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-500/20' - : 'bg-black/20 border-white/5 text-gray-400 hover:border-white/20 hover:text-white' + ? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-500/20' + : 'bg-black/20 border-white/5 text-gray-400 hover:border-white/20 hover:text-white' }`} > {lang.flag} @@ -284,8 +287,8 @@ export default function ProfilePage() { {message && (
{message.text}
diff --git a/web/experience/src/components/AppHeader.tsx b/web/experience/src/components/AppHeader.tsx index d2864ea..ca0c257 100644 --- a/web/experience/src/components/AppHeader.tsx +++ b/web/experience/src/components/AppHeader.tsx @@ -3,9 +3,12 @@ import Link from "next/link"; import { useBranding } from "@/context/BrandingContext"; import { useAuth } from "@/context/AuthContext"; -import { LogOut } from "lucide-react"; +import { useTranslation } from "@/context/I18nContext"; +import { LogOut, Globe } from "lucide-react"; +import NotificationCenter from "./NotificationCenter"; export default function AppHeader() { + const { t, language, setLanguage } = useTranslation(); const { branding } = useBranding(); const { user, logout } = useAuth(); @@ -27,11 +30,32 @@ export default function AppHeader() {
-