From f9e78a265ab5509ff780972384798a75ea2a890c Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 17 Feb 2026 22:43:19 -0300 Subject: [PATCH] feat: Implement advanced grading (rubrics) and lesson dependencies across CMS service, API, and Studio UI. --- README.md | 101 +-- roadmap.md | 12 +- .../20260217000001_advanced_grading.sql | 93 +++ .../20260218000001_lesson_dependencies.sql | 17 + .../cms-service/src/handlers_dependencies.rs | 102 +++ services/cms-service/src/handlers_rubrics.rs | 601 ++++++++++++++++++ services/cms-service/src/main.rs | 50 +- services/lms-service/src/handlers.rs | 75 ++- shared/common/src/models.rs | 86 +++ web/experience/src/app/courses/[id]/page.tsx | 98 ++- web/experience/src/lib/api.ts | 17 +- .../courses/[id]/lessons/[lessonId]/page.tsx | 266 +++++++- .../src/app/courses/[id]/rubrics/page.tsx | 60 ++ .../src/components/CourseEditorLayout.tsx | 3 +- .../src/components/Rubrics/RubricEditor.tsx | 376 +++++++++++ .../src/components/Rubrics/RubricList.tsx | 192 ++++++ web/studio/src/lib/api.ts | 156 +++++ 17 files changed, 2181 insertions(+), 124 deletions(-) create mode 100644 services/cms-service/migrations/20260217000001_advanced_grading.sql create mode 100644 services/cms-service/migrations/20260218000001_lesson_dependencies.sql create mode 100644 services/cms-service/src/handlers_dependencies.rs create mode 100644 services/cms-service/src/handlers_rubrics.rs create mode 100644 web/studio/src/app/courses/[id]/rubrics/page.tsx create mode 100644 web/studio/src/components/Rubrics/RubricEditor.tsx create mode 100644 web/studio/src/components/Rubrics/RubricList.tsx diff --git a/README.md b/README.md index d683948..5437b70 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Student Notes**: Sistema de anotaciones personales por lección con auto-guardado inteligente (debounced). - **Cohorts & Groups**: Segmentación de estudiantes en cohortes para gestión de grupos y análisis comparativo. - **Interactive Gradebook**: Libro de calificaciones avanzado con filtrado por cohortes y exportación a CSV. +- **Content Libraries**: Repositorio centralizado de bloques y lecciones reutilizables entre múltiples cursos. +- **Advanced Grading (Rubrics)**: Sistema de evaluación basado en rúbricas detalladas con indicadores de desempeño por criterio. ## Requisitos del Sistema @@ -101,14 +103,14 @@ npm install npm run dev ``` -#### 📦 Course Portability -Manage content mobility across different organizations using standardized JSON exports. +#### 📦 Portabilidad de Cursos +Gestiona la movilidad de contenidos entre diferentes organizaciones utilizando exportaciones estandarizadas en JSON. #### GET /courses/{id}/export -Generates a complete bundle of the course, including modules, lessons, and grading settings. +Genera un paquete completo del curso, incluyendo módulos, lecciones y configuraciones de calificación. #### POST /courses/import -Creates a new course based on a provided export bundle. Automatic dependency mapping ensures data integrity in the new organization. +Crea un nuevo curso basado en un paquete de exportación proporcionado. El mapeo automático de dependencias asegura la integridad de los datos en la nueva organización. #### Experience & LMS ```bash # Iniciar backend LMS @@ -512,16 +514,16 @@ curl -X POST "http://localhost:3002/posts/{post_id}/vote" \ --- -### 5. Course Announcements (Anuncios) +### 5. Anuncios del Curso (Announcements) -| Action | Method | Endpoint | Description | +| Acción | Método | Endpoint | Descripción | |--------|--------|----------|-------------| -| List | GET | `/courses/{id}/announcements` | Get all announcements for a course | -| Create | POST | `/courses/{id}/announcements` | Create a new announcement (Instructor/Admin only) | -| Update | PUT | `/announcements/{id}` | Update an announcement (Instructor/Admin only) | -| Delete | DELETE| `/announcements/{id}` | Delete an announcement (Instructor/Admin only) | +| Listar | GET | `/courses/{id}/announcements` | Obtiene todos los anuncios de un curso | +| Crear | POST | `/courses/{id}/announcements` | Crea un nuevo anuncio (Solo Instructor/Admin) | +| Actualizar | PUT | `/announcements/{id}` | Actualiza un anuncio (Solo Instructor/Admin) | +| Eliminar | DELETE| `/announcements/{id}` | Elimina un anuncio (Solo Instructor/Admin) | -#### Create Announcement Example +#### Ejemplo de Creación de Anuncio ```bash curl -X POST http://localhost:3002/courses/{course_id}/announcements \ -H "Authorization: Bearer $TOKEN" \ @@ -535,24 +537,25 @@ curl -X POST http://localhost:3002/courses/{course_id}/announcements \ --- -### 6. Multi-tenancy and Global Management (Super Admin) -OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Admin** can manage everything. +### 6. Multi-tenencia y Gestión Global (Super Admin) +OpenCCB está diseñado para multi-tenencia. Las organizaciones están aisladas, pero un **Super Admin** puede gestionarlo todo. -#### Super Admin Definition -- **Default Organization ID**: `00000000-0000-0000-0000-000000000001` -- Any user with `role: admin` in this organization is a **Super Admin**. +#### Definición de Super Admin +- **ID de Organización por Defecto**: `00000000-0000-0000-0000-000000000001` +- Cualquier usuario con `role: admin` en esta organización es un **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**. -- They appear in the catalog of **all organizations**. -- Users from any organization can enroll in global courses. +#### Cursos Globales +Los cursos creados por los Super Admins en la **Organización por Defecto** son automáticamente marcados como **Globales**. +- Aparecen en el catálogo de **todas las organizaciones**. +- Los usuarios de cualquier organización pueden inscribirse en cursos globales. + Riverside -#### Cross-Tenant Publishing -Super Admins can publish courses to **any organization**. When publishing through the Studio, a premium **Organization Selector** (with search-as-you-type) allows choosing the target destination. +#### Publicación Entre Organizaciones +Los Super Admins pueden publicar cursos en **cualquier organización**. Al publicar a través de Studio, un **Selector de Organizaciones** premium permite elegir el destino. #### X-Organization-Id Header Super Admins can simulate the context of any organization by sending this header in their requests: @@ -570,32 +573,34 @@ Obtiene una lista de todas las organizaciones registradas. --- -## 🏆 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. -- **Global Localization**: Native support for English, Spanish, and Portuguese. -- **PDF Integrated Viewer**: Read academic documents without leaving the platform. -- **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons. -- **White-Label Branding**: Fully custom platform name, logo, favicon, and color themes per organization. -- **Dynamic LAN Connectivity**: Automatic server IP detection for seamless multi-device access. -- **Mobile-First Navigation**: Responsive sliding menus and adaptive layouts for all screen sizes. -- **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers. -- **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results. -- **Color-Coded Navigation**: Real-time visual progress indicators for lessons and modules (Green/Yellow/Red). -- **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions. -- **Course Announcements**: Instructor-to-student communication system with automatic notifications and pinning functionality. -- **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support. -- **Mercado Pago Monetization**: Integrated payment gateway with automatic course unlocking and transaction tracking. -- **Student Notes Panel**: Personal lesson annotations with glassmorphism UI and intelligent auto-save. -- **Cohort Management**: Comprehensive group management system with member assignment and progress tracking. -- **Advanced Gradebook**: Student performance tracking with cohort-based filtering and analytics. +## 🏆 Componentes UI Premium +- **Advanced Grading (Rubrics)**: Sistema completo de evaluación por rúbricas configurables. +- **Content Libraries**: Repositorio reutilizable de bloques y lecciones para máxima eficiencia. +- **Course Portability**: Sistema de importación/exportación basado en JSON para movilidad de contenidos. +- **AI Course Wizard**: Generación instantánea de currículos a partir de prompts. +- **Global Admin Console**: Control centralizado para organizaciones, usuarios y registros de auditoría. +- **Experience Player**: Interfaz de aprendizaje de alto rendimiento y accesible con diseño glassmorphism. +- **Organization Selector**: Combobox con búsqueda para gestionar grandes listas de inquilinos. +- **Engagement Heatmaps**: Gráficos dinámicos que muestran la retención en videos. +- **Notification Center**: Alertas en tiempo real para fechas límite y logros. +- **Custom Report Builder**: Reportes profesionales con exportación a CSV en un clic. +- **Glassmorphism Design**: Estética consistente en los portales Studio y Experience. +- **Global Localization**: Soporte nativo para Inglés, Español y Portugués. +- **PDF Integrated Viewer**: Lectura de documentos académicos sin salir de la plataforma. +- **Interactive Video Markers**: Preguntas que pausan el video integradas en las lecciones. +- **White-Label Branding**: Nombre de plataforma, logo, favicon y temas de color personalizados por organización. +- **Dynamic LAN Connectivity**: Detección automática de la IP del servidor para acceso fluido multi-dispositivo. +- **Mobile-First Navigation**: Menús laterales responsivos y diseños adaptativos para todas las pantallas. +- **Context-Aware AI Tutor**: Asistente inteligente con RAG que recuerda lecciones pasadas y protege respuestas. +- **Personalized AI Feedback**: Retroalimentación motivacional e instruccional generada únicamente para cada estudiante. +- **Color-Coded Navigation**: Indicadores visuales de progreso en tiempo real (Verde/Amarillo/Rojo). +- **Discussion Forums**: Sistema completo de foros con hilos, votos, moderación y suscripciones. +- **Course Announcements**: Sistema de comunicación instructor-estudiante con notificaciones automáticas. +- **Split Authentication**: Flujos de inicio de sesión separados para usuarios personales y empresas con soporte SSO. +- **Mercado Pago Monetization**: Pasarela de pagos integrada con desbloqueo automático de cursos. +- **Student Notes Panel**: Anotaciones personales con interfaz glassmorphism y autoguardado inteligente. +- **Cohort Management**: Sistema de gestión de grupos con seguimiento de progreso por cohorte. +- **Advanced Gradebook**: Seguimiento del desempeño estudiantil con analíticas y filtrado avanzado. ## 📄 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/roadmap.md b/roadmap.md index 72ad0aa..2442647 100644 --- a/roadmap.md +++ b/roadmap.md @@ -183,8 +183,8 @@ - [x] **Student Notes**: Anotaciones personales por lección con exportación a PDF. - [x] **Peer Assessment**: Evaluación entre pares con rúbricas configurables. - [x] **Cohorts & Groups**: Segmentación de estudiantes con contenido específico. -- [ ] **Content Libraries**: Repositorio reutilizable de bloques y lecciones. -- [ ] **Advanced Grading**: Rúbricas detalladas y workflows de calificación. +- [x] **Content Libraries**: Repositorio reutilizable de bloques y lecciones. +- [x] **Advanced Grading**: Rúbricas detalladas y workflows de calificación. - [ ] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones. - [ ] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva. - [ ] **Course Teams**: Múltiples instructores con roles y permisos granulares. @@ -215,9 +215,9 @@ --- -**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** y **monetización integrada con Mercado Pago**. +**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**, **monetización integrada con Mercado Pago**, **Librerías de Contenido reutilizables** y **Sistema de Rúbricas Avanzado**. **Próximas Prioridades**: -1. **Content Libraries**: Repositorio reutilizable de bloques y lecciones. -2. **Advanced Grading**: Rúbricas detalladas y workflows de calificación. -3. **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones. +1. **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones. +2. **Bulk Operations**: Inscripción masiva y exportación avanzada de datos. +3. **Course Teams**: Gestión de múltiples instructores por curso. diff --git a/services/cms-service/migrations/20260217000001_advanced_grading.sql b/services/cms-service/migrations/20260217000001_advanced_grading.sql new file mode 100644 index 0000000..79efab2 --- /dev/null +++ b/services/cms-service/migrations/20260217000001_advanced_grading.sql @@ -0,0 +1,93 @@ +-- Migration: Advanced Grading System (Rubrics) +-- Description: Creates tables for rubric-based assessment and grading workflows +-- Created: 2026-02-17 + +-- Rubrics table - Reusable evaluation templates +CREATE TABLE IF NOT EXISTS rubrics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + course_id UUID REFERENCES courses(id) ON DELETE CASCADE, -- NULL = reusable across courses + created_by UUID NOT NULL REFERENCES users(id), + name VARCHAR(255) NOT NULL, + description TEXT, + total_points INTEGER NOT NULL DEFAULT 100, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_rubrics_org ON rubrics(organization_id); +CREATE INDEX idx_rubrics_course ON rubrics(course_id); + +-- Rubric Criteria - Evaluation dimensions +CREATE TABLE IF NOT EXISTS rubric_criteria ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + rubric_id UUID NOT NULL REFERENCES rubrics(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + max_points INTEGER NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_criteria_rubric ON rubric_criteria(rubric_id); + +-- Rubric Levels - Performance levels per criterion +CREATE TABLE IF NOT EXISTS rubric_levels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + criterion_id UUID NOT NULL REFERENCES rubric_criteria(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, -- e.g., "Excellent", "Proficient", "Developing" + description TEXT, + points INTEGER NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_levels_criterion ON rubric_levels(criterion_id); + +-- Lesson-Rubric Association +CREATE TABLE IF NOT EXISTS lesson_rubrics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + rubric_id UUID NOT NULL REFERENCES rubrics(id) ON DELETE CASCADE, + is_active BOOLEAN DEFAULT TRUE, + assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(lesson_id, rubric_id) +); + +CREATE INDEX idx_lesson_rubrics_lesson ON lesson_rubrics(lesson_id); +CREATE INDEX idx_lesson_rubrics_rubric ON lesson_rubrics(rubric_id); + +-- Rubric Assessments - Student evaluation results +CREATE TABLE IF NOT EXISTS rubric_assessments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + rubric_id UUID NOT NULL REFERENCES rubrics(id), + user_id UUID NOT NULL REFERENCES users(id), -- student being assessed + graded_by UUID REFERENCES users(id), -- instructor or peer reviewer + submission_id UUID, -- if linked to peer_submissions + total_score DECIMAL(5,2) NOT NULL, + max_score INTEGER NOT NULL, + feedback TEXT, + status VARCHAR(50) DEFAULT 'draft', -- draft, submitted, published + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_assessments_lesson ON rubric_assessments(lesson_id); +CREATE INDEX idx_assessments_user ON rubric_assessments(user_id); +CREATE INDEX idx_assessments_grader ON rubric_assessments(graded_by); +CREATE INDEX idx_assessments_status ON rubric_assessments(status); + +-- Assessment Scores - Individual criterion scores +CREATE TABLE IF NOT EXISTS assessment_scores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assessment_id UUID NOT NULL REFERENCES rubric_assessments(id) ON DELETE CASCADE, + criterion_id UUID NOT NULL REFERENCES rubric_criteria(id), + level_id UUID REFERENCES rubric_levels(id), -- selected performance level + points DECIMAL(5,2) NOT NULL, + feedback TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_scores_assessment ON assessment_scores(assessment_id); +CREATE INDEX idx_scores_criterion ON assessment_scores(criterion_id); diff --git a/services/cms-service/migrations/20260218000001_lesson_dependencies.sql b/services/cms-service/migrations/20260218000001_lesson_dependencies.sql new file mode 100644 index 0000000..4c31688 --- /dev/null +++ b/services/cms-service/migrations/20260218000001_lesson_dependencies.sql @@ -0,0 +1,17 @@ +-- Migración para crear la tabla de dependencias de lecciones (Learning Sequences) + +CREATE TABLE lesson_dependencies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + prerequisite_lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + min_score_percentage DOUBLE PRECISION, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(lesson_id, prerequisite_lesson_id), + CHECK (lesson_id != prerequisite_lesson_id) +); + +-- Índices para mejorar el rendimiento de las consultas +CREATE INDEX idx_lesson_dependencies_lesson_id ON lesson_dependencies(lesson_id); +CREATE INDEX idx_lesson_dependencies_prerequisite_id ON lesson_dependencies(prerequisite_lesson_id); +CREATE INDEX idx_lesson_dependencies_org_id ON lesson_dependencies(organization_id); diff --git a/services/cms-service/src/handlers_dependencies.rs b/services/cms-service/src/handlers_dependencies.rs new file mode 100644 index 0000000..de43314 --- /dev/null +++ b/services/cms-service/src/handlers_dependencies.rs @@ -0,0 +1,102 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use common::middleware::Org; +use common::models::LessonDependency; +use serde::Deserialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct AssignDependencyPayload { + pub prerequisite_lesson_id: Uuid, + pub min_score_percentage: Option, +} + +pub async fn assign_dependency( + Org(org_ctx): Org, + State(pool): State, + Path(lesson_id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + // 1. Validar que ambas lecciones pertenecen a la organización + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM lessons WHERE id IN ($1, $2) AND organization_id = $3", + ) + .bind(lesson_id) + .bind(payload.prerequisite_lesson_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if count != 2 { + return Err(StatusCode::NOT_FOUND); + } + + // 2. Insertar la dependencia + let dependency = sqlx::query_as!( + LessonDependency, + r#" + INSERT INTO lesson_dependencies (organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage) + VALUES ($1, $2, $3, $4) + ON CONFLICT (lesson_id, prerequisite_lesson_id) + DO UPDATE SET min_score_percentage = EXCLUDED.min_score_percentage + RETURNING id, organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage, created_at + "#, + org_ctx.id, + lesson_id, + payload.prerequisite_lesson_id, + payload.min_score_percentage + ) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to assign dependency: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(dependency)) +} + +pub async fn remove_dependency( + Org(org_ctx): Org, + State(pool): State, + Path((lesson_id, prerequisite_id)): Path<(Uuid, Uuid)>, +) -> Result { + let result = sqlx::query!( + "DELETE FROM lesson_dependencies WHERE lesson_id = $1 AND prerequisite_lesson_id = $2 AND organization_id = $3", + lesson_id, + prerequisite_id, + org_ctx.id + ) + .execute(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if result.rows_affected() == 0 { + return Err(StatusCode::NOT_FOUND); + } + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn list_lesson_dependencies( + Org(org_ctx): Org, + State(pool): State, + Path(lesson_id): Path, +) -> Result>, StatusCode> { + let dependencies = sqlx::query_as!( + LessonDependency, + "SELECT * FROM lesson_dependencies WHERE lesson_id = $1 AND organization_id = $2", + lesson_id, + org_ctx.id + ) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(dependencies)) +} diff --git a/services/cms-service/src/handlers_rubrics.rs b/services/cms-service/src/handlers_rubrics.rs new file mode 100644 index 0000000..73d9f63 --- /dev/null +++ b/services/cms-service/src/handlers_rubrics.rs @@ -0,0 +1,601 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use common::auth::Claims; +use common::middleware::Org; +use common::models::{LessonRubric, Rubric, RubricCriterion, RubricLevel}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +// ==================== Payload Structs ==================== + +#[derive(Debug, Deserialize)] +pub struct CreateRubricPayload { + pub name: String, + pub description: Option, + pub course_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateRubricPayload { + pub name: Option, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateCriterionPayload { + pub name: String, + pub description: Option, + pub max_points: i32, + pub position: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateCriterionPayload { + pub name: Option, + pub description: Option, + pub max_points: Option, + pub position: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateLevelPayload { + pub name: String, + pub description: Option, + pub points: i32, + pub position: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateLevelPayload { + pub name: Option, + pub description: Option, + pub points: Option, + pub position: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateAssessmentPayload { + pub lesson_id: Uuid, + pub rubric_id: Uuid, + pub user_id: Uuid, + pub submission_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateAssessmentPayload { + pub total_score: f32, + pub feedback: Option, + pub scores: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CriterionScorePayload { + pub criterion_id: Uuid, + pub level_id: Option, + pub points: f32, + pub feedback: Option, +} + +#[derive(Debug, Serialize)] +pub struct RubricWithDetails { + #[serde(flatten)] + pub rubric: Rubric, + pub criteria: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CriterionWithLevels { + #[serde(flatten)] + pub criterion: RubricCriterion, + pub levels: Vec, +} + +// ==================== Rubric Management ==================== + +/// Create a new rubric +pub async fn create_rubric( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let rubric = sqlx::query_as!( + Rubric, + r#" + INSERT INTO rubrics (organization_id, course_id, created_by, name, description) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at + "#, + org_ctx.id, + payload.course_id.or(Some(course_id)), + claims.sub, + payload.name, + payload.description + ) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(rubric)) +} + +/// List all rubrics for a course +pub async fn list_course_rubrics( + Org(org_ctx): Org, + State(pool): State, + Path(course_id): Path, +) -> Result>, (StatusCode, String)> { + let rubrics = sqlx::query_as!( + Rubric, + r#" + SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at + FROM rubrics + WHERE organization_id = $1 AND (course_id = $2 OR course_id IS NULL) + ORDER BY created_at DESC + "#, + org_ctx.id, + course_id + ) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(rubrics)) +} + +/// Get a rubric with all criteria and levels +pub async fn get_rubric_with_details( + Org(org_ctx): Org, + State(pool): State, + Path(rubric_id): Path, +) -> Result, (StatusCode, String)> { + // Get rubric + let rubric = sqlx::query_as!( + Rubric, + r#" + SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at + FROM rubrics + WHERE id = $1 AND organization_id = $2 + "#, + rubric_id, + org_ctx.id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?; + + // Get criteria + let criteria = sqlx::query_as!( + RubricCriterion, + r#" + SELECT id, rubric_id, name, description, max_points, position, created_at + FROM rubric_criteria + WHERE rubric_id = $1 + ORDER BY position ASC + "#, + rubric_id + ) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Get levels for each criterion + let mut criteria_with_levels = Vec::new(); + for criterion in criteria { + let levels = sqlx::query_as!( + RubricLevel, + r#" + SELECT id, criterion_id, name, description, points, position, created_at + FROM rubric_levels + WHERE criterion_id = $1 + ORDER BY position ASC + "#, + criterion.id + ) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + criteria_with_levels.push(CriterionWithLevels { criterion, levels }); + } + + Ok(Json(RubricWithDetails { + rubric, + criteria: criteria_with_levels, + })) +} + +/// Update a rubric +pub async fn update_rubric( + Org(org_ctx): Org, + State(pool): State, + Path(rubric_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let rubric = sqlx::query_as!( + Rubric, + r#" + UPDATE rubrics + SET name = COALESCE($1, name), + description = COALESCE($2, description), + updated_at = NOW() + WHERE id = $3 AND organization_id = $4 + RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at + "#, + payload.name, + payload.description, + rubric_id, + org_ctx.id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?; + + Ok(Json(rubric)) +} + +/// Delete a rubric +pub async fn delete_rubric( + Org(org_ctx): Org, + State(pool): State, + Path(rubric_id): Path, +) -> Result { + let result = sqlx::query!( + "DELETE FROM rubrics WHERE id = $1 AND organization_id = $2", + rubric_id, + org_ctx.id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Rubric not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +// ==================== Criterion Management ==================== + +/// Add a criterion to a rubric +pub async fn create_criterion( + Org(org_ctx): Org, + State(pool): State, + Path(rubric_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Verify rubric exists and belongs to org + let _rubric = sqlx::query!( + "SELECT id FROM rubrics WHERE id = $1 AND organization_id = $2", + rubric_id, + org_ctx.id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?; + + let position = payload.position.unwrap_or(0); + + let criterion = sqlx::query_as!( + RubricCriterion, + r#" + INSERT INTO rubric_criteria (rubric_id, name, description, max_points, position) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, rubric_id, name, description, max_points, position, created_at + "#, + rubric_id, + payload.name, + payload.description, + payload.max_points, + position + ) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Update rubric total_points + let _= sqlx::query!( + r#" + UPDATE rubrics + SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), + updated_at = NOW() + WHERE id = $1 + "#, + rubric_id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(criterion)) +} + +/// Update a criterion +pub async fn update_criterion( + Org(org_ctx): Org, + State(pool): State, + Path(criterion_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let criterion = sqlx::query_as!( + RubricCriterion, + r#" + UPDATE rubric_criteria + SET name = COALESCE($1, name), + description = COALESCE($2, description), + max_points = COALESCE($3, max_points), + position = COALESCE($4, position) + WHERE id = $5 + AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6) + RETURNING id, rubric_id, name, description, max_points, position, created_at + "#, + payload.name, + payload.description, + payload.max_points, + payload.position, + criterion_id, + org_ctx.id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?; + + // Update rubric total_points if max_points changed + if payload.max_points.is_some() { + let _ = sqlx::query!( + r#" + UPDATE rubrics + SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), + updated_at = NOW() + WHERE id = $1 + "#, + criterion.rubric_id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + + Ok(Json(criterion)) +} + +/// Delete a criterion +pub async fn delete_criterion( + Org(org_ctx): Org, + State(pool): State, + Path(criterion_id): Path, +) -> Result { + // Get rubric_id before deleting + let criterion = sqlx::query!( + "SELECT rubric_id FROM rubric_criteria WHERE id = $1", + criterion_id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?; + + let result = sqlx::query!( + r#" + DELETE FROM rubric_criteria + WHERE id = $1 + AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2) + "#, + criterion_id, + org_ctx.id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Criterion not found".to_string())); + } + + // Update rubric total_points + let _ = sqlx::query!( + r#" + UPDATE rubrics + SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), + updated_at = NOW() + WHERE id = $1 + "#, + criterion.rubric_id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + +// ==================== Performance Level Management ==================== + +/// Add a performance level to a criterion +pub async fn create_level( + Org(org_ctx): Org, + State(pool): State, + Path(criterion_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Verify criterion exists and belongs to org + let _criterion = sqlx::query!( + "SELECT id FROM rubric_criteria WHERE id = $1 AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)", + criterion_id, + org_ctx.id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?; + + let position = payload.position.unwrap_or(0); + + let level = sqlx::query_as!( + RubricLevel, + r#" + INSERT INTO rubric_levels (criterion_id, name, description, points, position) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, criterion_id, name, description, points, position, created_at + "#, + criterion_id, + payload.name, + payload.description, + payload.points, + position + ) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(level)) +} + +/// Update a performance level +pub async fn update_level( + Org(org_ctx): Org, + State(pool): State, + Path(level_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let level = sqlx::query_as!( + RubricLevel, + r#" + UPDATE rubric_levels + SET name = COALESCE($1, name), + description = COALESCE($2, description), + points = COALESCE($3, points), + position = COALESCE($4, position) + WHERE id = $5 + AND criterion_id IN ( + SELECT id FROM rubric_criteria + WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6) + ) + RETURNING id, criterion_id, name, description, points, position, created_at + "#, + payload.name, + payload.description, + payload.points, + payload.position, + level_id, + org_ctx.id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Level not found".to_string()))?; + + Ok(Json(level)) +} + +/// Delete a performance level +pub async fn delete_level( + Org(org_ctx): Org, + State(pool): State, + Path(level_id): Path, +) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM rubric_levels + WHERE id = $1 + AND criterion_id IN ( + SELECT id FROM rubric_criteria + WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2) + ) + "#, + level_id, + org_ctx.id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Level not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +// ==================== Lesson-Rubric Association ==================== + +/// Assign a rubric to a lesson +pub async fn assign_rubric_to_lesson( + Org(org_ctx): Org, + State(pool): State, + Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>, +) -> Result, (StatusCode, String)> { + let lesson_rubric = sqlx::query_as!( + LessonRubric, + r#" + INSERT INTO lesson_rubrics (lesson_id, rubric_id, is_active) + VALUES ($1, $2, true) + ON CONFLICT (lesson_id, rubric_id) DO UPDATE SET is_active = true + RETURNING id, lesson_id, rubric_id, is_active, assigned_at + "#, + lesson_id, + rubric_id + ) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(lesson_rubric)) +} + +/// Unassign a rubric from a lesson +pub async fn unassign_rubric_from_lesson( + Org(org_ctx): Org, + State(pool): State, + Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>, +) -> Result { + let result = sqlx::query!( + "DELETE FROM lesson_rubrics WHERE lesson_id = $1 AND rubric_id = $2", + lesson_id, + rubric_id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +/// Get rubrics assigned to a lesson +pub async fn get_lesson_rubrics( + Org(org_ctx): Org, + State(pool): State, + Path(lesson_id): Path, +) -> Result>, (StatusCode, String)> { + let rubrics = sqlx::query_as!( + Rubric, + r#" + SELECT r.id, r.organization_id, r.course_id, r.created_by, r.name, r.description, r.total_points, r.created_at, r.updated_at + FROM rubrics r + INNER JOIN lesson_rubrics lr ON lr.rubric_id = r.id + WHERE lr.lesson_id = $1 AND lr.is_active = true AND r.organization_id = $2 + ORDER BY lr.assigned_at DESC + "#, + lesson_id, + org_ctx.id + ) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(rubrics)) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index f6e97cc..a75cd43 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -3,14 +3,16 @@ pub mod exporter; mod external_handlers; mod handlers; mod handlers_branding; +mod handlers_dependencies; mod handlers_library; +mod handlers_rubrics; mod webhooks; use axum::{ Router, extract::DefaultBodyLimit, middleware, - routing::{delete, get, post}, + routing::{delete, get, post, put}, }; use dotenvy::dotenv; use sqlx::postgres::PgPoolOptions; @@ -196,6 +198,52 @@ async fn main() { "/library/blocks/{id}/increment-usage", post(handlers_library::increment_block_usage), ) + // Advanced Grading (Rubrics) routes + .route( + "/courses/{id}/rubrics", + get(handlers_rubrics::list_course_rubrics).post(handlers_rubrics::create_rubric), + ) + .route( + "/rubrics/{id}", + get(handlers_rubrics::get_rubric_with_details) + .put(handlers_rubrics::update_rubric) + .delete(handlers_rubrics::delete_rubric), + ) + .route( + "/rubrics/{id}/criteria", + post(handlers_rubrics::create_criterion), + ) + .route( + "/criteria/{id}", + put(handlers_rubrics::update_criterion).delete(handlers_rubrics::delete_criterion), + ) + .route( + "/criteria/{id}/levels", + post(handlers_rubrics::create_level), + ) + .route( + "/levels/{id}", + put(handlers_rubrics::update_level).delete(handlers_rubrics::delete_level), + ) + .route( + "/lessons/{lesson_id}/rubrics/{rubric_id}", + post(handlers_rubrics::assign_rubric_to_lesson) + .delete(handlers_rubrics::unassign_rubric_from_lesson), + ) + .route( + "/lessons/{id}/rubrics", + get(handlers_rubrics::get_lesson_rubrics), + ) + // Learning Sequences (Dependencies) routes + .route( + "/lessons/{id}/dependencies", + get(handlers_dependencies::list_lesson_dependencies) + .post(handlers_dependencies::assign_dependency), + ) + .route( + "/lessons/{id}/dependencies/{prerequisite_id}", + delete(handlers_dependencies::remove_dependency), + ) .route_layer(middleware::from_fn( common::middleware::org_extractor_middleware, )); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 37efde1..e81547c 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -600,11 +600,31 @@ pub async fn get_course_outline( pub_modules.push(common::models::PublishedModule { module, lessons }); } + // 6. Fetch all dependencies for this course + let dependencies = sqlx::query_as!( + LessonDependency, + r#" + SELECT ld.* + FROM lesson_dependencies ld + JOIN lessons l ON ld.lesson_id = l.id + JOIN modules m ON l.module_id = m.id + WHERE m.course_id = $1 + "#, + id + ) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("get_course_outline: dependencies fetch failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Json(common::models::PublishedCourse { course, organization, grading_categories, modules: pub_modules, + dependencies: Some(dependencies), })) } @@ -620,7 +640,7 @@ pub async fn get_lesson_content( claims.sub ); - // Check if user is enrolled in the course this lesson belongs to + // 1. Check if user is enrolled in the course this lesson belongs to let lesson = sqlx::query_as::<_, Lesson>( "SELECT l.* FROM lessons l JOIN modules m ON l.module_id = m.id @@ -636,17 +656,64 @@ pub async fn get_lesson_content( StatusCode::INTERNAL_SERVER_ERROR })?; - match lesson { - Some(l) => Ok(Json(l)), + let lesson = match lesson { + Some(l) => l, None => { tracing::warn!( "get_lesson_content: User {} not enrolled or lesson {} not found", claims.sub, id ); - Err(StatusCode::FORBIDDEN) + return Err(StatusCode::FORBIDDEN); } + }; + + // 2. Enforce Prerequisites + // We check if there are any prerequisites that the user hasn't completed yet. + // A prerequisite is completed if: + // a) It's graded and the user has a grade >= min_score_percentage (default 0) + // b) It's not graded and the user has a 'complete' interaction + let unmet_dependencies = sqlx::query!( + r#" + SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage + FROM lesson_dependencies ld + JOIN lessons p ON ld.prerequisite_lesson_id = p.id + LEFT JOIN user_grades ug ON ld.prerequisite_lesson_id = ug.lesson_id AND ug.user_id = $2 + LEFT JOIN lesson_interactions li ON ld.prerequisite_lesson_id = li.lesson_id + AND li.user_id = $2 AND li.event_type = 'complete' + WHERE ld.lesson_id = $1 + AND ( + (p.is_graded = true AND (ug.score IS NULL OR (ug.score * 100.0) < COALESCE(ld.min_score_percentage, 0.0))) + OR + (p.is_graded = false AND li.id IS NULL) + ) + "#, + id, + claims.sub + ) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("get_lesson_content: failed to check dependencies: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !unmet_dependencies.is_empty() { + let names: Vec = unmet_dependencies + .iter() + .map(|d| d.prereq_title.clone()) + .collect(); + tracing::warn!( + "get_lesson_content: User {} blocked for lesson {} by prerequisites: {:?}", + claims.sub, + id, + names + ); + // We could return a custom error body here, but for now 403 Forbidden is consistent. + return Err(StatusCode::FORBIDDEN); } + + Ok(Json(lesson)) } pub async fn get_user_enrollments( diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 2397b18..6948cf8 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -211,6 +211,8 @@ pub struct PublishedCourse { pub organization: Organization, pub grading_categories: Vec, pub modules: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub dependencies: Option>, } #[derive(Debug, Serialize, Deserialize)] @@ -691,3 +693,87 @@ mod tests { assert_eq!(deserialized.modules[0].lessons[0].title, "Test Lesson"); } } + +// ==================== Advanced Grading / Rubrics ==================== + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct Rubric { + pub id: Uuid, + pub organization_id: Uuid, + pub course_id: Option, + pub created_by: Uuid, + pub name: String, + pub description: Option, + pub total_points: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct RubricCriterion { + pub id: Uuid, + pub rubric_id: Uuid, + pub name: String, + pub description: Option, + pub max_points: i32, + pub position: i32, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct RubricLevel { + pub id: Uuid, + pub criterion_id: Uuid, + pub name: String, + pub description: Option, + pub points: i32, + pub position: i32, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct LessonRubric { + pub id: Uuid, + pub lesson_id: Uuid, + pub rubric_id: Uuid, + pub is_active: bool, + pub assigned_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct RubricAssessment { + pub id: Uuid, + pub lesson_id: Uuid, + pub rubric_id: Uuid, + pub user_id: Uuid, + pub graded_by: Option, + pub submission_id: Option, + pub total_score: f32, + pub max_score: i32, + pub feedback: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct AssessmentScore { + pub id: Uuid, + pub assessment_id: Uuid, + pub criterion_id: Uuid, + pub level_id: Option, + pub points: f32, + pub feedback: Option, + pub created_at: DateTime, +} +// ==================== Learning Sequences / Dependencies ==================== + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct LessonDependency { + pub id: Uuid, + pub organization_id: Uuid, + pub lesson_id: Uuid, + pub prerequisite_lesson_id: Uuid, + pub min_score_percentage: Option, + pub created_at: DateTime, +} diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index a6f4f74..4fb547c 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { lmsApi, Course, Module, Recommendation, UserGrade } from "@/lib/api"; import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } from "lucide-react"; import Link from "next/link"; -import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react"; +import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react"; import { useAuth } from "@/context/AuthContext"; import DiscussionBoard from "@/components/DiscussionBoard"; import { AnnouncementsList } from "@/components/AnnouncementsList"; @@ -17,6 +17,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } const [loadingAI, setLoadingAI] = useState(false); const [userGrades, setUserGrades] = useState([]); const [isEnrolled, setIsEnrolled] = useState(false); + const [lessonDependencies, setLessonDependencies] = useState([]); useEffect(() => { const fetchData = async () => { @@ -24,6 +25,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } setLoading(true); const data = await lmsApi.getCourseOutline(params.id); setCourseData({ ...data.course, modules: data.modules }); + setLessonDependencies(data.dependencies || []); if (user) { const grades = await lmsApi.getUserGrades(user.id, params.id); @@ -88,7 +90,23 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } if (!courseData) return
Curso no encontrado.
; + const isLessonLocked = (lessonId: string) => { + if (!isEnrolled) return false; + const deps = lessonDependencies.filter(d => d.lesson_id === lessonId); + if (deps.length === 0) return false; + + return deps.some(dep => { + const prereqGrade = userGrades.find(g => g.lesson_id === dep.prerequisite_lesson_id); + if (!prereqGrade) return true; // Not completed at all + if (dep.min_score_percentage && (prereqGrade.score * 100) < dep.min_score_percentage) return true; + return false; + }); + }; + const getStatusIcon = (lessonId: string, isGraded: boolean, allowRetry: boolean) => { + if (isLessonLocked(lessonId)) { + return ; + } const grade = userGrades.find((g: UserGrade) => g.lesson_id === lessonId); if (!grade) { return ; @@ -263,43 +281,63 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
- {module.lessons.map((lesson: any) => ( - isEnrolled ? ( - -
+ {module.lessons.map((lesson: any) => { + const locked = isLessonLocked(lesson.id); + return isEnrolled ? ( + locked ? ( +
-
- {lesson.content_type === 'video' ? ( - - ) : ( - - )} +
+
-

{lesson.title}

- - {lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'} - +

{lesson.title}

+ Bloqueado por Prerrequisitos
- {getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)} - {lesson.due_date && ( -
-
Vencimiento
-
- {new Date(lesson.due_date).toLocaleDateString()} -
-
- )} -
- -
+
- + ) : ( + +
+
+
+
+ {lesson.content_type === 'video' ? ( + + ) : ( + + )} +
+
+

{lesson.title}

+ + {lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'} + +
+
+
+ {getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)} + {lesson.due_date && ( +
+
Vencimiento
+
+ {new Date(lesson.due_date).toLocaleDateString()} +
+
+ )} +
+ +
+
+
+
+ + ) ) : (
@@ -319,8 +357,8 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
- ) - ))} + ); + })}
))} diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 60ebaa7..3ee32bd 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -138,6 +138,15 @@ export interface GradingCategory { drop_count: number; } +export interface LessonDependency { + id: string; + organization_id: string; + lesson_id: string; + prerequisite_lesson_id: string; + min_score_percentage: number | null; + created_at: string; +} + export interface UserGrade { id: string; user_id: string; @@ -366,7 +375,13 @@ export const lmsApi = { return apiFetch(`/catalog${query}`); }, - async getCourseOutline(courseId: string): Promise<{ course: Course, modules: Module[], grading_categories: GradingCategory[], organization: Organization }> { + async getCourseOutline(courseId: string): Promise<{ + course: Course, + modules: Module[], + grading_categories: GradingCategory[], + organization: Organization, + dependencies?: LessonDependency[] + }> { return apiFetch(`/courses/${courseId}/outline`); }, diff --git a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx index ab4d8c4..76faf07 100644 --- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -1,8 +1,27 @@ "use client"; -import { useEffect, useState } from "react"; -import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock } from "@/lib/api"; -import Link from "next/link"; +import { useEffect, useState, useCallback } from "react"; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency } from '@/lib/api'; +import { + Layout, + CheckCircle2, + Pencil, + Save, + Trash2, + Plus, + X, + ChevronDown, + ChevronUp, + Settings, + Target, + Eye, + Brain, + Library, + BookMarked, + ArrowLeft +} from 'lucide-react'; import DescriptionBlock from "@/components/blocks/DescriptionBlock"; import MediaBlock from "@/components/blocks/MediaBlock"; import QuizBlock from "@/components/blocks/QuizBlock"; @@ -19,16 +38,6 @@ import PeerReviewBlock from "@/components/blocks/PeerReviewBlock"; import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal"; import LibraryPanel from "@/components/LibraryPanel"; import Modal from "@/components/Modal"; -import { - Save, - X, - Pencil, - ChevronUp, - ChevronDown, - Trash2, - BookMarked, - Library -} from "lucide-react"; export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) { const [lesson, setLesson] = useState(null); @@ -50,15 +59,24 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI const [dueDate, setDueDate] = useState(""); const [importantDateType, setImportantDateType] = useState(""); - const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false); - const [aiQuizContext, setAiQuizContext] = useState(""); - const [aiQuizType, setAiQuizType] = useState("multiple-choice"); + // Rubric State + const [courseRubrics, setCourseRubrics] = useState([]); + const [assignedRubricIds, setAssignedRubricIds] = useState([]); // Content Libraries states const [isSaveToLibraryModalOpen, setIsSaveToLibraryModalOpen] = useState(false); const [blockToSave, setBlockToSave] = useState(null); const [isLibraryPanelOpen, setIsLibraryPanelOpen] = useState(false); + // Learning Sequences State + const [allLessons, setAllLessons] = useState([]); + const [dependencies, setDependencies] = useState([]); + + // AI Quiz Generation State + const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false); + const [aiQuizContext, setAiQuizContext] = useState(""); + const [aiQuizType, setAiQuizType] = useState("multiple-choice"); + const [editValue, setEditValue] = useState(""); @@ -120,6 +138,28 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI // Load grading categories const categories = await cmsApi.getGradingCategories(params.id); setGradingCategories(categories); + + // Load course rubrics and lesson rubrics + const [allRubrics, lessonRubrics, courseOutline, lessonDeps] = await Promise.all([ + cmsApi.listCourseRubrics(params.id), + cmsApi.getLessonRubrics(params.lessonId), + cmsApi.getCourseWithFullOutline(params.id), + cmsApi.listLessonDependencies(params.lessonId) + ]); + setCourseRubrics(allRubrics); + setAssignedRubricIds(lessonRubrics.map(r => r.id)); + + // Extract all lessons from outline for prerequisite selection + const lessons: Lesson[] = []; + courseOutline.modules?.forEach(m => { + m.lessons.forEach(l => { + if (l.id !== params.lessonId) { + lessons.push(l); + } + }); + }); + setAllLessons(lessons); + setDependencies(lessonDeps); } catch { console.error("Failed to load lesson or categories"); } finally { @@ -140,6 +180,21 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI } }; + const toggleRubric = async (rubricId: string, isAssigned: boolean) => { + try { + if (isAssigned) { + await cmsApi.unassignRubricFromLesson(params.lessonId, rubricId); + setAssignedRubricIds(assignedRubricIds.filter(id => id !== rubricId)); + } else { + await cmsApi.assignRubricToLesson(params.lessonId, rubricId); + setAssignedRubricIds([...assignedRubricIds, rubricId]); + } + } catch (err) { + console.error("Failed to toggle rubric", err); + alert("Failed to update rubric assignment."); + } + }; + const handleSave = async () => { if (!lesson) return; setIsSaving(true); @@ -265,7 +320,22 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI } }; - const handleGenerateQuiz = async () => { + const toggleDependency = async (prerequisiteId: string, isAssigned: boolean) => { + try { + if (isAssigned) { + await cmsApi.removeDependency(params.lessonId, prerequisiteId); + setDependencies(dependencies.filter(d => d.prerequisite_lesson_id !== prerequisiteId)); + } else { + const newDep = await cmsApi.assignDependency(params.lessonId, { prerequisite_lesson_id: prerequisiteId }); + setDependencies([...dependencies, newDep]); + } + } catch (err) { + console.error("Failed to toggle dependency", err); + alert("Failed to update prerequisite assignment."); + } + }; + + const handleGenerateQuiz = () => { setIsAIQuizModalOpen(true); }; @@ -368,22 +438,54 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI {isGraded && ( <> -
- Assessment Category - -
- Manage categories in Grading Policy +
+
+ Assessment Category + +
+ Manage categories in Grading Policy +
+
+ +
+ Rubric (Optional) +
+ {courseRubrics.length === 0 ? ( +

No rubrics found in this course.

+ ) : ( + courseRubrics.map(rubric => { + const isAssigned = assignedRubricIds.includes(rubric.id); + return ( + + ); + }) + )} +
+
+ Manage rubrics in Rubrics Manager +
@@ -419,6 +521,104 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI

