feat: Implement advanced grading (rubrics) and lesson dependencies across CMS service, API, and Studio UI.
This commit is contained in:
@@ -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).
|
- **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.
|
- **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.
|
- **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
|
## Requisitos del Sistema
|
||||||
|
|
||||||
@@ -101,14 +103,14 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 📦 Course Portability
|
#### 📦 Portabilidad de Cursos
|
||||||
Manage content mobility across different organizations using standardized JSON exports.
|
Gestiona la movilidad de contenidos entre diferentes organizaciones utilizando exportaciones estandarizadas en JSON.
|
||||||
|
|
||||||
#### GET /courses/{id}/export
|
#### 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
|
#### 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
|
#### Experience & LMS
|
||||||
```bash
|
```bash
|
||||||
# Iniciar backend LMS
|
# 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 |
|
| Listar | GET | `/courses/{id}/announcements` | Obtiene todos los anuncios de un curso |
|
||||||
| Create | POST | `/courses/{id}/announcements` | Create a new announcement (Instructor/Admin only) |
|
| Crear | POST | `/courses/{id}/announcements` | Crea un nuevo anuncio (Solo Instructor/Admin) |
|
||||||
| Update | PUT | `/announcements/{id}` | Update an announcement (Instructor/Admin only) |
|
| Actualizar | PUT | `/announcements/{id}` | Actualiza un anuncio (Solo Instructor/Admin) |
|
||||||
| Delete | DELETE| `/announcements/{id}` | Delete an announcement (Instructor/Admin only) |
|
| Eliminar | DELETE| `/announcements/{id}` | Elimina un anuncio (Solo Instructor/Admin) |
|
||||||
|
|
||||||
#### Create Announcement Example
|
#### Ejemplo de Creación de Anuncio
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3002/courses/{course_id}/announcements \
|
curl -X POST http://localhost:3002/courses/{course_id}/announcements \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-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)
|
### 6. Multi-tenencia y Gestión Global (Super Admin)
|
||||||
OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Admin** can manage everything.
|
OpenCCB está diseñado para multi-tenencia. Las organizaciones están aisladas, pero un **Super Admin** puede gestionarlo todo.
|
||||||
|
|
||||||
#### Super Admin Definition
|
#### Definición de Super Admin
|
||||||
- **Default Organization ID**: `00000000-0000-0000-0000-000000000001`
|
- **ID de Organización por Defecto**: `00000000-0000-0000-0000-000000000001`
|
||||||
- Any user with `role: admin` in this organization is a **Super Admin**.
|
- Cualquier usuario con `role: admin` en esta organización es un **Super Admin**.
|
||||||
#### Global Control Panel (`/admin`)
|
#### Global Control Panel (`/admin`)
|
||||||
- **Dashboard**: Resumen de organizaciones, usuarios y salud del sistema.
|
- **Dashboard**: Resumen de organizaciones, usuarios y salud del sistema.
|
||||||
- **Audit Logs**: Seguimiento detallado de todas las acciones administrativas.
|
- **Audit Logs**: Seguimiento detallado de todas las acciones administrativas.
|
||||||
- **Service Monitor**: Estado en tiempo real del API Cluster, AI Services y Background Workers.
|
- **Service Monitor**: Estado en tiempo real del API Cluster, AI Services y Background Workers.
|
||||||
|
|
||||||
#### Global Courses
|
#### Cursos Globales
|
||||||
Courses created by Super Admins in the **Default Organization** are automatically marked as **Global**.
|
Los cursos creados por los Super Admins en la **Organización por Defecto** son automáticamente marcados como **Globales**.
|
||||||
- They appear in the catalog of **all organizations**.
|
- Aparecen en el catálogo de **todas las organizaciones**.
|
||||||
- Users from any organization can enroll in global courses.
|
- Los usuarios de cualquier organización pueden inscribirse en cursos globales.
|
||||||
|
Riverside
|
||||||
|
|
||||||
#### Cross-Tenant Publishing
|
#### Publicación Entre Organizaciones
|
||||||
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.
|
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
|
#### X-Organization-Id Header
|
||||||
Super Admins can simulate the context of any organization by sending this header in their requests:
|
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
|
## 🏆 Componentes UI Premium
|
||||||
- **Course Portability**: Full JSON-based import/export system for multi-tenant content mobility.
|
- **Advanced Grading (Rubrics)**: Sistema completo de evaluación por rúbricas configurables.
|
||||||
- **AI Course Wizard**: Instant curriculum generation from natural language prompts.
|
- **Content Libraries**: Repositorio reutilizable de bloques y lecciones para máxima eficiencia.
|
||||||
- **Global Admin Console**: Centralized control for organizations, users, and audit logs.
|
- **Course Portability**: Sistema de importación/exportación basado en JSON para movilidad de contenidos.
|
||||||
- **Experience Player**: A high-performance, accessible learning interface with glassmorphism design.
|
- **AI Course Wizard**: Generación instantánea de currículos a partir de prompts.
|
||||||
- **Organization Selector**: A searchable combobox for managing large lists of tenants.
|
- **Global Admin Console**: Control centralizado para organizaciones, usuarios y registros de auditoría.
|
||||||
- **Engagement Heatmaps**: Dynamic bar charts showing video retention signatures.
|
- **Experience Player**: Interfaz de aprendizaje de alto rendimiento y accesible con diseño glassmorphism.
|
||||||
- **Notification Center**: Real-time alerts for deadlines and achievements.
|
- **Organization Selector**: Combobox con búsqueda para gestionar grandes listas de inquilinos.
|
||||||
- **Custom Report Builder**: Professional reports with one-click CSV export.
|
- **Engagement Heatmaps**: Gráficos dinámicos que muestran la retención en videos.
|
||||||
- **Glassmorphism Design**: Consistent aesthetic across Studio and Experience portals.
|
- **Notification Center**: Alertas en tiempo real para fechas límite y logros.
|
||||||
- **Global Localization**: Native support for English, Spanish, and Portuguese.
|
- **Custom Report Builder**: Reportes profesionales con exportación a CSV en un clic.
|
||||||
- **PDF Integrated Viewer**: Read academic documents without leaving the platform.
|
- **Glassmorphism Design**: Estética consistente en los portales Studio y Experience.
|
||||||
- **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons.
|
- **Global Localization**: Soporte nativo para Inglés, Español y Portugués.
|
||||||
- **White-Label Branding**: Fully custom platform name, logo, favicon, and color themes per organization.
|
- **PDF Integrated Viewer**: Lectura de documentos académicos sin salir de la plataforma.
|
||||||
- **Dynamic LAN Connectivity**: Automatic server IP detection for seamless multi-device access.
|
- **Interactive Video Markers**: Preguntas que pausan el video integradas en las lecciones.
|
||||||
- **Mobile-First Navigation**: Responsive sliding menus and adaptive layouts for all screen sizes.
|
- **White-Label Branding**: Nombre de plataforma, logo, favicon y temas de color personalizados por organización.
|
||||||
- **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers.
|
- **Dynamic LAN Connectivity**: Detección automática de la IP del servidor para acceso fluido multi-dispositivo.
|
||||||
- **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results.
|
- **Mobile-First Navigation**: Menús laterales responsivos y diseños adaptativos para todas las pantallas.
|
||||||
- **Color-Coded Navigation**: Real-time visual progress indicators for lessons and modules (Green/Yellow/Red).
|
- **Context-Aware AI Tutor**: Asistente inteligente con RAG que recuerda lecciones pasadas y protege respuestas.
|
||||||
- **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions.
|
- **Personalized AI Feedback**: Retroalimentación motivacional e instruccional generada únicamente para cada estudiante.
|
||||||
- **Course Announcements**: Instructor-to-student communication system with automatic notifications and pinning functionality.
|
- **Color-Coded Navigation**: Indicadores visuales de progreso en tiempo real (Verde/Amarillo/Rojo).
|
||||||
- **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support.
|
- **Discussion Forums**: Sistema completo de foros con hilos, votos, moderación y suscripciones.
|
||||||
- **Mercado Pago Monetization**: Integrated payment gateway with automatic course unlocking and transaction tracking.
|
- **Course Announcements**: Sistema de comunicación instructor-estudiante con notificaciones automáticas.
|
||||||
- **Student Notes Panel**: Personal lesson annotations with glassmorphism UI and intelligent auto-save.
|
- **Split Authentication**: Flujos de inicio de sesión separados para usuarios personales y empresas con soporte SSO.
|
||||||
- **Cohort Management**: Comprehensive group management system with member assignment and progress tracking.
|
- **Mercado Pago Monetization**: Pasarela de pagos integrada con desbloqueo automático de cursos.
|
||||||
- **Advanced Gradebook**: Student performance tracking with cohort-based filtering and analytics.
|
- **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
|
## 📄 Licencia
|
||||||
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
||||||
+6
-6
@@ -183,8 +183,8 @@
|
|||||||
- [x] **Student Notes**: Anotaciones personales por lección con exportación a PDF.
|
- [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] **Peer Assessment**: Evaluación entre pares con rúbricas configurables.
|
||||||
- [x] **Cohorts & Groups**: Segmentación de estudiantes con contenido específico.
|
- [x] **Cohorts & Groups**: Segmentación de estudiantes con contenido específico.
|
||||||
- [ ] **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
- [x] **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
||||||
- [ ] **Advanced Grading**: Rúbricas detalladas y workflows de calificación.
|
- [x] **Advanced Grading**: Rúbricas detalladas y workflows de calificación.
|
||||||
- [ ] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
- [ ] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
||||||
- [ ] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva.
|
- [ ] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva.
|
||||||
- [ ] **Course Teams**: Múltiples instructores con roles y permisos granulares.
|
- [ ] **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**:
|
**Próximas Prioridades**:
|
||||||
1. **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
1. **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
||||||
2. **Advanced Grading**: Rúbricas detalladas y workflows de calificación.
|
2. **Bulk Operations**: Inscripción masiva y exportación avanzada de datos.
|
||||||
3. **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
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))
|
||||||
|
}
|
||||||
@@ -3,14 +3,16 @@ pub mod exporter;
|
|||||||
mod external_handlers;
|
mod external_handlers;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod handlers_branding;
|
mod handlers_branding;
|
||||||
|
mod handlers_dependencies;
|
||||||
mod handlers_library;
|
mod handlers_library;
|
||||||
|
mod handlers_rubrics;
|
||||||
mod webhooks;
|
mod webhooks;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::DefaultBodyLimit,
|
extract::DefaultBodyLimit,
|
||||||
middleware,
|
middleware,
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
@@ -196,6 +198,52 @@ async fn main() {
|
|||||||
"/library/blocks/{id}/increment-usage",
|
"/library/blocks/{id}/increment-usage",
|
||||||
post(handlers_library::increment_block_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(
|
.route_layer(middleware::from_fn(
|
||||||
common::middleware::org_extractor_middleware,
|
common::middleware::org_extractor_middleware,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -600,11 +600,31 @@ pub async fn get_course_outline(
|
|||||||
pub_modules.push(common::models::PublishedModule { module, lessons });
|
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 {
|
Ok(Json(common::models::PublishedCourse {
|
||||||
course,
|
course,
|
||||||
organization,
|
organization,
|
||||||
grading_categories,
|
grading_categories,
|
||||||
modules: pub_modules,
|
modules: pub_modules,
|
||||||
|
dependencies: Some(dependencies),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +640,7 @@ pub async fn get_lesson_content(
|
|||||||
claims.sub
|
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>(
|
let lesson = sqlx::query_as::<_, Lesson>(
|
||||||
"SELECT l.* FROM lessons l
|
"SELECT l.* FROM lessons l
|
||||||
JOIN modules m ON l.module_id = m.id
|
JOIN modules m ON l.module_id = m.id
|
||||||
@@ -636,17 +656,64 @@ pub async fn get_lesson_content(
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match lesson {
|
let lesson = match lesson {
|
||||||
Some(l) => Ok(Json(l)),
|
Some(l) => l,
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"get_lesson_content: User {} not enrolled or lesson {} not found",
|
"get_lesson_content: User {} not enrolled or lesson {} not found",
|
||||||
claims.sub,
|
claims.sub,
|
||||||
id
|
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(
|
pub async fn get_user_enrollments(
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ pub struct PublishedCourse {
|
|||||||
pub organization: Organization,
|
pub organization: Organization,
|
||||||
pub grading_categories: Vec<GradingCategory>,
|
pub grading_categories: Vec<GradingCategory>,
|
||||||
pub modules: Vec<PublishedModule>,
|
pub modules: Vec<PublishedModule>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dependencies: Option<Vec<LessonDependency>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -691,3 +693,87 @@ mod tests {
|
|||||||
assert_eq!(deserialized.modules[0].lessons[0].title, "Test Lesson");
|
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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { lmsApi, Course, Module, Recommendation, UserGrade } from "@/lib/api";
|
import { lmsApi, Course, Module, Recommendation, UserGrade } from "@/lib/api";
|
||||||
import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } from "lucide-react";
|
import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } from "lucide-react";
|
||||||
import Link from "next/link";
|
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 { useAuth } from "@/context/AuthContext";
|
||||||
import DiscussionBoard from "@/components/DiscussionBoard";
|
import DiscussionBoard from "@/components/DiscussionBoard";
|
||||||
import { AnnouncementsList } from "@/components/AnnouncementsList";
|
import { AnnouncementsList } from "@/components/AnnouncementsList";
|
||||||
@@ -17,6 +17,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
const [loadingAI, setLoadingAI] = useState(false);
|
const [loadingAI, setLoadingAI] = useState(false);
|
||||||
const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
|
const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
|
||||||
const [isEnrolled, setIsEnrolled] = useState(false);
|
const [isEnrolled, setIsEnrolled] = useState(false);
|
||||||
|
const [lessonDependencies, setLessonDependencies] = useState<any[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -24,6 +25,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await lmsApi.getCourseOutline(params.id);
|
const data = await lmsApi.getCourseOutline(params.id);
|
||||||
setCourseData({ ...data.course, modules: data.modules });
|
setCourseData({ ...data.course, modules: data.modules });
|
||||||
|
setLessonDependencies(data.dependencies || []);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const grades = await lmsApi.getUserGrades(user.id, params.id);
|
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>;
|
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) => {
|
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);
|
const grade = userGrades.find((g: UserGrade) => g.lesson_id === lessonId);
|
||||||
if (!grade) {
|
if (!grade) {
|
||||||
return <Circle size={18} className="text-white/20" />;
|
return <Circle size={18} className="text-white/20" />;
|
||||||
@@ -263,8 +281,27 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 pl-14">
|
<div className="grid gap-3 pl-14">
|
||||||
{module.lessons.map((lesson: any) => (
|
{module.lessons.map((lesson: any) => {
|
||||||
isEnrolled ? (
|
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">
|
||||||
|
<Lock size={18} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
<Lock size={18} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
|
<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="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 justify-between">
|
||||||
@@ -300,6 +337,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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 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">
|
<div className="flex items-center justify-between">
|
||||||
@@ -319,8 +357,8 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ export interface GradingCategory {
|
|||||||
drop_count: number;
|
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 {
|
export interface UserGrade {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -366,7 +375,13 @@ export const lmsApi = {
|
|||||||
return apiFetch(`/catalog${query}`);
|
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`);
|
return apiFetch(`/courses/${courseId}/outline`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock } from "@/lib/api";
|
import Link from 'next/link';
|
||||||
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 DescriptionBlock from "@/components/blocks/DescriptionBlock";
|
||||||
import MediaBlock from "@/components/blocks/MediaBlock";
|
import MediaBlock from "@/components/blocks/MediaBlock";
|
||||||
import QuizBlock from "@/components/blocks/QuizBlock";
|
import QuizBlock from "@/components/blocks/QuizBlock";
|
||||||
@@ -19,16 +38,6 @@ import PeerReviewBlock from "@/components/blocks/PeerReviewBlock";
|
|||||||
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
|
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
|
||||||
import LibraryPanel from "@/components/LibraryPanel";
|
import LibraryPanel from "@/components/LibraryPanel";
|
||||||
import Modal from "@/components/Modal";
|
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 } }) {
|
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
|
||||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
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 [dueDate, setDueDate] = useState<string>("");
|
||||||
const [importantDateType, setImportantDateType] = useState<string>("");
|
const [importantDateType, setImportantDateType] = useState<string>("");
|
||||||
|
|
||||||
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
|
// Rubric State
|
||||||
const [aiQuizContext, setAiQuizContext] = useState("");
|
const [courseRubrics, setCourseRubrics] = useState<Rubric[]>([]);
|
||||||
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
|
const [assignedRubricIds, setAssignedRubricIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// Content Libraries states
|
// Content Libraries states
|
||||||
const [isSaveToLibraryModalOpen, setIsSaveToLibraryModalOpen] = useState(false);
|
const [isSaveToLibraryModalOpen, setIsSaveToLibraryModalOpen] = useState(false);
|
||||||
const [blockToSave, setBlockToSave] = useState<Block | null>(null);
|
const [blockToSave, setBlockToSave] = useState<Block | null>(null);
|
||||||
const [isLibraryPanelOpen, setIsLibraryPanelOpen] = useState(false);
|
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("");
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +138,28 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
// Load grading categories
|
// Load grading categories
|
||||||
const categories = await cmsApi.getGradingCategories(params.id);
|
const categories = await cmsApi.getGradingCategories(params.id);
|
||||||
setGradingCategories(categories);
|
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 {
|
} catch {
|
||||||
console.error("Failed to load lesson or categories");
|
console.error("Failed to load lesson or categories");
|
||||||
} finally {
|
} 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 () => {
|
const handleSave = async () => {
|
||||||
if (!lesson) return;
|
if (!lesson) return;
|
||||||
setIsSaving(true);
|
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);
|
setIsAIQuizModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,7 +438,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
|
|
||||||
{isGraded && (
|
{isGraded && (
|
||||||
<>
|
<>
|
||||||
<div className="col-span-full space-y-2">
|
<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>
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
|
||||||
<select
|
<select
|
||||||
value={selectedCategoryId}
|
value={selectedCategoryId}
|
||||||
@@ -387,6 +458,37 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6 border-t border-white/5 animate-in fade-in duration-500">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6 border-t border-white/5 animate-in fade-in duration-500">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -419,6 +521,104 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
<p className="text-[10px] text-gray-600 italic">Enables "Check Answer" buttons for individual blocks</p>
|
<p className="text-[10px] text-gray-600 italic">Enables "Check Answer" buttons for individual blocks</p>
|
||||||
</div>
|
</div>
|
||||||
</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 >
|
</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 {
|
interface CourseEditorLayoutProps {
|
||||||
children: React.ReactNode;
|
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) {
|
export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) {
|
||||||
@@ -16,6 +16,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: "outline", label: "Outline", icon: Layout, href: `/courses/${id}` },
|
{ key: "outline", label: "Outline", icon: Layout, href: `/courses/${id}` },
|
||||||
{ key: "grading", label: "Grading Policy", icon: CheckCircle2, href: `/courses/${id}/grading` },
|
{ 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: "grades", label: "Gradebook", icon: GraduationCap, href: `/courses/${id}/grades` },
|
||||||
{ key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` },
|
{ key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` },
|
||||||
{ key: "analytics", label: "Analytics", icon: BarChart2, href: `/courses/${id}/analytics` },
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -235,6 +235,118 @@ export interface LibraryBlockFilters {
|
|||||||
search?: string;
|
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 {
|
export interface AuditLog {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -570,6 +682,50 @@ export const cmsApi = {
|
|||||||
apiFetch(`/library/blocks/${id}`, { method: 'DELETE' }),
|
apiFetch(`/library/blocks/${id}`, { method: 'DELETE' }),
|
||||||
incrementBlockUsage: (id: string): Promise<void> =>
|
incrementBlockUsage: (id: string): Promise<void> =>
|
||||||
apiFetch(`/library/blocks/${id}/increment-usage`, { method: 'POST' }),
|
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 = {
|
export const lmsApi = {
|
||||||
|
|||||||
Reference in New Issue
Block a user