feat: Implement advanced grading (rubrics) and lesson dependencies across CMS service, API, and Studio UI.

This commit is contained in:
2026-02-17 22:43:19 -03:00
parent 12df920f60
commit f9e78a265a
17 changed files with 2181 additions and 124 deletions
+53 -48
View File
@@ -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.
+6 -6
View File
@@ -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.
@@ -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);
@@ -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);
@@ -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<f64>,
}
pub async fn assign_dependency(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(lesson_id): Path<Uuid>,
Json(payload): Json<AssignDependencyPayload>,
) -> Result<Json<LessonDependency>, 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<PgPool>,
Path((lesson_id, prerequisite_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, StatusCode> {
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<PgPool>,
Path(lesson_id): Path<Uuid>,
) -> Result<Json<Vec<LessonDependency>>, 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))
}
@@ -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<String>,
pub course_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateRubricPayload {
pub name: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCriterionPayload {
pub name: String,
pub description: Option<String>,
pub max_points: i32,
pub position: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateCriterionPayload {
pub name: Option<String>,
pub description: Option<String>,
pub max_points: Option<i32>,
pub position: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub struct CreateLevelPayload {
pub name: String,
pub description: Option<String>,
pub points: i32,
pub position: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateLevelPayload {
pub name: Option<String>,
pub description: Option<String>,
pub points: Option<i32>,
pub position: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub struct CreateAssessmentPayload {
pub lesson_id: Uuid,
pub rubric_id: Uuid,
pub user_id: Uuid,
pub submission_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAssessmentPayload {
pub total_score: f32,
pub feedback: Option<String>,
pub scores: Vec<CriterionScorePayload>,
}
#[derive(Debug, Deserialize)]
pub struct CriterionScorePayload {
pub criterion_id: Uuid,
pub level_id: Option<Uuid>,
pub points: f32,
pub feedback: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct RubricWithDetails {
#[serde(flatten)]
pub rubric: Rubric,
pub criteria: Vec<CriterionWithLevels>,
}
#[derive(Debug, Serialize)]
pub struct CriterionWithLevels {
#[serde(flatten)]
pub criterion: RubricCriterion,
pub levels: Vec<RubricLevel>,
}
// ==================== Rubric Management ====================
/// Create a new rubric
pub async fn create_rubric(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
Json(payload): Json<CreateRubricPayload>,
) -> Result<Json<Rubric>, (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<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<Vec<Rubric>>, (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<PgPool>,
Path(rubric_id): Path<Uuid>,
) -> Result<Json<RubricWithDetails>, (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<PgPool>,
Path(rubric_id): Path<Uuid>,
Json(payload): Json<UpdateRubricPayload>,
) -> Result<Json<Rubric>, (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<PgPool>,
Path(rubric_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
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<PgPool>,
Path(rubric_id): Path<Uuid>,
Json(payload): Json<CreateCriterionPayload>,
) -> Result<Json<RubricCriterion>, (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<PgPool>,
Path(criterion_id): Path<Uuid>,
Json(payload): Json<UpdateCriterionPayload>,
) -> Result<Json<RubricCriterion>, (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<PgPool>,
Path(criterion_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
// 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<PgPool>,
Path(criterion_id): Path<Uuid>,
Json(payload): Json<CreateLevelPayload>,
) -> Result<Json<RubricLevel>, (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<PgPool>,
Path(level_id): Path<Uuid>,
Json(payload): Json<UpdateLevelPayload>,
) -> Result<Json<RubricLevel>, (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<PgPool>,
Path(level_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
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<PgPool>,
Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<LessonRubric>, (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<PgPool>,
Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, (StatusCode, String)> {
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<PgPool>,
Path(lesson_id): Path<Uuid>,
) -> Result<Json<Vec<Rubric>>, (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))
}
+49 -1
View File
@@ -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,
));
+71 -4
View File
@@ -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<String> = 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(
+86
View File
@@ -211,6 +211,8 @@ pub struct PublishedCourse {
pub organization: Organization,
pub grading_categories: Vec<GradingCategory>,
pub modules: Vec<PublishedModule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dependencies: Option<Vec<LessonDependency>>,
}
#[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<Uuid>,
pub created_by: Uuid,
pub name: String,
pub description: Option<String>,
pub total_points: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct RubricCriterion {
pub id: Uuid,
pub rubric_id: Uuid,
pub name: String,
pub description: Option<String>,
pub max_points: i32,
pub position: i32,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
pub struct RubricLevel {
pub id: Uuid,
pub criterion_id: Uuid,
pub name: String,
pub description: Option<String>,
pub points: i32,
pub position: i32,
pub created_at: DateTime<Utc>,
}
#[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<Utc>,
}
#[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<Uuid>,
pub submission_id: Option<Uuid>,
pub total_score: f32,
pub max_score: i32,
pub feedback: Option<String>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct AssessmentScore {
pub id: Uuid,
pub assessment_id: Uuid,
pub criterion_id: Uuid,
pub level_id: Option<Uuid>,
pub points: f32,
pub feedback: Option<String>,
pub created_at: DateTime<Utc>,
}
// ==================== 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<f64>,
pub created_at: DateTime<Utc>,
}
+68 -30
View File
@@ -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<UserGrade[]>([]);
const [isEnrolled, setIsEnrolled] = useState(false);
const [lessonDependencies, setLessonDependencies] = useState<any[]>([]);
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 <div className="text-center py-20 text-gray-500">Curso no encontrado.</div>;
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 <Lock size={18} className="text-gray-600" />;
}
const grade = userGrades.find((g: UserGrade) => g.lesson_id === lessonId);
if (!grade) {
return <Circle size={18} className="text-white/20" />;
@@ -263,43 +281,63 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
<div className="grid gap-3 pl-14">
{module.lessons.map((lesson: any) => (
isEnrolled ? (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
{module.lessons.map((lesson: any) => {
const locked = isLessonLocked(lesson.id);
return isEnrolled ? (
locked ? (
<div key={lesson.id} className="glass-card !p-4 border-white/5 opacity-60 cursor-not-allowed">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
{lesson.content_type === 'video' ? (
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
) : (
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
)}
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center">
<Lock size={18} className="text-gray-600" />
</div>
<div>
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
</span>
<h3 className="text-sm font-bold text-gray-400">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-600">Bloqueado por Prerrequisitos</span>
</div>
</div>
<div className="flex items-center gap-6">
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
{lesson.due_date && (
<div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
{new Date(lesson.due_date).toLocaleDateString()}
</div>
</div>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight size={18} className="text-blue-500" />
</div>
<Lock size={18} className="text-gray-600" />
</div>
</div>
</div>
</Link>
) : (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
{lesson.content_type === 'video' ? (
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
) : (
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
)}
</div>
<div>
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
</span>
</div>
</div>
<div className="flex items-center gap-6">
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
{lesson.due_date && (
<div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
{new Date(lesson.due_date).toLocaleDateString()}
</div>
</div>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight size={18} className="text-blue-500" />
</div>
</div>
</div>
</div>
</Link>
)
) : (
<div key={lesson.id} onClick={handleEnrollOrBuy} className="glass-card !p-4 group border-white/5 opacity-60 cursor-pointer hover:bg-white/5 transition-all">
<div className="flex items-center justify-between">
@@ -319,8 +357,8 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
</div>
</div>
)
))}
);
})}
</div>
</div>
))}
+16 -1
View File
@@ -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`);
},
@@ -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<Lesson | null>(null);
@@ -50,15 +59,24 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [dueDate, setDueDate] = useState<string>("");
const [importantDateType, setImportantDateType] = useState<string>("");
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
const [aiQuizContext, setAiQuizContext] = useState("");
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
// Rubric State
const [courseRubrics, setCourseRubrics] = useState<Rubric[]>([]);
const [assignedRubricIds, setAssignedRubricIds] = useState<string[]>([]);
// Content Libraries states
const [isSaveToLibraryModalOpen, setIsSaveToLibraryModalOpen] = useState(false);
const [blockToSave, setBlockToSave] = useState<Block | null>(null);
const [isLibraryPanelOpen, setIsLibraryPanelOpen] = useState(false);
// Learning Sequences State
const [allLessons, setAllLessons] = useState<Lesson[]>([]);
const [dependencies, setDependencies] = useState<LessonDependency[]>([]);
// 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 && (
<>
<div className="col-span-full space-y-2">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
>
<option value="" className="bg-gray-900 border-0">Select Category...</option>
{gradingCategories.map((cat) => (
<option key={cat.id} value={cat.id} className="bg-gray-900 border-0">
{cat.name} ({cat.weight}%)
</option>
))}
</select>
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
Manage categories in <Link href={`/courses/${params.id}/grading`} className="text-blue-400 hover:underline">Grading Policy</Link>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
>
<option value="" className="bg-gray-900 border-0">Select Category...</option>
{gradingCategories.map((cat) => (
<option key={cat.id} value={cat.id} className="bg-gray-900 border-0">
{cat.name} ({cat.weight}%)
</option>
))}
</select>
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
Manage categories in <Link href={`/courses/${params.id}/grading`} className="text-blue-400 hover:underline">Grading Policy</Link>
</div>
</div>
<div className="space-y-4">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Rubric (Optional)</span>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-48 overflow-y-auto space-y-2">
{courseRubrics.length === 0 ? (
<p className="text-xs text-gray-500 italic p-2 text-center">No rubrics found in this course.</p>
) : (
courseRubrics.map(rubric => {
const isAssigned = assignedRubricIds.includes(rubric.id);
return (
<label key={rubric.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-white/5 transition-all cursor-pointer group">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isAssigned}
onChange={() => toggleRubric(rubric.id, isAssigned)}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-blue-500 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-200 group-hover:text-blue-400 transition-colors">{rubric.name}</span>
</div>
<span className="text-[10px] font-bold text-gray-600 bg-white/5 px-1.5 py-0.5 rounded">{rubric.total_points} pts</span>
</label>
);
})
)}
</div>
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
Manage rubrics in <Link href={`/courses/${params.id}/rubrics`} className="text-blue-400 hover:underline">Rubrics Manager</Link>
</div>
</div>
</div>
@@ -419,6 +521,104 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<p className="text-[10px] text-gray-600 italic">Enables &quot;Check Answer&quot; buttons for individual blocks</p>
</div>
</div>
<div className="pt-8 border-t border-white/5 animate-in fade-in duration-500 delay-150">
<div className="flex items-center gap-2 mb-6 uppercase tracking-[0.2em] text-[10px] font-black text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 shadow-lg shadow-blue-500/50"></span>
Access & Prerequisites
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<h4 className="text-sm font-bold text-gray-200">Prerequisites</h4>
<p className="text-xs text-gray-500 leading-relaxed">
Students must complete these lessons before they can access this one.
</p>
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 max-h-60 overflow-y-auto space-y-2">
{allLessons.length === 0 ? (
<p className="text-xs text-gray-500 italic p-4 text-center">No other lessons available.</p>
) : (
allLessons.map(l => {
const dep = dependencies.find(d => d.prerequisite_lesson_id === l.id);
const isAssigned = !!dep;
return (
<div key={l.id} className="space-y-2 p-2 rounded-xl hover:bg-white/5 transition-all group border border-transparent hover:border-white/10">
<label className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isAssigned}
onChange={() => toggleDependency(l.id, isAssigned)}
className="w-4 h-4 rounded-md border-gray-700 bg-gray-800 text-blue-500 focus:ring-blue-500 transition-all"
/>
<span className={`text-sm font-medium transition-colors ${isAssigned ? 'text-blue-400' : 'text-gray-400 group-hover:text-gray-200'}`}>
{l.title}
</span>
</div>
{l.is_graded && (
<span className="text-[9px] font-black uppercase tracking-tighter px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20">Graded</span>
)}
</label>
{isAssigned && l.is_graded && (
<div className="pl-7 animate-in slide-in-from-left-2 duration-300">
<div className="flex items-center gap-3">
<span className="text-[10px] text-gray-500 font-bold uppercase tracking-widest whitespace-nowrap">Min. Score %</span>
<input
type="number"
min="0"
max="100"
value={dep.min_score_percentage || 0}
onChange={async (e) => {
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"
/>
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
<div className="flex flex-col justify-center gap-4 px-6 border-l border-white/5">
<div className="flex items-start gap-4 p-4 rounded-2xl bg-indigo-500/5 border border-indigo-500/10">
<div className="p-2 rounded-xl bg-indigo-500/20">
<Layout className="w-5 h-5 text-indigo-400" />
</div>
<div>
<h5 className="text-sm font-bold text-indigo-300">Intelligent Sequences</h5>
<p className="text-[11px] text-indigo-300/60 leading-relaxed mt-1">
Locked lessons will be visible in the student outline with a lock icon 🔒 until they meet all prerequisites.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-4 rounded-2xl bg-blue-500/5 border border-blue-500/10">
<div className="p-2 rounded-xl bg-blue-500/20">
<CheckCircle2 className="w-5 h-5 text-blue-400" />
</div>
<div>
<h5 className="text-sm font-bold text-blue-300">Completion Tracking</h5>
<p className="text-[11px] text-blue-300/60 leading-relaxed mt-1">
If a minimum score is set, students must pass the prerequisite before the next lesson is unlocked.
</p>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div >
@@ -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<string | null>(null);
return (
<div className="min-h-screen bg-[#0f1115] text-white p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-12">
<div className="flex items-center gap-4">
<button
onClick={() => router.back()}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ArrowLeft className="w-6 h-6" />
</button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
Rubrics Management
</h1>
<p className="text-gray-400 mt-1">Create and manage evaluation rubrics for your course</p>
</div>
</div>
<div className="hidden md:flex items-center gap-2 bg-blue-500/10 border border-blue-500/20 px-4 py-2 rounded-2xl text-blue-400 text-sm">
<Info className="w-4 h-4" />
<span>Rubrics can be assigned to multiple lessons across the course.</span>
</div>
</div>
<CourseEditorLayout activeTab="rubrics">
<div className="p-8">
{editingRubricId ? (
<RubricEditor
rubricId={editingRubricId}
courseId={id}
onClose={() => setEditingRubricId(null)}
/>
) : (
<RubricList
courseId={id}
onEdit={(rubricId) => setEditingRubricId(rubricId)}
/>
)}
</div>
</CourseEditorLayout>
</div>
</div>
);
}
@@ -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` },
@@ -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<RubricWithDetails | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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<RubricCriterion>) => {
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<RubricLevel>) => {
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 (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
</div>
);
if (error || !rubric) return (
<div className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-red-400 font-medium">{error || "Rubric not found"}</p>
<button onClick={onClose} className="mt-4 text-blue-400 hover:underline">Go Back</button>
</div>
);
return (
<div className="bg-[#1a1d23] rounded-3xl border border-white/10 overflow-hidden shadow-2xl flex flex-col max-h-[90vh]">
{/* Header */}
<div className="p-6 border-b border-white/10 flex items-center justify-between bg-white/[0.02]">
<div className="flex-1">
<input
type="text"
value={rubric.name}
onChange={(e) => 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"
/>
<input
type="text"
value={rubric.description || ""}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-4 ml-4">
<div className="text-right mr-4">
<span className="text-xs text-gray-500 uppercase tracking-widest block font-semibold">Total Points</span>
<span className="text-2xl font-bold text-blue-400">{rubric.total_points}</span>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/10 rounded-full text-gray-400 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8 space-y-12">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold flex items-center gap-2 text-gray-200">
Criteria
<span className="text-xs font-normal text-gray-500 bg-white/5 px-2 py-0.5 rounded-full">
{rubric.criteria.length} items
</span>
</h3>
<button
onClick={handleAddCriterion}
className="flex items-center gap-2 px-4 py-2 bg-blue-600/10 text-blue-400 border border-blue-500/30 rounded-xl hover:bg-blue-600 hover:text-white transition-all transform active:scale-95"
>
<Plus className="w-4 h-4" />
Add Criterion
</button>
</div>
<div className="space-y-6">
{rubric.criteria.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-white/5 rounded-3xl bg-white/[0.01]">
<p className="text-gray-500 italic">No criteria added yet. Add your first evaluation criterion.</p>
</div>
) : (
rubric.criteria.map((item, idx) => (
<div key={item.id} className="group bg-white/[0.03] border border-white/10 rounded-3xl p-6 hover:border-blue-500/30 transition-all">
<div className="flex items-start gap-4">
<div className="mt-2 text-gray-600 group-hover:text-blue-500/50 transition-colors">
<GripVertical className="w-5 h-5 cursor-grab active:cursor-grabbing" />
</div>
<div className="flex-1 space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<input
type="text"
value={item.name}
onChange={(e) => 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"
/>
<input
type="text"
value={item.description || ""}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-end">
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-bold mb-1">Max Points</label>
<input
type="number"
value={item.max_points}
onChange={(e) => 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"
/>
</div>
<button
onClick={() => handleDeleteCriterion(item.id)}
className="p-2 text-red-500/50 hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-all"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
{/* Levels */}
<div className="pl-4 border-l-2 border-white/5 space-y-4 pt-2">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-widest">Performance Levels</h4>
<button
onClick={() => handleAddLevel(item.id)}
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 font-semibold"
>
<Plus className="w-3 h-3" /> Add Level
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{item.levels.map(level => (
<div key={level.id} className="bg-white/5 border border-white/10 rounded-2xl p-4 flex flex-col gap-2 relative group/level hover:bg-white/[0.08] transition-all">
<button
onClick={() => handleDeleteLevel(level.id, item.id)}
className="absolute top-2 right-2 p-1 text-gray-600 hover:text-red-500 opacity-0 group-level-hover:opacity-100 transition-all"
>
<Trash2 className="w-3 h-3" />
</button>
<input
type="text"
value={level.name}
onChange={(e) => 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"
/>
<textarea
value={level.description || ""}
onChange={(e) => handleUpdateLevel(level.id, item.id, { description: e.target.value })}
placeholder="Criteria description..."
className="bg-transparent text-xs text-gray-500 focus:outline-none resize-none h-12"
/>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-gray-600 uppercase font-bold">Points:</span>
<input
type="number"
value={level.points}
onChange={(e) => handleUpdateLevel(level.id, item.id, { points: parseInt(e.target.value) || 0 })}
className="bg-white/5 border border-white/10 rounded px-1.5 w-12 text-xs text-center text-blue-400 focus:outline-none"
/>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* Footer */}
<div className="p-6 border-t border-white/10 bg-white/[0.02] flex justify-end gap-4">
<button
onClick={onClose}
className="px-6 py-2 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all font-medium"
>
Close
</button>
<button
onClick={() => {
if (onSaved) onSaved();
onClose();
}}
className="px-8 py-2 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-500 shadow-lg shadow-blue-500/20 active:scale-95 transition-all flex items-center gap-2"
>
<Check className="w-5 h-5" />
Done
</button>
</div>
</div>
);
}
@@ -0,0 +1,192 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Rubric, cmsApi } from "@/lib/api";
import {
Plus,
Search,
FileText,
MoreVertical,
Edit2,
Trash2,
ExternalLink,
AlertCircle,
Copy
} from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface RubricListProps {
courseId: string;
onEdit: (rubricId: string) => void;
}
export default function RubricList({ courseId, onEdit }: RubricListProps) {
const [rubrics, setRubrics] = useState<Rubric[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [creating, setCreating] = useState(false);
const [newRubricName, setNewRubricName] = useState("");
const loadRubrics = useCallback(async () => {
try {
setLoading(true);
const data = await cmsApi.listCourseRubrics(courseId);
setRubrics(data);
} catch (err) {
console.error("Failed to load rubrics", err);
} finally {
setLoading(false);
}
}, [courseId]);
useEffect(() => {
loadRubrics();
}, [loadRubrics]);
const handleCreate = async () => {
if (!newRubricName) return;
setCreating(true);
try {
const newRubric = await cmsApi.createRubric(courseId, {
name: newRubricName,
course_id: courseId
});
setRubrics([newRubric, ...rubrics]);
setNewRubricName("");
onEdit(newRubric.id);
} catch (err) {
console.error("Failed to create rubric", err);
} finally {
setCreating(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this rubric? This will affect any lessons using it.")) return;
try {
await cmsApi.deleteRubric(id);
setRubrics(rubrics.filter(r => r.id !== id));
} catch (err) {
console.error("Failed to delete rubric", err);
}
};
const filteredRubrics = rubrics.filter(r =>
r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(r.description && r.description.toLowerCase().includes(searchQuery.toLowerCase()))
);
if (loading) return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
</div>
);
return (
<div className="space-y-8">
{/* Header & Search */}
<div className="flex flex-col md:flex-row gap-6 justify-between items-start md:items-center">
<div className="relative flex-1 w-full max-w-md">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="search"
placeholder="Search rubrics..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-2xl pl-11 pr-4 py-3 focus:outline-none focus:border-blue-500/50 transition-all text-gray-100"
/>
</div>
<div className="flex items-center gap-3 w-full md:w-auto">
<input
type="text"
placeholder="New Rubric Name"
value={newRubricName}
onChange={(e) => setNewRubricName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
className="flex-1 md:w-64 bg-white/5 border border-white/10 rounded-xl px-4 py-2 focus:outline-none focus:border-blue-500/50 transition-all"
/>
<button
onClick={handleCreate}
disabled={creating || !newRubricName}
className="bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white font-bold px-6 py-2 rounded-xl transition-all shadow-lg shadow-blue-500/20 active:scale-95 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Create
</button>
</div>
</div>
{/* Rubrics Grid */}
{filteredRubrics.length === 0 ? (
<div className="bg-white/5 border border-white/10 rounded-3xl p-16 text-center">
<div className="w-16 h-16 bg-blue-500/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
<FileText className="w-8 h-8 text-blue-400" />
</div>
<h3 className="text-xl font-bold text-gray-200 mb-2">No rubrics found</h3>
<p className="text-gray-500 max-w-md mx-auto">
Create a rubric to start using advanced grading criteria in your course lessons.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredRubrics.map((rubric) => (
<div
key={rubric.id}
className="group bg-white/5 border border-white/10 rounded-3xl p-6 hover:border-blue-500/50 hover:bg-white/[0.08] transition-all duration-300 flex flex-col justify-between"
>
<div>
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400">
<FileText className="w-6 h-6" />
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onEdit(rubric.id)}
className="p-2 text-gray-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-lg transition-all"
title="Edit Rubric"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(rubric.id)}
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
title="Delete Rubric"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<h3 className="text-xl font-bold text-gray-100 group-hover:text-blue-400 transition-colors mb-2">
{rubric.name}
</h3>
<p className="text-gray-500 text-sm line-clamp-2 min-h-[2.5rem]">
{rubric.description || "No description provided."}
</p>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex flex-col">
<span className="text-[10px] text-gray-500 uppercase tracking-widest font-bold mb-1">Total Points</span>
<span className="text-xl font-black text-white">{rubric.total_points}</span>
</div>
<button
onClick={() => onEdit(rubric.id)}
className="px-4 py-2 bg-white/5 border border-white/10 rounded-xl text-sm font-semibold hover:bg-blue-600 hover:text-white hover:border-blue-600 transition-all active:scale-95"
>
Manage Criteria
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+156
View File
@@ -235,6 +235,118 @@ export interface LibraryBlockFilters {
search?: string;
}
// ==================== Advanced Grading / Rubrics ====================
export interface Rubric {
id: string;
organization_id: string;
course_id: string | null;
created_by: string;
name: string;
description: string | null;
total_points: number;
created_at: string;
updated_at: string;
}
export interface RubricCriterion {
id: string;
rubric_id: string;
name: string;
description: string | null;
max_points: number;
position: number;
created_at: string;
}
export interface RubricLevel {
id: string;
criterion_id: string;
name: string;
description: string | null;
points: number;
position: number;
created_at: string;
}
export interface RubricWithDetails {
id: string;
organization_id: string;
course_id: string | null;
created_by: string;
name: string;
description: string | null;
total_points: number;
created_at: string;
updated_at: string;
criteria: CriterionWithLevels[];
}
export interface CriterionWithLevels {
id: string;
rubric_id: string;
name: string;
description: string | null;
max_points: number;
position: number;
created_at: string;
levels: RubricLevel[];
}
export interface CreateRubricPayload {
name: string;
description?: string;
course_id?: string;
}
export interface UpdateRubricPayload {
name?: string;
description?: string;
}
export interface CreateCriterionPayload {
name: string;
description?: string;
max_points: number;
position?: number;
}
export interface UpdateCriterionPayload {
name?: string;
description?: string;
max_points?: number;
position?: number;
}
export interface CreateLevelPayload {
name: string;
description?: string;
points: number;
position?: number;
}
export interface UpdateLevelPayload {
name?: string;
description?: string;
points?: number;
position?: number;
}
// ==================== Learning Sequences / Dependencies ====================
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 AssignDependencyPayload {
prerequisite_lesson_id: string;
min_score_percentage?: number;
}
export interface AuditLog {
id: string;
user_id: string;
@@ -570,6 +682,50 @@ export const cmsApi = {
apiFetch(`/library/blocks/${id}`, { method: 'DELETE' }),
incrementBlockUsage: (id: string): Promise<void> =>
apiFetch(`/library/blocks/${id}/increment-usage`, { method: 'POST' }),
// Advanced Grading / Rubrics
createRubric: (courseId: string, payload: CreateRubricPayload): Promise<Rubric> =>
apiFetch(`/courses/${courseId}/rubrics`, { method: 'POST', body: JSON.stringify(payload) }),
listCourseRubrics: (courseId: string): Promise<Rubric[]> =>
apiFetch(`/courses/${courseId}/rubrics`),
getRubricWithDetails: (rubricId: string): Promise<RubricWithDetails> =>
apiFetch(`/rubrics/${rubricId}`),
updateRubric: (rubricId: string, payload: UpdateRubricPayload): Promise<Rubric> =>
apiFetch(`/rubrics/${rubricId}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteRubric: (rubricId: string): Promise<void> =>
apiFetch(`/rubrics/${rubricId}`, { method: 'DELETE' }),
// Rubric Criteria
createCriterion: (rubricId: string, payload: CreateCriterionPayload): Promise<RubricCriterion> =>
apiFetch(`/rubrics/${rubricId}/criteria`, { method: 'POST', body: JSON.stringify(payload) }),
updateCriterion: (criterionId: string, payload: UpdateCriterionPayload): Promise<RubricCriterion> =>
apiFetch(`/criteria/${criterionId}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteCriterion: (criterionId: string): Promise<void> =>
apiFetch(`/criteria/${criterionId}`, { method: 'DELETE' }),
// Rubric Levels
createLevel: (criterionId: string, payload: CreateLevelPayload): Promise<RubricLevel> =>
apiFetch(`/criteria/${criterionId}/levels`, { method: 'POST', body: JSON.stringify(payload) }),
updateLevel: (levelId: string, payload: UpdateLevelPayload): Promise<RubricLevel> =>
apiFetch(`/levels/${levelId}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteLevel: (levelId: string): Promise<void> =>
apiFetch(`/levels/${levelId}`, { method: 'DELETE' }),
// Lesson-Rubric Association
assignRubricToLesson: (lessonId: string, rubricId: string): Promise<void> =>
apiFetch(`/lessons/${lessonId}/rubrics/${rubricId}`, { method: 'POST' }),
unassignRubricFromLesson: (lessonId: string, rubricId: string): Promise<void> =>
apiFetch(`/lessons/${lessonId}/rubrics/${rubricId}`, { method: 'DELETE' }),
getLessonRubrics: (lessonId: string): Promise<Rubric[]> =>
apiFetch(`/lessons/${lessonId}/rubrics`),
// Learning Sequences (Dependencies)
listLessonDependencies: (lessonId: string): Promise<LessonDependency[]> =>
apiFetch(`/lessons/${lessonId}/dependencies`),
assignDependency: (lessonId: string, payload: AssignDependencyPayload): Promise<LessonDependency> =>
apiFetch(`/lessons/${lessonId}/dependencies`, { method: 'POST', body: JSON.stringify(payload) }),
removeDependency: (lessonId: string, prerequisiteId: string): Promise<void> =>
apiFetch(`/lessons/${lessonId}/dependencies/${prerequisiteId}`, { method: 'DELETE' }),
};
export const lmsApi = {