Enables "Check Answer" buttons for individual blocks

+ +
+
+ + Access & Prerequisites +
+ +
+
+

Prerequisites

+

+ Students must complete these lessons before they can access this one. +

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

No other lessons available.

+ ) : ( + allLessons.map(l => { + const dep = dependencies.find(d => d.prerequisite_lesson_id === l.id); + const isAssigned = !!dep; + return ( +
+ + {isAssigned && l.is_graded && ( +
+
+ Min. Score % + { + const minScore = parseFloat(e.target.value); + try { + const updated = await cmsApi.assignDependency(params.lessonId, { + prerequisite_lesson_id: l.id, + min_score_percentage: minScore + }); + setDependencies(dependencies.map(d => d.id === updated.id ? updated : d)); + } catch (err) { + console.error("Failed to update min score", err); + } + }} + className="w-16 bg-black/40 border border-white/10 rounded-lg px-2 py-1 text-xs text-blue-400 font-bold focus:outline-none focus:border-blue-500" + /> +
+
+ )} +
+ ); + }) + )} +
+
+ +
+
+
+ +
+
+
Intelligent Sequences
+

+ Locked lessons will be visible in the student outline with a lock icon 🔒 until they meet all prerequisites. +

+
+
+ +
+
+ +
+
+
Completion Tracking
+

