From 44b2160590451b5b8ac6c879264004c6b68c2efe Mon Sep 17 00:00:00 2001 From: Nurfog Date: Wed, 25 Feb 2026 14:06:28 -0300 Subject: [PATCH] feat: Implement peer review management, student, and session pages for courses. --- README.md | 2 + roadmap.md | 18 +- .../lms-service/src/handlers_peer_review.rs | 51 ++- services/lms-service/src/main.rs | 8 + shared/common/src/models.rs | 11 + .../app/courses/[id]/peer-reviews/page.tsx | 273 ++++++++++++++++ .../src/app/courses/[id]/sessions/page.tsx | 251 +++++++++++++++ .../src/app/courses/[id]/students/page.tsx | 300 ++++++++++++++++++ .../src/components/CourseEditorLayout.tsx | 7 +- web/studio/src/lib/api.ts | 14 + 10 files changed, 925 insertions(+), 10 deletions(-) create mode 100644 web/studio/src/app/courses/[id]/peer-reviews/page.tsx create mode 100644 web/studio/src/app/courses/[id]/sessions/page.tsx create mode 100644 web/studio/src/app/courses/[id]/students/page.tsx diff --git a/README.md b/README.md index dd5ef25..98d5482 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **LTI 1.3 Tool Provider**: Interoperabilidad completa para lanzar cursos de OpenCCB desde LMS externos (Canvas, Moodle) de manera segura y estandarizada, con soporte para **Deep Linking** (Content Picking). - **Global Asset Library**: Repositorio centralizado de medios para toda la organización, permitiendo la reutilización de archivos en múltiples cursos con gestión de cuotas e integridad de datos. - **Predictive Analytics (Dropout Risk)**: Motor de IA que analiza el desempeño, actividad y compromiso social del estudiante para detectar riesgos de abandono de forma proactiva, con alertas accionables para instructores. +- **Live Learning (Videoconference)**: Integración nativa con Jitsi para clases virtuales síncronas, con programación desde Studio y acceso integrado en Experience. +- **Student Portfolio & Badges**: Sistema de reconocimiento con Open Badges y perfiles públicos profesionales para mostrar logros y progreso verificado. ## Requisitos del Sistema diff --git a/roadmap.md b/roadmap.md index 6034645..b441a97 100644 --- a/roadmap.md +++ b/roadmap.md @@ -210,16 +210,20 @@ - [x] **Gestión de Activos**: ✅ - [x] Biblioteca de medios global (Global Asset Manager). - [x] Reutilización de recursos multi-curso. -- [ ] **Aprendizaje en Vivo**: - - [ ] Integración con BigBlueButton. -- [ ] **Portafolio del Estudiante**: - - [ ] Perfil profesional público con Open Badges. +- [x] **Aprendizaje en Vivo**: ✅ + - [x] Integración con Jitsi para aulas virtuales en tiempo real. + - [x] Gestión de reuniones y programación desde Studio. + - [x] Acceso directo para estudiantes desde Experience. +- [x] **Portafolio del Estudiante**: ✅ + - [x] Sistema de medallas y logros (Open Badges). + - [x] Perfiles profesionales públicos con control de privacidad. + - [x] Visualización de progreso y nivel de XP. --- -**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3 con soporte para Deep Linking** y **Analíticas Predictivas de Riesgo de Abandono**. +**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3 con soporte para Deep Linking**, **Analíticas Predictivas de Riesgo de Abandono**, **Integración de Videoconferencia (Jitsi)** y **Portafolios con Perfiles Públicos**. **Próximas Prioridades**: 1. **Apps Móviles**: Desarrollo de versiones nativas para iOS y Android. -2. **Aprendizaje en Vivo**: Integración con plataformas de videoconferencia (BigBlueButton/Jitsi). -3. **Portafolio del Estudiante**: Perfiles profesionales públicos y Open Badges. +2. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos. +3. **IA Generativa Avanzada**: Generación automática de contenido de video a partir de guiones. diff --git a/services/lms-service/src/handlers_peer_review.rs b/services/lms-service/src/handlers_peer_review.rs index 3106acb..7409053 100644 --- a/services/lms-service/src/handlers_peer_review.rs +++ b/services/lms-service/src/handlers_peer_review.rs @@ -4,7 +4,8 @@ use axum::{ http::StatusCode, }; use common::models::{ - CourseSubmission, PeerReview, SubmitAssignmentPayload, SubmitPeerReviewPayload, + CourseSubmission, PeerReview, SubmissionWithReviews, SubmitAssignmentPayload, + SubmitPeerReviewPayload, }; use common::{auth::Claims, middleware::Org}; use sqlx::PgPool; @@ -201,3 +202,51 @@ pub async fn get_my_submission_feedback( Ok(Json(reviews)) } + +pub async fn list_lesson_submissions( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>, +) -> Result>, (StatusCode, String)> { + let submissions = sqlx::query_as!( + SubmissionWithReviews, + r#" + SELECT + s.id, s.user_id, u.full_name, u.email, s.submitted_at, + COUNT(pr.id) as "review_count!", + AVG(pr.score)::float8 as average_score + FROM course_submissions s + JOIN users u ON s.user_id = u.id + LEFT JOIN peer_reviews pr ON s.id = pr.submission_id + WHERE s.lesson_id = $1 AND s.organization_id = $2 + GROUP BY s.id, u.full_name, u.email + ORDER BY s.submitted_at DESC + "#, + lesson_id, + org_ctx.id + ) + .fetch_all(&pool) + .await + .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(submissions)) +} + +pub async fn get_submission_reviews( + Org(_org_ctx): Org, + _claims: Claims, + State(pool): State, + Path(submission_id): Path, +) -> Result>, (StatusCode, String)> { + let reviews = sqlx::query_as!( + PeerReview, + "SELECT * FROM peer_reviews WHERE submission_id = $1", + submission_id + ) + .fetch_all(&pool) + .await + .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(reviews)) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index b1f0483..040751e 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -210,6 +210,14 @@ async fn main() { "/courses/{id}/lessons/{lesson_id}/feedback", get(handlers_peer_review::get_my_submission_feedback), ) + .route( + "/courses/{id}/lessons/{lesson_id}/submissions", + get(handlers_peer_review::list_lesson_submissions), + ) + .route( + "/peer-reviews/submissions/{id}/reviews", + get(handlers_peer_review::get_submission_reviews), + ) .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 ac66c99..8efc2f0 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -707,6 +707,17 @@ pub struct SubmitPeerReviewPayload { pub feedback: String, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct SubmissionWithReviews { + pub id: Uuid, + pub user_id: Uuid, + pub full_name: String, + pub email: String, + pub submitted_at: DateTime, + pub review_count: i64, + pub average_score: Option, +} + // Content Libraries #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct LibraryBlock { diff --git a/web/studio/src/app/courses/[id]/peer-reviews/page.tsx b/web/studio/src/app/courses/[id]/peer-reviews/page.tsx new file mode 100644 index 0000000..51e0b36 --- /dev/null +++ b/web/studio/src/app/courses/[id]/peer-reviews/page.tsx @@ -0,0 +1,273 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { cmsApi, lmsApi, Course, Lesson, SubmissionWithReviews, PeerReview } from "@/lib/api"; +import { + Users, + MessageSquare, + Search, + ArrowLeft, + Loader2, + CheckCircle, + Clock, + ChevronRight, + Award +} from "lucide-react"; +import CourseEditorLayout from "@/components/CourseEditorLayout"; + +export default function PeerReviewDashboard() { + const { id } = useParams() as { id: string }; + const router = useRouter(); + const [course, setCourse] = useState(null); + const [lessons, setLessons] = useState([]); + const [selectedLessonId, setSelectedLessonId] = useState(null); + const [submissions, setSubmissions] = useState([]); + const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); + const [reviews, setReviews] = useState([]); + const [loading, setLoading] = useState(true); + const [submissionsLoading, setSubmissionsLoading] = useState(false); + const [reviewsLoading, setReviewsLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + + useEffect(() => { + const loadInitialData = async () => { + try { + setLoading(true); + const courseData = await cmsApi.getCourseWithFullOutline(id); + setCourse(courseData); + + const peerReviewLessons: Lesson[] = []; + courseData.modules?.forEach(m => { + m.lessons.forEach(l => { + const hasPeerReview = l.metadata?.blocks?.some((b: any) => b.type === 'peer-review'); + if (hasPeerReview) { + peerReviewLessons.push(l); + } + }); + }); + setLessons(peerReviewLessons); + + if (peerReviewLessons.length > 0) { + setSelectedLessonId(peerReviewLessons[0].id); + } + } catch (error) { + console.error("Error loading course data:", error); + } finally { + setLoading(false); + } + }; + loadInitialData(); + }, [id]); + + useEffect(() => { + if (!selectedLessonId) return; + + const loadSubmissions = async () => { + try { + setSubmissionsLoading(true); + const data = await lmsApi.listLessonSubmissions(id, selectedLessonId); + setSubmissions(data); + } catch (error) { + console.error("Error loading submissions:", error); + } finally { + setSubmissionsLoading(false); + } + }; + loadSubmissions(); + }, [id, selectedLessonId]); + + useEffect(() => { + if (!selectedSubmissionId) { + setReviews([]); + return; + } + + const loadReviews = async () => { + try { + setReviewsLoading(true); + const data = await lmsApi.getSubmissionReviews(selectedSubmissionId); + setReviews(data); + } catch (error) { + console.error("Error loading reviews:", error); + } finally { + setReviewsLoading(false); + } + }; + loadReviews(); + }, [selectedSubmissionId]); + + const filteredSubmissions = submissions.filter(s => + s.full_name.toLowerCase().includes(searchTerm.toLowerCase()) || + s.email.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+
+ +
+

