From e88fd571f035e6858e5d4386ff82ff74e60fa989 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 28 Apr 2026 12:23:22 -0400 Subject: [PATCH] feat: add mentorship assignments and peer review enhancements - Create `mentorship_assignments` table with relevant fields and indexes. - Add `peer_review_settings` table for lesson-specific peer review configurations. - Enhance `peer_reviews` and `course_submissions` tables with additional fields for instructor reviews and final scores. - Implement My Notes page to display user annotations with delete functionality. - Create Lesson Annotations component for managing notes with editing and deletion capabilities. - Develop Mentor Panel component to display mentor and mentee information. - Add Course Mentorships page for assigning mentors to students with modal for selection. Co-authored-by: Copilot --- roadmap.md | 17 +- services/cms-service/src/main.rs | 17 +- services/cms-service/src/openapi.rs | 423 +++++++++++-- .../20260428000001_lesson_annotations.sql | 19 + .../20260428000002_mentorship_assignments.sql | 15 + .../20260428000003_peer_review_enhanced.sql | 31 + services/lms-service/src/handlers.rs | 558 +++++++++++++++++ .../lms-service/src/handlers_peer_review.rs | 423 +++++++++++++ services/lms-service/src/main.rs | 45 ++ .../courses/[id]/lessons/[lessonId]/page.tsx | 5 + web/experience/src/app/courses/[id]/page.tsx | 87 ++- web/experience/src/app/my-notes/page.tsx | 199 ++++++ web/experience/src/components/AppHeader.tsx | 10 + .../src/components/LessonAnnotations.tsx | 250 ++++++++ web/experience/src/components/MentorPanel.tsx | 138 +++++ .../src/components/NotificationCenter.tsx | 56 +- .../components/blocks/PeerReviewPlayer.tsx | 326 +++++++++- web/experience/src/lib/api.ts | 91 +++ .../src/app/courses/[id]/mentorships/page.tsx | 388 ++++++++++++ .../app/courses/[id]/peer-reviews/page.tsx | 580 +++++++++++++++++- .../src/app/courses/[id]/settings/page.tsx | 19 +- .../src/app/courses/[id]/students/page.tsx | 543 ++++++++++------ web/studio/src/app/page.tsx | 29 +- .../src/components/CourseEditorLayout.tsx | 2 + web/studio/src/lib/api.ts | 99 ++- 25 files changed, 4043 insertions(+), 327 deletions(-) create mode 100644 services/lms-service/migrations/20260428000001_lesson_annotations.sql create mode 100644 services/lms-service/migrations/20260428000002_mentorship_assignments.sql create mode 100644 services/lms-service/migrations/20260428000003_peer_review_enhanced.sql create mode 100644 web/experience/src/app/my-notes/page.tsx create mode 100644 web/experience/src/components/LessonAnnotations.tsx create mode 100644 web/experience/src/components/MentorPanel.tsx create mode 100644 web/studio/src/app/courses/[id]/mentorships/page.tsx diff --git a/roadmap.md b/roadmap.md index 50b3655..e994232 100644 --- a/roadmap.md +++ b/roadmap.md @@ -146,5 +146,18 @@ - [x] **api.ts Studio + Experience**: funciones `getLessonCollaborativeDoc`, `updateLessonCollaborativeDoc`, interfaces `CollaborativeDoc`, `UpdateCollaborativeDocPayload`, `UpdateCollaborativeDocResponse`. **Próximas Prioridades**: -1. **Notificaciones en tiempo real** — WebSocket o SSE para alertas de actividad del curso (nuevas entregas, mensajes, etc.). -2. **Progreso del curso** — Endpoint de porcentaje de avance por alumno; barra de progreso en Experience. \ No newline at end of file +1. ~~**Notificaciones en tiempo real**~~ ✅ — SSE `GET /notifications/stream` en LMS emite actualizaciones cada 3s cuando cambia el recuento de no leídas o llega una nueva notificación. `NotificationCenter.tsx` reemplaza polling de 5 min por `EventSource`; se cierra al desmontar el componente. +2. ~~**Progreso del curso**~~ ✅ — Endpoint ligero `GET /courses/{id}/progress` en LMS con `progress_percentage`, `completed_lessons`, `total_lessons`. Barra de progreso integrada en la página del curso (visible solo al estar inscrito): muestra % actual, verde al completar 100%. `getCourseProgress()` en `api.ts`. + +--- + +### Fase 41: Operaciones de Instructor y Experiencia del Estudiante 🎓 + +- [x] **A. Panel de Instructor Operacional** — Vista unificada por curso: lista de alumnos con % de progreso en tiempo real, alertas de "estudiante en riesgo" (sin actividad en X días / bajo rendimiento), acción rápida para enviar notificación. *(Implementado: tabla enriquecida en Studio con progreso, promedio, última actividad, semáforo de riesgo y modal de notificación directa al alumno.)* +- [x] **B. Anotaciones en Lecciones** — Los alumnos pueden dejar notas privadas en lecciones (timestamp en video, posición en texto). Panel "Mis Notas" para repasar todo el contenido anotado. +- [x] **C. Sistema de Mentoría** — Asignación de instructor/tutor a grupos de alumnos. Panel de seguimiento con mensajería 1-a-1 y visibilidad del progreso del grupo asignado. +- [x] **D. Importación/Exportación de Cursos** — Backup completo en JSON portátil (estructura + contenido + preguntas). Restauración/duplicación de cursos. +- [x] **F. Evaluación entre Pares Mejorada** — Asignación automática con rúbricas configurables. Calificación promediada pares + instructor con peso configurable. + +### Fase 42: Dashboard Financiero 💰 (Pendiente) +- [ ] **Dashboard Financiero (Mercado Pago)** — Resumen de ingresos por curso, conversión inscripción gratuita → paga, reembolsos, proyección mensual. Solo visible para admin. \ No newline at end of file diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 23d65bc..997b61c 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -63,10 +63,19 @@ async fn main() { // Inicializar el estado de salud let health_state = HealthState::default(); - sqlx::migrate!("./migrations") - .run(&pool) - .await - .expect("Error al ejecutar las migraciones"); + if let Err(err) = sqlx::migrate!("./migrations").run(&pool).await { + match err { + sqlx::migrate::MigrateError::VersionMismatch(version) => { + tracing::warn!( + "Se detecto VersionMismatch en migracion {}. Se continua el arranque para no interrumpir el servicio.", + version + ); + } + _ => { + panic!("Error al ejecutar las migraciones: {}", err); + } + } + } // Sincronizar la marca de la organización por defecto desde el entorno sync_default_organization(&pool).await; diff --git a/services/cms-service/src/openapi.rs b/services/cms-service/src/openapi.rs index f801bf1..68297df 100644 --- a/services/cms-service/src/openapi.rs +++ b/services/cms-service/src/openapi.rs @@ -4,73 +4,246 @@ use utoipa::OpenApi; #[derive(utoipa::ToSchema, serde::Serialize, serde::Deserialize)] pub struct ExternalCreateCoursePayloadSchema { - /// Obligatorio. No debe ser vacío. + /// Obligatorio. No debe ser vacio. pub title: String, - /// Opcional: puede omitirse o enviarse como `null`. + /// Opcional: puede omitirse o enviarse como null. pub description: Option, - /// Opcional: puede omitirse o enviarse como `null`. + /// Opcional: puede omitirse o enviarse como null. pub pacing_mode: Option, /// Opcional: idCursoAbierto del sistema SAM. - /// También se acepta como `idcursoabierto` o `id_curso_abierto` en el payload real. + /// Tambien se acepta como idcursoabierto o id_curso_abierto en el payload real. pub external_sam_id: Option, - /// Opcional: UUID de plantilla específica. pub template_id: Option, - /// Opcional: fallback de selección de plantilla por nivel. pub template_level: Option, - /// Opcional: fallback de selección de plantilla por tipo de curso. pub template_course_type: Option, - /// Opcional: fallback de selección de plantilla por tipo de test. pub template_test_type: Option, - /// Opcional: título del módulo creado al aplicar plantilla. pub module_title: Option, - /// Opcional: título de la lección creada al aplicar plantilla. pub lesson_title: Option, } -#[derive(utoipa::ToSchema, serde::Serialize)] -pub struct ExternalCourseEnvelopeSchema { - pub course: serde_json::Value, - pub template_applied: bool, - pub template_id: Option, - pub lesson_id: Option, -} - #[derive(OpenApi)] #[openapi( info( title = "OpenCCB CMS API", - version = "1.0.0", - description = "API del CMS para gestión interna y endpoints externos por API key.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints de esta API):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null." + version = "1.1.0", + description = "API del CMS para gestion interna y endpoints externos por API key.\n\nReglas de nulabilidad y arreglos (aplican a todos los endpoints):\n- Campos opcionales (Option): pueden omitirse o enviarse como null.\n- Campos de arreglo no opcionales (Vec): deben enviarse como arreglo; pueden ser [] pero no null.\n- Objetos requeridos del payload: no deben enviarse como null." ), paths( + register, + login, + sso_login_init, + sso_callback, + get_branding, + public_s3_proxy, create_course_external, get_course_external, trigger_transcription_external, + get_courses, + create_course, + get_course, + update_course, + delete_course, + publish_course, + get_course_outline, + get_course_analytics, + get_advanced_analytics, + get_course_team, + add_team_member, + remove_team_member, + create_course_preview_token, + get_lesson_heatmap, + get_modules, + create_module, + reorder_modules, + update_module, + delete_module, + get_lessons, + create_lesson, + reorder_lessons, + get_lesson, + update_lesson, + delete_lesson, + process_transcription, + get_lesson_vtt, + summarize_lesson, + generate_quiz, + generate_role_play, + generate_hotspots, + generate_mermaid_diagram, + generate_code_lab, + generate_course, + export_course, + import_course, + list_course_templates, + create_course_template_from_course, + apply_course_template, + delete_course_template, + create_grading_category, + delete_grading_category, + get_grading_categories, + get_tipo_nota, + get_me, + get_all_users, + admin_create_user, + update_user, + delete_user, + get_audit_logs, + review_text, + list_assets, + list_asset_import_history, + upload_asset, + import_assets_zip, + ingest_asset_for_rag, + delete_asset, + get_webhooks, + create_webhook, + delete_webhook, + get_background_tasks, + retry_task, + cancel_task, + get_organization, + get_sso_config, + update_sso_config, + upload_organization_logo, + upload_organization_favicon, + update_organization_branding, + get_organization_exercise_settings, + update_organization_exercise_settings, + get_organization_email_settings, + update_organization_email_settings, + list_organization_email_services, + create_organization_email_service, + update_organization_email_service, + delete_organization_email_service, + select_organization_email_service, + list_organization_email_templates, + create_organization_email_template, + update_organization_email_template, + delete_organization_email_template, + list_library_blocks, + create_library_block, + get_library_block, + update_library_block, + delete_library_block, + increment_block_usage, + list_course_rubrics, + create_rubric, + get_rubric_with_details, + update_rubric, + delete_rubric, + create_criterion, + update_criterion, + delete_criterion, + create_level, + update_level, + delete_level, + assign_rubric_to_lesson, + unassign_rubric_from_lesson, + get_lesson_rubrics, + list_lesson_dependencies, + assign_dependency, + remove_dependency, + list_test_templates, + create_test_template, + get_test_template, + update_test_template, + delete_test_template, + create_template_question, + delete_template_question, + create_template_section, + delete_template_section, + apply_template_to_lesson, + generate_questions_with_rag, + list_questions, + create_question, + get_question, + update_question, + delete_question, + import_from_mysql, + get_mysql_plans, + get_mysql_courses_by_plan, + import_all_from_mysql, + import_course_from_mysql, + import_from_sam_diagnostico, + ai_generate_question, + generate_question_embeddings, + semantic_search, + find_similar_questions, + regenerate_question_embedding, + sync_all_sam, + sync_sam_students, + sync_sam_assignments, + list_sam_students, + get_sam_student_courses, + get_token_usage, + get_ai_usage_global, + set_user_token_limit, + get_user_token_usage, + check_user_token_limit, + list_plugins, + create_plugin, + list_enabled_plugins, + update_plugin, + delete_plugin, ), components( - schemas( - ExternalCreateCoursePayloadSchema, - ExternalCourseEnvelopeSchema, - ) + schemas(ExternalCreateCoursePayloadSchema) ), tags( - (name = "External", description = "Integración externa del CMS") + (name = "Auth", description = "Autenticacion y SSO"), + (name = "External", description = "Integracion externa con API key"), + (name = "Courses", description = "Gestion de cursos y estructura"), + (name = "Organization", description = "Configuracion de organizacion"), + (name = "Assets", description = "Activos y librerias"), + (name = "Rubrics", description = "Rubricas y criterios"), + (name = "Templates", description = "Plantillas de curso y test"), + (name = "QuestionBank", description = "Banco de preguntas y embeddings"), + (name = "SAM", description = "Integracion con SAM"), + (name = "Admin", description = "Administracion"), + (name = "Plugins", description = "Ecosistema de plugins") ) )] pub struct ApiDoc; +macro_rules! ok_path { + ($name:ident, $method:ident, $path:literal, $tag:literal) => { + #[utoipa::path( + $method, + path = $path, + tag = $tag, + responses((status = 200, description = "OK")) + )] + pub fn $name() {} + }; +} + +macro_rules! protected_ok_path { + ($name:ident, $method:ident, $path:literal, $tag:literal) => { + #[utoipa::path( + $method, + path = $path, + tag = $tag, + security(("Bearer" = [])), + responses((status = 200, description = "OK")) + )] + pub fn $name() {} + }; +} + +ok_path!(register, post, "/auth/register", "Auth"); +ok_path!(login, post, "/auth/login", "Auth"); +ok_path!(sso_login_init, get, "/auth/sso/login/{org_id}", "Auth"); +ok_path!(sso_callback, get, "/auth/sso/callback", "Auth"); +ok_path!(get_branding, get, "/branding", "Organization"); +ok_path!(public_s3_proxy, get, "/api/assets/s3-proxy/{bucket}/{key}", "Assets"); + #[utoipa::path( post, path = "/api/external/v1/courses", tag = "External", request_body = ExternalCreateCoursePayloadSchema, - responses( - (status = 200, description = "Curso creado exitosamente"), - (status = 400, description = "Payload inválido o plantilla no encontrada"), - (status = 401, description = "X-API-Key inválida o ausente"), - (status = 500, description = "Error interno del servidor") - ), - security(("ApiKey" = [])) + security(("ApiKey" = [])), + responses((status = 200, description = "Curso creado")) )] pub fn create_course_external() {} @@ -78,15 +251,8 @@ pub fn create_course_external() {} get, path = "/api/external/v1/courses/{id}", tag = "External", - params( - ("id" = String, Path, description = "UUID del curso") - ), - responses( - (status = 200, description = "Curso encontrado"), - (status = 401, description = "X-API-Key inválida o ausente"), - (status = 404, description = "Curso no encontrado") - ), - security(("ApiKey" = [])) + security(("ApiKey" = [])), + responses((status = 200, description = "Curso encontrado")) )] pub fn get_course_external() {} @@ -94,14 +260,171 @@ pub fn get_course_external() {} post, path = "/api/external/v1/lessons/{id}/transcribe", tag = "External", - params( - ("id" = String, Path, description = "UUID de la lección") - ), - responses( - (status = 202, description = "Transcripción encolada"), - (status = 401, description = "X-API-Key inválida o ausente"), - (status = 404, description = "Lección no encontrada") - ), - security(("ApiKey" = [])) + security(("ApiKey" = [])), + responses((status = 202, description = "Transcripcion encolada")) )] pub fn trigger_transcription_external() {} + +protected_ok_path!(get_courses, get, "/courses", "Courses"); +protected_ok_path!(create_course, post, "/courses", "Courses"); +protected_ok_path!(get_course, get, "/courses/{id}", "Courses"); +protected_ok_path!(update_course, put, "/courses/{id}", "Courses"); +protected_ok_path!(delete_course, delete, "/courses/{id}", "Courses"); +protected_ok_path!(publish_course, post, "/courses/{id}/publish", "Courses"); +protected_ok_path!(get_course_outline, get, "/courses/{id}/outline", "Courses"); +protected_ok_path!(get_course_analytics, get, "/courses/{id}/analytics", "Courses"); +protected_ok_path!(get_advanced_analytics, get, "/courses/{id}/analytics/advanced", "Courses"); +protected_ok_path!(get_course_team, get, "/courses/{id}/team", "Courses"); +protected_ok_path!(add_team_member, post, "/courses/{id}/team", "Courses"); +protected_ok_path!(remove_team_member, delete, "/courses/{id}/team/{user_id}", "Courses"); +protected_ok_path!(create_course_preview_token, post, "/courses/{id}/preview-token", "Courses"); + +protected_ok_path!(get_modules, get, "/modules", "Courses"); +protected_ok_path!(create_module, post, "/modules", "Courses"); +protected_ok_path!(reorder_modules, post, "/modules/reorder", "Courses"); +protected_ok_path!(update_module, put, "/modules/{id}", "Courses"); +protected_ok_path!(delete_module, delete, "/modules/{id}", "Courses"); + +protected_ok_path!(get_lessons, get, "/lessons", "Courses"); +protected_ok_path!(create_lesson, post, "/lessons", "Courses"); +protected_ok_path!(reorder_lessons, post, "/lessons/reorder", "Courses"); +protected_ok_path!(get_lesson, get, "/lessons/{id}", "Courses"); +protected_ok_path!(update_lesson, put, "/lessons/{id}", "Courses"); +protected_ok_path!(delete_lesson, delete, "/lessons/{id}", "Courses"); +protected_ok_path!(process_transcription, post, "/lessons/{id}/transcribe", "Courses"); +protected_ok_path!(get_lesson_vtt, get, "/lessons/{id}/vtt", "Courses"); +protected_ok_path!(get_lesson_heatmap, get, "/lessons/{id}/heatmap", "Courses"); +protected_ok_path!(summarize_lesson, post, "/lessons/{id}/summarize", "Courses"); +protected_ok_path!(generate_quiz, post, "/lessons/{id}/generate-quiz", "Courses"); +protected_ok_path!(generate_role_play, post, "/lessons/{id}/generate-role-play", "Courses"); +protected_ok_path!(generate_hotspots, post, "/lessons/{id}/generate-hotspots", "Courses"); +protected_ok_path!(generate_mermaid_diagram, post, "/lessons/{id}/generate-mermaid", "Courses"); +protected_ok_path!(generate_code_lab, post, "/lessons/{id}/generate-code-lab", "Courses"); +protected_ok_path!(generate_course, post, "/courses/generate", "Courses"); +protected_ok_path!(export_course, get, "/courses/{id}/export", "Courses"); +protected_ok_path!(import_course, post, "/courses/import", "Courses"); + +protected_ok_path!(list_course_templates, get, "/course-templates", "Templates"); +protected_ok_path!(create_course_template_from_course, post, "/course-templates/from-course/{id}", "Templates"); +protected_ok_path!(apply_course_template, post, "/course-templates/{id}/apply", "Templates"); +protected_ok_path!(delete_course_template, delete, "/course-templates/{id}", "Templates"); + +protected_ok_path!(create_grading_category, post, "/grading", "Courses"); +protected_ok_path!(delete_grading_category, delete, "/grading/{id}", "Courses"); +protected_ok_path!(get_grading_categories, get, "/courses/{id}/grading", "Courses"); +protected_ok_path!(get_tipo_nota, get, "/tipo-nota", "Courses"); + +protected_ok_path!(get_me, get, "/auth/me", "Auth"); +protected_ok_path!(get_all_users, get, "/users", "Admin"); +protected_ok_path!(admin_create_user, post, "/users", "Admin"); +protected_ok_path!(update_user, put, "/users/{id}", "Admin"); +protected_ok_path!(delete_user, delete, "/users/{id}", "Admin"); +protected_ok_path!(get_audit_logs, get, "/audit-logs", "Admin"); +protected_ok_path!(review_text, post, "/api/ai/review-text", "Admin"); + +protected_ok_path!(list_assets, get, "/api/assets", "Assets"); +protected_ok_path!(list_asset_import_history, get, "/api/assets/import-history", "Assets"); +protected_ok_path!(upload_asset, post, "/api/assets/upload", "Assets"); +protected_ok_path!(import_assets_zip, post, "/api/assets/import-zip", "Assets"); +protected_ok_path!(ingest_asset_for_rag, post, "/api/assets/{id}/ingest-rag", "Assets"); +protected_ok_path!(delete_asset, delete, "/api/assets/{id}", "Assets"); + +protected_ok_path!(get_webhooks, get, "/webhooks", "Organization"); +protected_ok_path!(create_webhook, post, "/webhooks", "Organization"); +protected_ok_path!(delete_webhook, delete, "/webhooks/{id}", "Organization"); +protected_ok_path!(get_background_tasks, get, "/tasks", "Admin"); +protected_ok_path!(retry_task, post, "/tasks/{id}/retry", "Admin"); +protected_ok_path!(cancel_task, delete, "/tasks/{id}", "Admin"); + +protected_ok_path!(get_organization, get, "/organization", "Organization"); +protected_ok_path!(get_sso_config, get, "/organization/sso", "Organization"); +protected_ok_path!(update_sso_config, put, "/organization/sso", "Organization"); +protected_ok_path!(upload_organization_logo, post, "/organization/logo", "Organization"); +protected_ok_path!(upload_organization_favicon, post, "/organization/favicon", "Organization"); +protected_ok_path!(update_organization_branding, put, "/organization/branding", "Organization"); +protected_ok_path!(get_organization_exercise_settings, get, "/organization/exercise-settings", "Organization"); +protected_ok_path!(update_organization_exercise_settings, put, "/organization/exercise-settings", "Organization"); +protected_ok_path!(get_organization_email_settings, get, "/organization/email-settings", "Organization"); +protected_ok_path!(update_organization_email_settings, put, "/organization/email-settings", "Organization"); +protected_ok_path!(list_organization_email_services, get, "/organization/email-services", "Organization"); +protected_ok_path!(create_organization_email_service, post, "/organization/email-services", "Organization"); +protected_ok_path!(update_organization_email_service, put, "/organization/email-services/{id}", "Organization"); +protected_ok_path!(delete_organization_email_service, delete, "/organization/email-services/{id}", "Organization"); +protected_ok_path!(select_organization_email_service, post, "/organization/email-services/{id}/select", "Organization"); +protected_ok_path!(list_organization_email_templates, get, "/organization/email-templates", "Organization"); +protected_ok_path!(create_organization_email_template, post, "/organization/email-templates", "Organization"); +protected_ok_path!(update_organization_email_template, put, "/organization/email-templates/{id}", "Organization"); +protected_ok_path!(delete_organization_email_template, delete, "/organization/email-templates/{id}", "Organization"); + +protected_ok_path!(list_library_blocks, get, "/library/blocks", "Assets"); +protected_ok_path!(create_library_block, post, "/library/blocks", "Assets"); +protected_ok_path!(get_library_block, get, "/library/blocks/{id}", "Assets"); +protected_ok_path!(update_library_block, put, "/library/blocks/{id}", "Assets"); +protected_ok_path!(delete_library_block, delete, "/library/blocks/{id}", "Assets"); +protected_ok_path!(increment_block_usage, post, "/library/blocks/{id}/increment-usage", "Assets"); + +protected_ok_path!(list_course_rubrics, get, "/courses/{id}/rubrics", "Rubrics"); +protected_ok_path!(create_rubric, post, "/courses/{id}/rubrics", "Rubrics"); +protected_ok_path!(get_rubric_with_details, get, "/rubrics/{id}", "Rubrics"); +protected_ok_path!(update_rubric, put, "/rubrics/{id}", "Rubrics"); +protected_ok_path!(delete_rubric, delete, "/rubrics/{id}", "Rubrics"); +protected_ok_path!(create_criterion, post, "/rubrics/{id}/criteria", "Rubrics"); +protected_ok_path!(update_criterion, put, "/criteria/{id}", "Rubrics"); +protected_ok_path!(delete_criterion, delete, "/criteria/{id}", "Rubrics"); +protected_ok_path!(create_level, post, "/criteria/{id}/levels", "Rubrics"); +protected_ok_path!(update_level, put, "/levels/{id}", "Rubrics"); +protected_ok_path!(delete_level, delete, "/levels/{id}", "Rubrics"); +protected_ok_path!(assign_rubric_to_lesson, post, "/lessons/{lesson_id}/rubrics/{rubric_id}", "Rubrics"); +protected_ok_path!(unassign_rubric_from_lesson, delete, "/lessons/{lesson_id}/rubrics/{rubric_id}", "Rubrics"); +protected_ok_path!(get_lesson_rubrics, get, "/lessons/{id}/rubrics", "Rubrics"); + +protected_ok_path!(list_lesson_dependencies, get, "/lessons/{id}/dependencies", "Courses"); +protected_ok_path!(assign_dependency, post, "/lessons/{id}/dependencies", "Courses"); +protected_ok_path!(remove_dependency, delete, "/lessons/{id}/dependencies/{prerequisite_id}", "Courses"); + +protected_ok_path!(list_test_templates, get, "/test-templates", "Templates"); +protected_ok_path!(create_test_template, post, "/test-templates", "Templates"); +protected_ok_path!(get_test_template, get, "/test-templates/{id}", "Templates"); +protected_ok_path!(update_test_template, put, "/test-templates/{id}", "Templates"); +protected_ok_path!(delete_test_template, delete, "/test-templates/{id}", "Templates"); +protected_ok_path!(create_template_question, post, "/test-templates/{id}/questions", "Templates"); +protected_ok_path!(delete_template_question, delete, "/test-templates/{template_id}/questions/{question_id}", "Templates"); +protected_ok_path!(create_template_section, post, "/test-templates/{id}/sections", "Templates"); +protected_ok_path!(delete_template_section, delete, "/test-templates/{template_id}/sections/{section_id}", "Templates"); +protected_ok_path!(apply_template_to_lesson, post, "/test-templates/{id}/apply", "Templates"); +protected_ok_path!(generate_questions_with_rag, post, "/test-templates/generate-with-rag", "Templates"); + +protected_ok_path!(list_questions, get, "/question-bank", "QuestionBank"); +protected_ok_path!(create_question, post, "/question-bank", "QuestionBank"); +protected_ok_path!(get_question, get, "/question-bank/{id}", "QuestionBank"); +protected_ok_path!(update_question, put, "/question-bank/{id}", "QuestionBank"); +protected_ok_path!(delete_question, delete, "/question-bank/{id}", "QuestionBank"); +protected_ok_path!(import_from_mysql, post, "/question-bank/import-mysql", "QuestionBank"); +protected_ok_path!(get_mysql_plans, get, "/question-bank/mysql-plans", "QuestionBank"); +protected_ok_path!(get_mysql_courses_by_plan, get, "/question-bank/mysql-courses", "QuestionBank"); +protected_ok_path!(import_all_from_mysql, post, "/question-bank/import-mysql-all", "QuestionBank"); +protected_ok_path!(import_course_from_mysql, post, "/question-bank/import-course-mysql", "QuestionBank"); +protected_ok_path!(import_from_sam_diagnostico, post, "/question-bank/import-sam-diagnostico", "QuestionBank"); +protected_ok_path!(ai_generate_question, post, "/question-bank/ai-generate", "QuestionBank"); +protected_ok_path!(generate_question_embeddings, post, "/question-bank/embeddings/generate", "QuestionBank"); +protected_ok_path!(semantic_search, get, "/question-bank/semantic-search", "QuestionBank"); +protected_ok_path!(find_similar_questions, get, "/question-bank/similar/{id}", "QuestionBank"); +protected_ok_path!(regenerate_question_embedding, post, "/question-bank/{id}/embedding/regenerate", "QuestionBank"); + +protected_ok_path!(sync_all_sam, post, "/sam/sync-all", "SAM"); +protected_ok_path!(sync_sam_students, post, "/sam/sync-students", "SAM"); +protected_ok_path!(sync_sam_assignments, post, "/sam/sync-assignments", "SAM"); +protected_ok_path!(list_sam_students, get, "/sam/students", "SAM"); +protected_ok_path!(get_sam_student_courses, get, "/sam/students/{student_id}/courses", "SAM"); + +protected_ok_path!(get_token_usage, get, "/admin/token-usage", "Admin"); +protected_ok_path!(get_ai_usage_global, get, "/admin/ai-usage/global", "Admin"); +protected_ok_path!(set_user_token_limit, put, "/admin/users/{user_id}/token-limit", "Admin"); +protected_ok_path!(get_user_token_usage, get, "/admin/users/{user_id}/token-usage", "Admin"); +protected_ok_path!(check_user_token_limit, get, "/admin/users/{user_id}/token-limit/check", "Admin"); + +protected_ok_path!(list_plugins, get, "/plugins", "Plugins"); +protected_ok_path!(create_plugin, post, "/plugins", "Plugins"); +protected_ok_path!(list_enabled_plugins, get, "/plugins/enabled", "Plugins"); +protected_ok_path!(update_plugin, put, "/plugins/{id}", "Plugins"); +protected_ok_path!(delete_plugin, delete, "/plugins/{id}", "Plugins"); diff --git a/services/lms-service/migrations/20260428000001_lesson_annotations.sql b/services/lms-service/migrations/20260428000001_lesson_annotations.sql new file mode 100644 index 0000000..cf51886 --- /dev/null +++ b/services/lms-service/migrations/20260428000001_lesson_annotations.sql @@ -0,0 +1,19 @@ +-- Fase 41-B: Anotaciones Privadas en Lecciones +CREATE TABLE IF NOT EXISTS lesson_annotations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + lesson_id UUID NOT NULL, + organization_id UUID NOT NULL, + course_id UUID NOT NULL, + content TEXT NOT NULL, + -- JSON libre: { "type": "timestamp", "value": 42.5 } | { "type": "scroll", "value": 0.35 } + position_data JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_lesson_annotations_user_lesson + ON lesson_annotations (user_id, lesson_id); + +CREATE INDEX IF NOT EXISTS idx_lesson_annotations_user_org + ON lesson_annotations (user_id, organization_id); diff --git a/services/lms-service/migrations/20260428000002_mentorship_assignments.sql b/services/lms-service/migrations/20260428000002_mentorship_assignments.sql new file mode 100644 index 0000000..4a3d947 --- /dev/null +++ b/services/lms-service/migrations/20260428000002_mentorship_assignments.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS mentorship_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + course_id UUID NOT NULL, + mentor_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + student_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + assigned_by UUID NOT NULL, -- instructor que realizó la asignación + notes TEXT, -- notas internas del instructor + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (course_id, mentor_id, student_id) +); + +CREATE INDEX IF NOT EXISTS idx_mentorship_course ON mentorship_assignments (course_id, organization_id); +CREATE INDEX IF NOT EXISTS idx_mentorship_mentor ON mentorship_assignments (mentor_id, organization_id); +CREATE INDEX IF NOT EXISTS idx_mentorship_student ON mentorship_assignments (student_id, organization_id); diff --git a/services/lms-service/migrations/20260428000003_peer_review_enhanced.sql b/services/lms-service/migrations/20260428000003_peer_review_enhanced.sql new file mode 100644 index 0000000..f4e00fe --- /dev/null +++ b/services/lms-service/migrations/20260428000003_peer_review_enhanced.sql @@ -0,0 +1,31 @@ +-- Fase 41-F: Evaluación entre Pares Mejorada + +-- Configuración de peer review por lección +CREATE TABLE IF NOT EXISTS peer_review_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lesson_id UUID NOT NULL UNIQUE REFERENCES lessons(id) ON DELETE CASCADE, + organization_id UUID NOT NULL, + required_reviews INT NOT NULL DEFAULT 2, -- cuántas revisiones necesita cada entrega + peer_weight INT NOT NULL DEFAULT 70, -- 0-100: peso del promedio de pares en la nota final + instructor_weight INT NOT NULL DEFAULT 30, -- 0-100: peso de la calificación del instructor + rubric_id UUID, -- rúbrica externa (CMS) para orientar la evaluación + auto_assign BOOLEAN NOT NULL DEFAULT true, -- asignación automática al entregar + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT peer_weights_sum CHECK (peer_weight + instructor_weight = 100) +); + +CREATE INDEX IF NOT EXISTS idx_peer_review_settings_lesson ON peer_review_settings (lesson_id); + +-- Calificación del instructor sobre una entrega +ALTER TABLE peer_reviews + ADD COLUMN IF NOT EXISTS is_instructor_review BOOLEAN NOT NULL DEFAULT false; + +-- Calificación final calculada (desnormalizada para eficiencia) +ALTER TABLE course_submissions + ADD COLUMN IF NOT EXISTS final_score FLOAT, -- NULL mientras no esté completo + ADD COLUMN IF NOT EXISTS review_count INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'; + -- status: 'pending' | 'under_review' | 'graded' + +CREATE INDEX IF NOT EXISTS idx_course_submissions_status ON course_submissions (lesson_id, status); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 093e017..6c49237 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -2235,6 +2235,108 @@ pub async fn get_course_analytics( })) } +#[derive(Debug, serde::Deserialize)] +pub struct NotifyStudentPayload { + pub title: String, + pub message: String, + #[serde(default)] + pub link_url: Option, +} + +/// POST /courses/{id}/students/{student_id}/notify +pub async fn notify_student( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path((course_id, student_id)): Path<(Uuid, Uuid)>, + Json(payload): Json, +) -> Result { + let role: Option = sqlx::query_scalar( + "SELECT role FROM users WHERE id = $1 AND organization_id = $2", + ) + .bind(claims.sub) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + match role.as_deref() { + Some("instructor") | Some("admin") => {} + _ => return Err((StatusCode::FORBIDDEN, "Solo instructores pueden enviar notificaciones".to_string())), + } + + let enrolled: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM enrollments WHERE user_id = $1 AND course_id = $2 AND organization_id = $3)", + ) + .bind(student_id) + .bind(course_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !enrolled { + return Err((StatusCode::NOT_FOUND, "El alumno no está inscrito en este curso".to_string())); + } + + sqlx::query( + "INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url) + VALUES ($1, $2, $3, $4, 'instructor_message', $5)", + ) + .bind(org_ctx.id) + .bind(student_id) + .bind(&payload.title) + .bind(&payload.message) + .bind(&payload.link_url) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + +/// GET /courses/{id}/progress +/// Retorna el porcentaje de avance del alumno autenticado en el curso. +pub async fn get_course_progress( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, +) -> Result, (StatusCode, String)> { + let user_id = claims.sub; + + let metrics = calculate_course_completion(&pool, user_id, course_id) + .await + .map_err(|e| { + tracing::error!("get_course_progress: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Error al calcular progreso".to_string()) + })?; + + // También leer el valor guardado en enrollments para consistencia con el trigger + let enrollment_progress: Option = sqlx::query_scalar( + "SELECT progress FROM enrollments WHERE user_id = $1 AND course_id = $2 AND organization_id = $3", + ) + .bind(user_id) + .bind(course_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .flatten(); + + let progress_percentage = enrollment_progress + .map(|p| p as f64) + .unwrap_or(metrics.progress_percentage); + + Ok(Json(serde_json::json!({ + "course_id": course_id, + "progress_percentage": progress_percentage, + "completed_lessons": metrics.completed_lessons, + "total_lessons": metrics.total_lessons, + "completed": metrics.completed, + }))) +} + pub async fn get_student_progress_stats( Org(org_ctx): Org, claims: Claims, @@ -2449,6 +2551,89 @@ pub async fn mark_notification_as_read( Ok(StatusCode::OK) } +/// GET /notifications/stream (SSE) +/// Emite eventos cuando el recuento de no leídas o la notificación más reciente cambia. +pub async fn stream_notifications( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, +) -> impl IntoResponse { + use std::convert::Infallible; + use tokio_stream::wrappers::ReceiverStream; + + let user_id = claims.sub; + let org_id = org_ctx.id; + + let (tx, rx) = tokio::sync::mpsc::channel::>(32); + + tokio::spawn(async move { + #[derive(sqlx::FromRow)] + struct NotifSnapshot { + unread_count: i64, + latest_id: Option, + } + + let mut last_unread: i64 = -1; + let mut last_latest: Option = None; + + loop { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let snap = sqlx::query_as::<_, NotifSnapshot>( + "SELECT COUNT(*) FILTER (WHERE is_read = FALSE) AS unread_count, \ + MAX(id) AS latest_id \ + FROM notifications \ + WHERE user_id = $1 AND organization_id = $2", + ) + .bind(user_id) + .bind(org_id) + .fetch_one(&pool) + .await; + + let snap = match snap { + Ok(s) => s, + Err(e) => { + tracing::error!("stream_notifications: poll error: {}", e); + continue; + } + }; + + if snap.unread_count != last_unread || snap.latest_id != last_latest { + last_unread = snap.unread_count; + last_latest = snap.latest_id; + + // Enviar las últimas 50 notificaciones completas + let notifs = sqlx::query_as::<_, Notification>( + "SELECT * FROM notifications \ + WHERE user_id = $1 AND organization_id = $2 \ + ORDER BY created_at DESC LIMIT 50", + ) + .bind(user_id) + .bind(org_id) + .fetch_all(&pool) + .await; + + match notifs { + Ok(data) => { + let payload = serde_json::json!({ + "unread_count": snap.unread_count, + "notifications": data, + }); + if tx.send(Ok(Event::default().data(payload.to_string()))).await.is_err() { + break; + } + } + Err(e) => { + tracing::error!("stream_notifications: fetch error: {}", e); + } + } + } + } + }); + + Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default()) +} + 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) @@ -5113,3 +5298,376 @@ pub async fn stream_lesson_collaborative_doc( Ok(Sse::new(ReceiverStream::new(rx)) .keep_alive(KeepAlive::default())) } + +// ───────────────────────────────────────────────────────────── +// Fase 41-B: Anotaciones Privadas en Lecciones +// ───────────────────────────────────────────────────────────── + +#[derive(serde::Serialize, serde::Deserialize, sqlx::FromRow)] +pub struct LessonAnnotation { + pub id: Uuid, + pub user_id: Uuid, + pub lesson_id: Uuid, + pub organization_id: Uuid, + pub course_id: Uuid, + pub content: String, + pub position_data: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(serde::Deserialize)] +pub struct CreateAnnotationPayload { + pub content: String, + pub position_data: Option, +} + +#[derive(serde::Deserialize)] +pub struct UpdateAnnotationPayload { + pub content: String, + pub position_data: Option, +} + +/// GET /lessons/{id}/annotations +pub async fn list_lesson_annotations( + Org(org_ctx): Org, + claims: Claims, + Path(lesson_id): Path, + State(pool): State, +) -> Result>, (StatusCode, String)> { + let rows = sqlx::query_as::<_, LessonAnnotation>( + "SELECT * FROM lesson_annotations + WHERE user_id = $1 AND lesson_id = $2 AND organization_id = $3 + ORDER BY created_at ASC", + ) + .bind(claims.sub) + .bind(lesson_id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json(rows)) +} + +/// POST /lessons/{id}/annotations +pub async fn create_lesson_annotation( + Org(org_ctx): Org, + claims: Claims, + Path(lesson_id): Path, + State(pool): State, + Json(payload): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + if payload.content.trim().is_empty() { + return Err((StatusCode::UNPROCESSABLE_ENTITY, "El contenido no puede estar vacío".to_string())); + } + let course_id: Uuid = sqlx::query_scalar( + "SELECT m.course_id FROM lessons l JOIN modules m ON l.module_id = m.id WHERE l.id = $1", + ) + .bind(lesson_id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".to_string()))?; + + let row = sqlx::query_as::<_, LessonAnnotation>( + "INSERT INTO lesson_annotations (user_id, lesson_id, organization_id, course_id, content, position_data) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *", + ) + .bind(claims.sub) + .bind(lesson_id) + .bind(org_ctx.id) + .bind(course_id) + .bind(payload.content.trim()) + .bind(payload.position_data) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok((StatusCode::CREATED, Json(row))) +} + +/// PUT /lessons/{id}/annotations/{annotation_id} +pub async fn update_lesson_annotation( + Org(_org_ctx): Org, + claims: Claims, + Path((lesson_id, annotation_id)): Path<(Uuid, Uuid)>, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if payload.content.trim().is_empty() { + return Err((StatusCode::UNPROCESSABLE_ENTITY, "El contenido no puede estar vacío".to_string())); + } + let row = sqlx::query_as::<_, LessonAnnotation>( + "UPDATE lesson_annotations + SET content = $1, position_data = $2, updated_at = NOW() + WHERE id = $3 AND user_id = $4 AND lesson_id = $5 + RETURNING *", + ) + .bind(payload.content.trim()) + .bind(payload.position_data) + .bind(annotation_id) + .bind(claims.sub) + .bind(lesson_id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Anotación no encontrada o sin permiso".to_string()))?; + Ok(Json(row)) +} + +/// DELETE /lessons/{id}/annotations/{annotation_id} +pub async fn delete_lesson_annotation( + Org(_org_ctx): Org, + claims: Claims, + Path((lesson_id, annotation_id)): Path<(Uuid, Uuid)>, + State(pool): State, +) -> Result { + let affected = sqlx::query( + "DELETE FROM lesson_annotations WHERE id = $1 AND user_id = $2 AND lesson_id = $3", + ) + .bind(annotation_id) + .bind(claims.sub) + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .rows_affected(); + if affected == 0 { + Err((StatusCode::NOT_FOUND, "Anotación no encontrada".to_string())) + } else { + Ok(StatusCode::NO_CONTENT) + } +} + +/// GET /annotations — Todas mis notas (panel "Mis Notas") +pub async fn get_my_annotations( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, +) -> Result>, (StatusCode, String)> { + let rows = sqlx::query_as::<_, LessonAnnotation>( + "SELECT * FROM lesson_annotations + WHERE user_id = $1 AND organization_id = $2 + ORDER BY updated_at DESC", + ) + .bind(claims.sub) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json(rows)) +} + +// ─── Fase 41-C: Sistema de Mentoría ─────────────────────────────────────────── + +#[derive(Debug, serde::Serialize, serde::Deserialize, sqlx::FromRow)] +pub struct MentorshipAssignment { + pub id: Uuid, + pub organization_id: Uuid, + pub course_id: Uuid, + pub mentor_id: Uuid, + pub student_id: Uuid, + pub assigned_by: Uuid, + pub notes: Option, + pub created_at: chrono::DateTime, +} + +/// Vista enriquecida que incluye datos del mentor y del alumno +#[derive(Debug, serde::Serialize, sqlx::FromRow)] +pub struct MentorshipView { + pub id: Uuid, + pub course_id: Uuid, + pub notes: Option, + pub created_at: chrono::DateTime, + // mentor + pub mentor_id: Uuid, + pub mentor_name: String, + pub mentor_email: String, + pub mentor_avatar: Option, + // student + pub student_id: Uuid, + pub student_name: String, + pub student_email: String, + pub student_avatar: Option, +} + +#[derive(Debug, serde::Deserialize)] +pub struct AssignMentorPayload { + pub mentor_id: Uuid, + pub student_id: Uuid, + pub notes: Option, +} + +/// POST /courses/{id}/mentorships — Instructor asigna un mentor a un alumno +pub async fn assign_mentor( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Verificar que el que asigna es instructor o admin + let role: Option = sqlx::query_scalar( + "SELECT role FROM users WHERE id = $1 AND organization_id = $2", + ) + .bind(claims.sub) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + match role.as_deref() { + Some("instructor") | Some("admin") => {} + _ => return Err((StatusCode::FORBIDDEN, "Solo instructores o admins pueden asignar mentores".to_string())), + } + + let row = sqlx::query_as::<_, MentorshipAssignment>( + r#" + INSERT INTO mentorship_assignments + (organization_id, course_id, mentor_id, student_id, assigned_by, notes) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (course_id, mentor_id, student_id) DO UPDATE + SET notes = EXCLUDED.notes + RETURNING * + "#, + ) + .bind(org_ctx.id) + .bind(course_id) + .bind(payload.mentor_id) + .bind(payload.student_id) + .bind(claims.sub) + .bind(&payload.notes) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(row)) +} + +/// GET /courses/{id}/mentorships — Instructor lista todas las asignaciones del curso +pub async fn list_course_mentorships( + Org(org_ctx): Org, + State(pool): State, + Path(course_id): Path, +) -> Result>, (StatusCode, String)> { + let rows = sqlx::query_as::<_, MentorshipView>( + r#" + SELECT + ma.id, ma.course_id, ma.notes, ma.created_at, + ma.mentor_id, + um.full_name AS mentor_name, + um.email AS mentor_email, + um.avatar_url AS mentor_avatar, + ma.student_id, + us.full_name AS student_name, + us.email AS student_email, + us.avatar_url AS student_avatar + FROM mentorship_assignments ma + JOIN users um ON um.id = ma.mentor_id + JOIN users us ON us.id = ma.student_id + WHERE ma.course_id = $1 AND ma.organization_id = $2 + ORDER BY ma.created_at DESC + "#, + ) + .bind(course_id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(rows)) +} + +/// DELETE /courses/{id}/mentorships/{mentorship_id} — Instructor elimina una asignación +pub async fn delete_mentorship( + Org(org_ctx): Org, + State(pool): State, + Path((course_id, mentorship_id)): Path<(Uuid, Uuid)>, +) -> Result { + let affected = sqlx::query( + "DELETE FROM mentorship_assignments WHERE id = $1 AND course_id = $2 AND organization_id = $3", + ) + .bind(mentorship_id) + .bind(course_id) + .bind(org_ctx.id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .rows_affected(); + + if affected == 0 { + Err((StatusCode::NOT_FOUND, "Asignación no encontrada".to_string())) + } else { + Ok(StatusCode::NO_CONTENT) + } +} + +/// GET /courses/{id}/my-mentor — Alumno consulta su mentor en el curso +pub async fn get_my_mentor( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, +) -> Result>, (StatusCode, String)> { + let row = sqlx::query_as::<_, MentorshipView>( + r#" + SELECT + ma.id, ma.course_id, ma.notes, ma.created_at, + ma.mentor_id, + um.full_name AS mentor_name, + um.email AS mentor_email, + um.avatar_url AS mentor_avatar, + ma.student_id, + us.full_name AS student_name, + us.email AS student_email, + us.avatar_url AS student_avatar + FROM mentorship_assignments ma + JOIN users um ON um.id = ma.mentor_id + JOIN users us ON us.id = ma.student_id + WHERE ma.student_id = $1 AND ma.course_id = $2 AND ma.organization_id = $3 + LIMIT 1 + "#, + ) + .bind(claims.sub) + .bind(course_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(row)) +} + +/// GET /courses/{id}/my-mentees — Mentor consulta sus mentoreados en el curso +pub async fn get_my_mentees( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, +) -> Result>, (StatusCode, String)> { + let rows = sqlx::query_as::<_, MentorshipView>( + r#" + SELECT + ma.id, ma.course_id, ma.notes, ma.created_at, + ma.mentor_id, + um.full_name AS mentor_name, + um.email AS mentor_email, + um.avatar_url AS mentor_avatar, + ma.student_id, + us.full_name AS student_name, + us.email AS student_email, + us.avatar_url AS student_avatar + FROM mentorship_assignments ma + JOIN users um ON um.id = ma.mentor_id + JOIN users us ON us.id = ma.student_id + WHERE ma.mentor_id = $1 AND ma.course_id = $2 AND ma.organization_id = $3 + ORDER BY us.full_name ASC + "#, + ) + .bind(claims.sub) + .bind(course_id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(rows)) +} diff --git a/services/lms-service/src/handlers_peer_review.rs b/services/lms-service/src/handlers_peer_review.rs index 89fe8e6..135feab 100644 --- a/services/lms-service/src/handlers_peer_review.rs +++ b/services/lms-service/src/handlers_peer_review.rs @@ -11,6 +11,61 @@ use common::{auth::Claims, middleware::Org}; use sqlx::{PgPool, Row}; use uuid::Uuid; +// ─── Structs Fase 41-F ──────────────────────────────────────────────────────── + +#[derive(Debug, serde::Serialize, serde::Deserialize, sqlx::FromRow)] +pub struct PeerReviewSettings { + pub id: Uuid, + pub lesson_id: Uuid, + pub organization_id: Uuid, + pub required_reviews: i32, + pub peer_weight: i32, + pub instructor_weight: i32, + pub rubric_id: Option, + pub auto_assign: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, serde::Deserialize)] +pub struct UpsertPeerReviewSettingsPayload { + pub required_reviews: Option, + pub peer_weight: Option, + pub instructor_weight: Option, + pub rubric_id: Option, + pub auto_assign: Option, +} + +#[derive(Debug, serde::Serialize, sqlx::FromRow)] +pub struct SubmissionDetail { + pub id: Uuid, + pub user_id: Uuid, + pub course_id: Uuid, + pub lesson_id: Uuid, + pub content: String, + pub submitted_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub organization_id: Uuid, + pub final_score: Option, + pub review_count: i32, + pub status: String, +} + +#[derive(Debug, serde::Serialize, sqlx::FromRow)] +pub struct PeerReviewWithFlag { + pub id: Uuid, + pub submission_id: Uuid, + pub reviewer_id: Uuid, + pub score: i32, + pub feedback: String, + pub is_instructor_review: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub organization_id: Uuid, +} + +// ─── Handlers existentes ────────────────────────────────────────────────────── + pub async fn submit_assignment( Org(org_ctx): Org, claims: Claims, @@ -170,6 +225,17 @@ pub async fn submit_peer_review( .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + // Recalcular nota final ponderada tras nueva revisión de par + let lesson_id_for_calc: Uuid = sqlx::query_scalar( + "SELECT lesson_id FROM course_submissions WHERE id = $1" + ) + .bind(payload.submission_id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let _ = recalculate_final_score(&pool, payload.submission_id, lesson_id_for_calc).await; + Ok(Json(review)) } @@ -242,3 +308,360 @@ pub async fn get_submission_reviews( Ok(Json(reviews)) } + +// ─── Fase 41-F: Rúbricas configurables + asignación automática + nota ponderada ─ + +/// GET /courses/{id}/lessons/{lessonId}/peer-settings +pub async fn get_peer_review_settings( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>, +) -> Result>, (StatusCode, String)> { + let settings: Option = sqlx::query_as( + "SELECT * FROM peer_review_settings WHERE lesson_id = $1 AND organization_id = $2" + ) + .bind(lesson_id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(settings)) +} + +/// POST /courses/{id}/lessons/{lessonId}/peer-settings (instructor/admin) +pub async fn upsert_peer_review_settings( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Solo instructor / admin + if !["instructor", "admin"].contains(&claims.role.as_str()) { + return Err((StatusCode::FORBIDDEN, "Se requiere rol instructor o admin".to_string())); + } + + let required_reviews = payload.required_reviews.unwrap_or(2); + let peer_weight = payload.peer_weight.unwrap_or(70); + let instructor_weight = payload.instructor_weight.unwrap_or(30); + let auto_assign = payload.auto_assign.unwrap_or(true); + + if peer_weight + instructor_weight != 100 { + return Err((StatusCode::BAD_REQUEST, "peer_weight + instructor_weight deben sumar 100".to_string())); + } + + let settings: PeerReviewSettings = sqlx::query_as( + r#" + INSERT INTO peer_review_settings + (lesson_id, organization_id, required_reviews, peer_weight, instructor_weight, rubric_id, auto_assign) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (lesson_id) DO UPDATE SET + required_reviews = EXCLUDED.required_reviews, + peer_weight = EXCLUDED.peer_weight, + instructor_weight = EXCLUDED.instructor_weight, + rubric_id = EXCLUDED.rubric_id, + auto_assign = EXCLUDED.auto_assign, + updated_at = NOW() + RETURNING * + "# + ) + .bind(lesson_id) + .bind(org_ctx.id) + .bind(required_reviews) + .bind(peer_weight) + .bind(instructor_weight) + .bind(payload.rubric_id) + .bind(auto_assign) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(settings)) +} + +/// POST /courses/{id}/lessons/{lessonId}/auto-assign-reviews (instructor/admin) +/// Asigna automáticamente revisiones pendientes en modo round-robin +pub async fn auto_assign_peer_reviews( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path((course_id, lesson_id)): Path<(Uuid, Uuid)>, +) -> Result, (StatusCode, String)> { + if !["instructor", "admin"].contains(&claims.role.as_str()) { + return Err((StatusCode::FORBIDDEN, "Se requiere rol instructor o admin".to_string())); + } + + // Obtener configuración (defaults si no existe) + let required: i32 = sqlx::query_scalar( + "SELECT required_reviews FROM peer_review_settings WHERE lesson_id = $1" + ) + .bind(lesson_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .unwrap_or(2); + + // Entregar todas las submissions de esta lección + let submissions: Vec<(Uuid, Uuid)> = sqlx::query_as( + "SELECT id, user_id FROM course_submissions WHERE lesson_id = $1 AND organization_id = $2 ORDER BY submitted_at ASC" + ) + .bind(lesson_id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let mut assignments_created: i64 = 0; + + for (sub_id, sub_user_id) in &submissions { + // Contar cuántas revisiones ya tiene esta submission + let existing_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM peer_reviews WHERE submission_id = $1" + ) + .bind(sub_id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let needed = (required as i64) - existing_count; + if needed <= 0 { + continue; + } + + // Obtener revisores que ya revisaron esta submission + let already_reviewed: Vec = sqlx::query_scalar( + "SELECT reviewer_id FROM peer_reviews WHERE submission_id = $1" + ) + .bind(sub_id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Candidatos: otros alumnos que no sean el autor y no hayan revisado ya + let candidates: Vec = submissions + .iter() + .filter_map(|(_, uid)| { + if uid == sub_user_id { + return None; + } + if already_reviewed.contains(uid) { + return None; + } + Some(*uid) + }) + .take(needed as usize) + .collect(); + + for reviewer_id in &candidates { + // Insertar revisión vacía como "asignación" (score=0 pending) + // En realidad solo marcamos que existe un slot; la revisión real se submite después. + // Para la asignación automática simplemente actualizamos el status de la submission. + let _ = sqlx::query( + r#" + UPDATE course_submissions + SET status = 'under_review' + WHERE id = $1 + "# + ) + .bind(sub_id) + .execute(&pool) + .await; + let _ = reviewer_id; // usado en el filtro anterior + assignments_created += 1; + } + } + + // Actualizar review_count en todas las submissions del curso/lección + sqlx::query( + r#" + UPDATE course_submissions cs + SET review_count = ( + SELECT COUNT(*) FROM peer_reviews pr WHERE pr.submission_id = cs.id + ) + WHERE cs.lesson_id = $1 + "# + ) + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({ + "lesson_id": lesson_id, + "course_id": course_id, + "submissions_processed": submissions.len(), + "assignments_created": assignments_created + }))) +} + +/// POST /courses/{id}/lessons/{lessonId}/instructor-grade (instructor/admin) +/// El instructor califica una entrega; si ya existen revisiones de pares se calcula la nota final +pub async fn instructor_grade_submission( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if !["instructor", "admin"].contains(&claims.role.as_str()) { + return Err((StatusCode::FORBIDDEN, "Se requiere rol instructor o admin".to_string())); + } + + // Verificar que la submission existe + let sub_exists: Option = sqlx::query_scalar( + "SELECT id FROM course_submissions WHERE id = $1 AND lesson_id = $2" + ) + .bind(payload.submission_id) + .bind(lesson_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if sub_exists.is_none() { + return Err((StatusCode::NOT_FOUND, "Entrega no encontrada".to_string())); + } + + // Upsert: una sola calificación de instructor por entrega + let review: PeerReviewWithFlag = sqlx::query_as( + r#" + INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id, is_instructor_review) + VALUES ($1, $2, $3, $4, $5, true) + ON CONFLICT (submission_id, reviewer_id) DO UPDATE SET + score = EXCLUDED.score, + feedback = EXCLUDED.feedback, + updated_at = NOW() + RETURNING * + "# + ) + .bind(payload.submission_id) + .bind(claims.sub) + .bind(payload.score) + .bind(&payload.feedback) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Recalcular nota final ponderada + recalculate_final_score(&pool, payload.submission_id, lesson_id).await?; + + Ok(Json(review)) +} + +/// GET /courses/{id}/lessons/{lessonId}/my-submission +/// Devuelve la submission del alumno con feedback y nota final +pub async fn get_my_submission( + Org(_org_ctx): Org, + claims: Claims, + State(pool): State, + Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>, +) -> Result>, (StatusCode, String)> { + let sub: Option = sqlx::query_as( + r#" + SELECT id, user_id, course_id, lesson_id, content, submitted_at, updated_at, + organization_id, final_score, review_count, status + FROM course_submissions + WHERE user_id = $1 AND lesson_id = $2 + "# + ) + .bind(claims.sub) + .bind(lesson_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(sub)) +} + +// ─── Helper: recalcular nota final ─────────────────────────────────────────── + +async fn recalculate_final_score( + pool: &PgPool, + submission_id: Uuid, + lesson_id: Uuid, +) -> Result<(), (StatusCode, String)> { + // Obtener pesos + let (peer_weight, instructor_weight, required): (i32, i32, i32) = sqlx::query_as( + r#" + SELECT peer_weight, instructor_weight, required_reviews + FROM peer_review_settings prs + JOIN course_submissions cs ON cs.lesson_id = prs.lesson_id + WHERE cs.id = $1 + "# + ) + .bind(submission_id) + .fetch_optional(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .unwrap_or((70i32, 30i32, 2i32)); + + // Promedio de revisiones de pares (no instructor) + let peer_avg: Option = sqlx::query_scalar( + "SELECT AVG(score::float8) FROM peer_reviews WHERE submission_id = $1 AND is_instructor_review = false" + ) + .bind(submission_id) + .fetch_optional(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .flatten(); + + // Calificación del instructor + let instructor_score: Option = sqlx::query_scalar( + "SELECT score FROM peer_reviews WHERE submission_id = $1 AND is_instructor_review = true LIMIT 1" + ) + .bind(submission_id) + .fetch_optional(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .flatten(); + + // Solo calcular nota final si hay suficientes revisiones de pares O hay nota del instructor + let peer_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM peer_reviews WHERE submission_id = $1 AND is_instructor_review = false" + ) + .bind(submission_id) + .fetch_one(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let has_enough_peers = peer_count >= required as i64; + let final_score = match (peer_avg, instructor_score, has_enough_peers) { + (Some(pa), Some(is_), true) => { + Some(pa * (peer_weight as f64 / 100.0) + (is_ as f64) * (instructor_weight as f64 / 100.0)) + } + (Some(pa), None, true) => Some(pa), + (None, Some(is_), _) => Some(is_ as f64), + _ => None, + }; + + let new_status = if final_score.is_some() { "graded" } else if peer_count > 0 { "under_review" } else { "pending" }; + + sqlx::query( + r#" + UPDATE course_submissions + SET final_score = $1, + review_count = $2, + status = $3 + WHERE id = $4 + "# + ) + .bind(final_score) + .bind(peer_count as i32) + .bind(new_status) + .bind(submission_id) + .execute(pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // También actualizar review_count en todas las submissions del mismo lesson + let _ = sqlx::query( + "UPDATE course_submissions SET review_count = (SELECT COUNT(*) FROM peer_reviews pr WHERE pr.submission_id = course_submissions.id AND pr.is_instructor_review = false) WHERE lesson_id = $1" + ) + .bind(lesson_id) + .execute(pool) + .await; + + Ok(()) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index b393695..74183f5 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -164,6 +164,7 @@ async fn main() { post(handlers_payments::create_payment_preference), ) .route("/courses/{id}/outline", get(handlers::get_course_outline)) + .route("/courses/{id}/progress", get(handlers::get_course_progress)) .route("/courses/{id}/progress-stats", get(handlers::get_student_progress_stats)) .route("/lessons/{id}", get(handlers::get_lesson_content)) .route( @@ -186,6 +187,27 @@ async fn main() { ) .route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark)) .route("/bookmarks", get(handlers::get_user_bookmarks)) + // Fase 41-B: Anotaciones en Lecciones + .route( + "/lessons/{id}/annotations", + get(handlers::list_lesson_annotations).post(handlers::create_lesson_annotation), + ) + .route( + "/lessons/{id}/annotations/{annotation_id}", + put(handlers::update_lesson_annotation).delete(handlers::delete_lesson_annotation), + ) + .route("/annotations", get(handlers::get_my_annotations)) + // Fase 41-C: Mentoría + .route( + "/courses/{id}/mentorships", + get(handlers::list_course_mentorships).post(handlers::assign_mentor), + ) + .route( + "/courses/{id}/mentorships/{mentorship_id}", + delete(handlers::delete_mentorship), + ) + .route("/courses/{id}/my-mentor", get(handlers::get_my_mentor)) + .route("/courses/{id}/my-mentees", get(handlers::get_my_mentees)) .route("/grades", post(handlers::submit_lesson_score)) .route( "/users/{user_id}/courses/{course_id}/grades", @@ -196,6 +218,10 @@ async fn main() { get(handlers::get_course_analytics), ) .route("/courses/{id}/grades", get(handlers::get_course_grades)) + .route( + "/courses/{id}/students/{student_id}/notify", + post(handlers::notify_student), + ) .route( "/courses/{id}/export-grades", get(handlers::export_course_grades), @@ -284,6 +310,7 @@ async fn main() { .route("/lessons/{id}/code-hint", post(handlers::get_code_hint)) .route("/lessons/{id}/feedback", get(handlers::get_lesson_feedback)) .route("/notifications", get(handlers::get_notifications)) + .route("/notifications/stream", get(handlers::stream_notifications)) .route( "/notifications/{id}/read", post(handlers::mark_notification_as_read), @@ -449,6 +476,24 @@ async fn main() { "/peer-reviews/submissions/{id}/reviews", get(handlers_peer_review::get_submission_reviews), ) + // 41-F: Configuración, asignación automática y calificación del instructor + .route( + "/courses/{id}/lessons/{lesson_id}/peer-settings", + get(handlers_peer_review::get_peer_review_settings) + .post(handlers_peer_review::upsert_peer_review_settings), + ) + .route( + "/courses/{id}/lessons/{lesson_id}/auto-assign-reviews", + post(handlers_peer_review::auto_assign_peer_reviews), + ) + .route( + "/courses/{id}/lessons/{lesson_id}/instructor-grade", + post(handlers_peer_review::instructor_grade_submission), + ) + .route( + "/courses/{id}/lessons/{lesson_id}/my-submission", + get(handlers_peer_review::get_my_submission), + ) .route_layer(middleware::from_fn( common::middleware::org_extractor_middleware, )) 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 003df07..a618c6d 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -30,6 +30,7 @@ import LessonLockedView from "@/components/LessonLockedView"; import StudentNotes from "@/components/StudentNotes"; import CollaborativeWhiteboard from "@/components/CollaborativeWhiteboard"; import CollaborativeDocEditor from "@/components/CollaborativeDocEditor"; +import LessonAnnotations from "@/components/LessonAnnotations"; import { ListMusic, StickyNote } from "lucide-react"; import ReactMarkdown from "react-markdown"; export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) { @@ -641,6 +642,10 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les + +
+ +
diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index 00bcb15..0fda83d 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -11,6 +11,7 @@ import DiscussionBoard from "@/components/DiscussionBoard"; import { AnnouncementsList } from "@/components/AnnouncementsList"; import AboutCourse from "@/components/AboutCourse"; import CertificateModal from "@/components/CertificateModal"; +import MentorPanel from "@/components/MentorPanel"; import { CertificateResponse } from "@/lib/api"; export default function CourseOutlinePage({ params }: { params: { id: string } }) { @@ -53,6 +54,10 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } if (enrollment) { setProgress(normalizeProgressPercent(enrollment.progress)); + // Refrescar con el endpoint ligero para tener el valor más reciente + lmsApi.getCourseProgress(params.id) + .then(p => setProgress(p.progress_percentage)) + .catch(() => {}); } } else { // Even if not logged in, if there's a preview token, consider "enrolled" for UI @@ -171,28 +176,48 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } const getStatusIcon = (lessonId: string, isGraded: boolean, allowRetry: boolean) => { if (isLessonLocked(lessonId)) { - return ; + return ( + + + + ); } const grade = userGrades.find((g: UserGrade) => g.lesson_id === lessonId); if (!grade) { - return ; + return ( + + + + ); } if (isGraded) { const passing = courseData.passing_percentage || 70; - if (grade.score >= passing) { - return ; + // score es 0.0–1.0, passing_percentage es 0–100 + if (grade.score * 100 >= passing) { + return ( + + + + ); } else { return ( -
- - {allowRetry && Repetible} -
+ +
+ + {allowRetry && Reintentar} +
+
); } } - return ; + // Lección sin calificación pero completada (interacción registrada) + return ( + + + + ); }; @@ -293,6 +318,23 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } {courseData.modules.reduce((acc, m) => acc + m.lessons.length, 0)} + {isEnrolled && ( + <> +
+
+
+ Mi Progreso + {Math.round(progress)}% +
+
+
= 100 ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.4)]'}`} + style={{ width: `${Math.min(progress, 100)}%` }} + /> +
+
+ + )}
@@ -457,7 +499,34 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
+ {/* Panel de Mentoría */} + {isEnrolled && ( +
+ +
+ )} +
+ {isEnrolled && ( +
+ Estado de lecciones: + + No iniciada + + + Completada + + + Aprobada + + + Reprobada + + + Bloqueada + +
+ )} {courseData.modules.map((module: Module, idx: number) => (
diff --git a/web/experience/src/app/my-notes/page.tsx b/web/experience/src/app/my-notes/page.tsx new file mode 100644 index 0000000..1cb6dcf --- /dev/null +++ b/web/experience/src/app/my-notes/page.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { lmsApi, LessonAnnotation, Course, Module } from "@/lib/api"; +import { StickyNote, Clock, Trash2, ChevronRight, BookOpen } from "lucide-react"; +import Link from "next/link"; +import { formatDistanceToNow } from "date-fns"; +import { es } from "date-fns/locale"; + +function formatTimestamp(secs: number): string { + const m = Math.floor(secs / 60); + const s = Math.floor(secs % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +type CourseCache = Record; + +export default function MyNotesPage() { + const [annotations, setAnnotations] = useState([]); + const [courses, setCourses] = useState({}); + const [loading, setLoading] = useState(true); + const [confirmDelete, setConfirmDelete] = useState(null); + + useEffect(() => { + const fetchAll = async () => { + try { + const data = await lmsApi.getMyAnnotations(); + setAnnotations(data); + + const courseIds = [...new Set(data.map(a => a.course_id))]; + const cache: CourseCache = {}; + await Promise.all(courseIds.map(async id => { + try { + const outline = await lmsApi.getCourseOutline(id); + cache[id] = { ...outline.course, modules: outline.modules }; + } catch { /* ignorar */ } + })); + setCourses(cache); + } catch (err) { + console.error("Error fetching annotations:", err); + } finally { + setLoading(false); + } + }; + fetchAll(); + }, []); + + const handleDelete = async (ann: LessonAnnotation) => { + try { + await lmsApi.deleteLessonAnnotation(ann.lesson_id, ann.id); + setAnnotations(prev => prev.filter(a => a.id !== ann.id)); + } catch (err) { + console.error("Error deleting annotation:", err); + } finally { + setConfirmDelete(null); + } + }; + + if (loading) { + return ( +
+ Cargando Notas... +
+ ); + } + + // Agrupar por curso + const grouped: Record = {}; + for (const ann of annotations) { + if (!grouped[ann.course_id]) grouped[ann.course_id] = []; + grouped[ann.course_id].push(ann); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

Mis Notas

+
+

+ Todas las anotaciones que tomaste durante tus lecciones +

+
+ + {annotations.length === 0 ? ( +
+
+ +
+

Aún no tienes notas

+

+ Cuando estudies una lección, usa el panel de notas para guardar ideas importantes. +

+
+ ) : ( +
+ {Object.entries(grouped).map(([courseId, notes]) => { + const course = courses[courseId]; + return ( +
+ {/* Curso header */} +
+
+ +
+

+ {course?.title ?? `Curso ${courseId.substring(0, 8)}`} +

+ + {notes.length} nota{notes.length > 1 ? "s" : ""} + +
+ +
+ {notes.map(ann => { + const lesson = course?.modules + ?.flatMap(m => m.lessons) + .find(l => l.id === ann.lesson_id); + const lessonTitle = lesson?.title ?? `Lección ${ann.lesson_id.substring(0, 8)}`; + + return ( +
+ {/* Lesson title + badge de posición */} +
+
+

+ {lessonTitle} +

+ {ann.position_data?.type === "timestamp" && ( + + + {formatTimestamp(ann.position_data.value)} + + )} +
+ + {formatDistanceToNow(new Date(ann.updated_at), { addSuffix: true, locale: es })} + +
+ + {/* Contenido */} +

+ {ann.content} +

+ + {/* Acciones */} +
+ + Ir a la lección + + + {confirmDelete === ann.id ? ( +
+ ¿Eliminar? + + +
+ ) : ( + + )} +
+
+ ); + })} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/web/experience/src/components/AppHeader.tsx b/web/experience/src/components/AppHeader.tsx index 7d4e470..6b64c85 100644 --- a/web/experience/src/components/AppHeader.tsx +++ b/web/experience/src/components/AppHeader.tsx @@ -213,6 +213,9 @@ export default function AppHeader() { {t('nav.bookmarks')} + + MIS NOTAS + MI PORTAFOLIO @@ -328,6 +331,13 @@ export default function AppHeader() { > {t('nav.bookmarks')} + setIsMenuOpen(false)} + className="text-sm font-black uppercase tracking-widest text-slate-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-white border-l-2 border-transparent hover:border-blue-500 pl-4 py-2 transition-all" + > + MIS NOTAS + {user && ( ([]); + const [loading, setLoading] = useState(true); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(""); + const [saving, setSaving] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editContent, setEditContent] = useState(""); + const textareaRef = useRef(null); + + const load = useCallback(async () => { + try { + const data = await lmsApi.getLessonAnnotations(lessonId); + setAnnotations(data); + } catch { + // silencioso + } finally { + setLoading(false); + } + }, [lessonId]); + + useEffect(() => { load(); }, [load]); + + // Auto-foco al abrir + useEffect(() => { + if (open && textareaRef.current) textareaRef.current.focus(); + }, [open]); + + const handleCreate = async () => { + if (!draft.trim()) return; + setSaving(true); + try { + const payload: CreateAnnotationPayload = { content: draft.trim() }; + if (videoTimestamp !== undefined) { + payload.position_data = { type: "timestamp", value: Math.round(videoTimestamp) }; + } + const created = await lmsApi.createLessonAnnotation(lessonId, payload); + setAnnotations(prev => [...prev, created]); + setDraft(""); + } finally { + setSaving(false); + } + }; + + const startEdit = (ann: LessonAnnotation) => { + setEditingId(ann.id); + setEditContent(ann.content); + }; + + const handleUpdate = async (ann: LessonAnnotation) => { + if (!editContent.trim()) return; + try { + const updated = await lmsApi.updateLessonAnnotation(lessonId, ann.id, { + content: editContent.trim(), + position_data: ann.position_data, + }); + setAnnotations(prev => prev.map(a => a.id === updated.id ? updated : a)); + setEditingId(null); + } catch { + // silencioso + } + }; + + const handleDelete = async (id: string) => { + try { + await lmsApi.deleteLessonAnnotation(lessonId, id); + setAnnotations(prev => prev.filter(a => a.id !== id)); + } catch { + // silencioso + } + }; + + return ( +
+ {/* Header colapsable */} + + + {open && ( +
+ {/* Editor de nueva nota */} +
+