+ If a minimum score is set, students must pass the prerequisite before the next lesson is unlocked. +

+
+
+
+
+
)} diff --git a/web/studio/src/app/courses/[id]/rubrics/page.tsx b/web/studio/src/app/courses/[id]/rubrics/page.tsx new file mode 100644 index 0000000..7014638 --- /dev/null +++ b/web/studio/src/app/courses/[id]/rubrics/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React, { useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import CourseEditorLayout from "@/components/CourseEditorLayout"; +import RubricList from "@/components/Rubrics/RubricList"; +import RubricEditor from "@/components/Rubrics/RubricEditor"; +import { ArrowLeft, FileText, Info } from "lucide-react"; + +export default function RubricsPage() { + const { id } = useParams() as { id: string }; + const router = useRouter(); + const [editingRubricId, setEditingRubricId] = useState(null); + + return ( +
+
+ {/* Header */} +
+
+ +
+

+ Rubrics Management +

+

Create and manage evaluation rubrics for your course

+
+
+ +
+ + Rubrics can be assigned to multiple lessons across the course. +
+
+ + +
+ {editingRubricId ? ( + setEditingRubricId(null)} + /> + ) : ( + setEditingRubricId(rubricId)} + /> + )} +
+
+
+
+ ); +} diff --git a/web/studio/src/components/CourseEditorLayout.tsx b/web/studio/src/components/CourseEditorLayout.tsx index a0a4e02..eff6cb7 100644 --- a/web/studio/src/components/CourseEditorLayout.tsx +++ b/web/studio/src/components/CourseEditorLayout.tsx @@ -7,7 +7,7 @@ import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder, Graduation interface CourseEditorLayoutProps { children: React.ReactNode; - activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings" | "files" | "grades"; + activeTab: "outline" | "grading" | "rubrics" | "calendar" | "analytics" | "settings" | "files" | "grades"; } export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) { @@ -16,6 +16,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor const tabs = [ { key: "outline", label: "Outline", icon: Layout, href: `/courses/${id}` }, { key: "grading", label: "Grading Policy", icon: CheckCircle2, href: `/courses/${id}/grading` }, + { key: "rubrics", label: "Rubrics", icon: Layout, href: `/courses/${id}/rubrics` }, { key: "grades", label: "Gradebook", icon: GraduationCap, href: `/courses/${id}/grades` }, { key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` }, { key: "analytics", label: "Analytics", icon: BarChart2, href: `/courses/${id}/analytics` }, diff --git a/web/studio/src/components/Rubrics/RubricEditor.tsx b/web/studio/src/components/Rubrics/RubricEditor.tsx new file mode 100644 index 0000000..47c5572 --- /dev/null +++ b/web/studio/src/components/Rubrics/RubricEditor.tsx @@ -0,0 +1,376 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + RubricWithDetails, + RubricCriterion, + RubricLevel, + cmsApi, + CreateCriterionPayload, + CreateLevelPayload +} from "@/lib/api"; +import { + Plus, + Trash2, + Save, + X, + GripVertical, + ChevronDown, + ChevronUp, + AlertCircle, + Check +} from "lucide-react"; +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +interface RubricEditorProps { + rubricId: string; + courseId: string; + onClose: () => void; + onSaved?: () => void; +} + +export default function RubricEditor({ rubricId, courseId, onClose, onSaved }: RubricEditorProps) { + const [rubric, setRubric] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + loadRubric(); + }, [rubricId]); + + const loadRubric = async () => { + try { + setLoading(true); + const data = await cmsApi.getRubricWithDetails(rubricId); + setRubric(data); + } catch (err) { + console.error("Failed to load rubric", err); + setError("Failed to load rubric details."); + } finally { + setLoading(false); + } + }; + + const handleUpdateRubric = async (name: string, description: string) => { + if (!rubric) return; + try { + const updated = await cmsApi.updateRubric(rubricId, { name, description: description || undefined }); + setRubric({ ...rubric, ...updated }); + } catch (err) { + console.error("Failed to update rubric", err); + } + }; + + const handleAddCriterion = async () => { + if (!rubric) return; + const payload: CreateCriterionPayload = { + name: "New Criterion", + max_points: 10, + position: rubric.criteria.length + }; + try { + const newCriterion = await cmsApi.createCriterion(rubricId, payload); + setRubric({ + ...rubric, + criteria: [...rubric.criteria, { ...newCriterion, levels: [] }] + }); + } catch (err) { + console.error("Failed to add criterion", err); + } + }; + + const handleUpdateCriterion = async (criterionId: string, updates: Partial) => { + if (!rubric) return; + try { + const payload = { + ...updates, + description: updates.description === null ? undefined : updates.description + }; + const updated = await cmsApi.updateCriterion(criterionId, payload); + setRubric({ + ...rubric, + criteria: rubric.criteria.map(c => + c.id === criterionId + ? { ...c, ...updated } + : c + ) + }); + // Total points might have changed + if (updates.max_points !== undefined) { + const updatedRubric = await cmsApi.getRubricWithDetails(rubricId); + setRubric(updatedRubric); + } + } catch (err) { + console.error("Failed to update criterion", err); + } + }; + + const handleDeleteCriterion = async (criterionId: string) => { + if (!confirm("Are you sure you want to delete this criterion?")) return; + try { + await cmsApi.deleteCriterion(criterionId); + const updatedRubric = await cmsApi.getRubricWithDetails(rubricId); + setRubric(updatedRubric); + } catch (err) { + console.error("Failed to delete criterion", err); + } + }; + + const handleAddLevel = async (criterionId: string) => { + if (!rubric) return; + const criterion = rubric.criteria.find(c => c.id === criterionId); + if (!criterion) return; + + const payload: CreateLevelPayload = { + name: "New Level", + points: 0, + position: criterion.levels.length + }; + try { + const newLevel = await cmsApi.createLevel(criterionId, payload); + setRubric({ + ...rubric, + criteria: rubric.criteria.map(c => + c.id === criterionId + ? { ...c, levels: [...c.levels, newLevel] } + : c + ) + }); + } catch (err) { + console.error("Failed to add level", err); + } + }; + + const handleUpdateLevel = async (levelId: string, criterionId: string, updates: Partial) => { + if (!rubric) return; + try { + const payload = { + ...updates, + description: updates.description === null ? undefined : updates.description + }; + const updated = await cmsApi.updateLevel(levelId, payload); + setRubric({ + ...rubric, + criteria: rubric.criteria.map(c => + c.id === criterionId + ? { ...c, levels: c.levels.map(l => l.id === levelId ? updated : l) } + : c + ) + }); + } catch (err) { + console.error("Failed to update level", err); + } + }; + + const handleDeleteLevel = async (levelId: string, criterionId: string) => { + try { + await cmsApi.deleteLevel(levelId); + setRubric({ + ...rubric!, + criteria: rubric!.criteria.map(c => + c.id === criterionId + ? { ...c, levels: c.levels.filter(l => l.id !== levelId) } + : c + ) + }); + } catch (err) { + console.error("Failed to delete level", err); + } + }; + + if (loading) return ( +
+
+
+ ); + + if (error || !rubric) return ( +
+ +

{error || "Rubric not found"}

+ +
+ ); + + return ( +
+ {/* Header */} +
+
+ handleUpdateRubric(e.target.value, rubric.description || "")} + placeholder="Rubric Name" + className="bg-transparent text-2xl font-bold text-white focus:outline-none focus:ring-1 focus:ring-blue-500/50 rounded px-2 w-full" + /> + handleUpdateRubric(rubric.name, e.target.value)} + placeholder="Add a description..." + className="bg-transparent text-sm text-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500/50 rounded px-2 mt-1 w-full" + /> +
+
+
+ Total Points + {rubric.total_points} +
+ +
+
+ + {/* Content */} +
+
+