+ Peer Review Management +

+

Monitor and manage student peer assessments

+
+
+
+ + +
+ {/* Lessons List */} +
+

Activities

+
+ {lessons.length === 0 ? ( +
+ No peer review activities found in this course. +
+ ) : ( + lessons.map(lesson => ( + + )) + )} +
+
+ + {/* Submissions List */} +
+
+
+

+ + Submissions +

+
+ + setSearchTerm(e.target.value)} + className="w-full bg-black/20 border border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:border-blue-500/50 transition-all" + /> +
+
+ + {submissionsLoading ? ( +
+ +
+ ) : filteredSubmissions.length === 0 ? ( +
+ +

No submissions found for this activity.

+
+ ) : ( +
+ {filteredSubmissions.map(sub => ( +
+
setSelectedSubmissionId(selectedSubmissionId === sub.id ? null : sub.id)} + className={`p-4 rounded-xl border transition-all cursor-pointer ${selectedSubmissionId === sub.id + ? "bg-blue-500/5 border-blue-500/30" + : "bg-white/[0.02] border-white/5 hover:bg-white/[0.05] hover:border-white/20" + }`} + > +
+
+
+ {sub.full_name.charAt(0)} +
+
+
{sub.full_name}
+
{sub.email}
+
+
+ +
+
+
+ + {sub.average_score !== null ? `${(sub.average_score).toFixed(1)}/10` : '—'} +
+
Avg. Score
+
+
+
= 2 ? 'text-green-400' : 'text-orange-400'}`}> + + {sub.review_count} +
+
Reviews
+
+
+
+ + {new Date(sub.submitted_at).toLocaleDateString()} +
+
Submitted
+
+ +
+
+
+ + {/* Reviews Detail Drawer-like expansion */} + {selectedSubmissionId === sub.id && ( +
+

Review Details

+ {reviewsLoading ? ( +
+ ) : reviews.length === 0 ? ( +

No reviews received yet.

+ ) : ( +
+ {reviews.map(review => ( +
+
+ Review Task + {review.score}/10 +
+

"{review.feedback}"

+
+ ))} +
+ )} +
+ )} +
+ ))} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/web/studio/src/app/courses/[id]/sessions/page.tsx b/web/studio/src/app/courses/[id]/sessions/page.tsx new file mode 100644 index 0000000..d520ebe --- /dev/null +++ b/web/studio/src/app/courses/[id]/sessions/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { lmsApi, cmsApi, Course, Meeting } from "@/lib/api"; +import { + Video, + Plus, + Calendar as CalendarIcon, + Clock, + Trash2, + ArrowLeft, + Loader2, + Globe, + Link as LinkIcon, + X +} from "lucide-react"; +import CourseEditorLayout from "@/components/CourseEditorLayout"; + +export default function LiveSessionsPage() { + const { id } = useParams() as { id: string }; + const router = useRouter(); + const [meetings, setMeetings] = useState([]); + const [loading, setLoading] = useState(true); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [newMeeting, setNewMeeting] = useState({ + title: "", + description: "", + start_at: new Date().toISOString().slice(0, 16), + duration_minutes: 60, + provider: "jitsi" + }); + + const loadMeetings = async () => { + try { + setLoading(true); + const data = await lmsApi.getMeetings(id); + setMeetings(data); + } catch (error) { + console.error("Error loading meetings:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadMeetings(); + }, [id]); + + const handleCreateMeeting = async () => { + try { + await lmsApi.createMeeting(id, { + ...newMeeting, + start_at: new Date(newMeeting.start_at).toISOString() + }); + setIsCreateModalOpen(false); + loadMeetings(); + } catch (error) { + console.error("Error creating meeting:", error); + alert("Failed to create meeting."); + } + }; + + const handleDeleteMeeting = async (meetingId: string) => { + if (!confirm("Are you sure you want to delete this meeting?")) return; + try { + await lmsApi.deleteMeeting(id, meetingId); + loadMeetings(); + } catch (error) { + console.error("Error deleting meeting:", error); + alert("Failed to delete meeting."); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+
+ +
+

+ Live Learning Sessions +

+

Schedule and manage virtual meetings and live sessions

+
+
+ +
+ + +
+ {meetings.length === 0 ? ( +
+
+ ) : ( + meetings.map(meeting => ( +
+
+ +
+ +
+
+
+
+

{meeting.title}

+
+ + {meeting.provider} Session +
+
+
+ +
+
+ + {new Date(meeting.start_at).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} +
+
+ + + {new Date(meeting.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + | + {meeting.duration_minutes} minutes + +
+ {meeting.description && ( +

+ "{meeting.description}" +

+ )} +
+ +
+ +
+ {meeting.is_active ? 'Scheduled' : 'Past'} +
+
+
+ )) + )} +
+
+
+ + {/* Create Meeting Modal */} + {isCreateModalOpen && ( +
+
+
+

+

+ +
+
+
+ + setNewMeeting({ ...newMeeting, title: e.target.value })} + placeholder="e.g., Weekly Office Hours" + className="w-full bg-black/20 border border-white/10 rounded-xl py-3 px-4 text-sm focus:outline-none focus:border-blue-500/50" + /> +
+
+ +