+ Criteria + + {rubric.criteria.length} items + +

+ +
+ +
+ {rubric.criteria.length === 0 ? ( +
+

No criteria added yet. Add your first evaluation criterion.

+
+ ) : ( + rubric.criteria.map((item, idx) => ( +
+
+
+ +
+
+
+
+ handleUpdateCriterion(item.id, { name: e.target.value })} + className="bg-transparent text-lg font-bold text-gray-100 focus:outline-none focus:border-b border-blue-500/50 w-full" + /> + handleUpdateCriterion(item.id, { description: e.target.value })} + placeholder="Description of what to evaluate..." + className="bg-transparent text-sm text-gray-500 focus:outline-none w-full mt-1" + /> +
+
+
+ + handleUpdateCriterion(item.id, { max_points: parseInt(e.target.value) || 0 })} + className="bg-white/5 border border-white/10 rounded-lg px-2 py-1 w-16 text-center text-blue-400 font-bold focus:outline-none focus:border-blue-500" + /> +
+ +
+
+ + {/* Levels */} +
+
+

Performance Levels

+ +
+
+ {item.levels.map(level => ( +
+ + handleUpdateLevel(level.id, item.id, { name: e.target.value })} + placeholder="Level Name (e.g. Excellent)" + className="bg-transparent text-sm font-semibold text-gray-200 focus:outline-none" + /> +