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).
|
||||
- **Cohorts & Groups**: Segmentación de estudiantes en cohortes para gestión de grupos y análisis comparativo.
|
||||
- **Interactive Gradebook**: Libro de calificaciones avanzado con filtrado por cohortes y exportación a CSV.
|
||||
- **Content Libraries**: Repositorio centralizado de bloques y lecciones reutilizables entre múltiples cursos.
|
||||
- **Advanced Grading (Rubrics)**: Sistema de evaluación basado en rúbricas detalladas con indicadores de desempeño por criterio.
|
||||
|
||||
## Requisitos del Sistema
|
||||
|
||||
@@ -101,14 +103,14 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
#### 📦 Course Portability
|
||||
Manage content mobility across different organizations using standardized JSON exports.
|
||||
#### 📦 Portabilidad de Cursos
|
||||
Gestiona la movilidad de contenidos entre diferentes organizaciones utilizando exportaciones estandarizadas en JSON.
|
||||
|
||||
#### GET /courses/{id}/export
|
||||
Generates a complete bundle of the course, including modules, lessons, and grading settings.
|
||||
Genera un paquete completo del curso, incluyendo módulos, lecciones y configuraciones de calificación.
|
||||
|
||||
#### POST /courses/import
|
||||
Creates a new course based on a provided export bundle. Automatic dependency mapping ensures data integrity in the new organization.
|
||||
Crea un nuevo curso basado en un paquete de exportación proporcionado. El mapeo automático de dependencias asegura la integridad de los datos en la nueva organización.
|
||||
#### Experience & LMS
|
||||
```bash
|
||||
# Iniciar backend LMS
|
||||
@@ -512,16 +514,16 @@ curl -X POST "http://localhost:3002/posts/{post_id}/vote" \
|
||||
|
||||
---
|
||||
|
||||
### 5. Course Announcements (Anuncios)
|
||||
### 5. Anuncios del Curso (Announcements)
|
||||
|
||||
| Action | Method | Endpoint | Description |
|
||||
| Acción | Método | Endpoint | Descripción |
|
||||
|--------|--------|----------|-------------|
|
||||
| List | GET | `/courses/{id}/announcements` | Get all announcements for a course |
|
||||
| Create | POST | `/courses/{id}/announcements` | Create a new announcement (Instructor/Admin only) |
|
||||
| Update | PUT | `/announcements/{id}` | Update an announcement (Instructor/Admin only) |
|
||||
| Delete | DELETE| `/announcements/{id}` | Delete an announcement (Instructor/Admin only) |
|
||||
| Listar | GET | `/courses/{id}/announcements` | Obtiene todos los anuncios de un curso |
|
||||
| Crear | POST | `/courses/{id}/announcements` | Crea un nuevo anuncio (Solo Instructor/Admin) |
|
||||
| Actualizar | PUT | `/announcements/{id}` | Actualiza un anuncio (Solo Instructor/Admin) |
|
||||
| Eliminar | DELETE| `/announcements/{id}` | Elimina un anuncio (Solo Instructor/Admin) |
|
||||
|
||||
#### Create Announcement Example
|
||||
#### Ejemplo de Creación de Anuncio
|
||||
```bash
|
||||
curl -X POST http://localhost:3002/courses/{course_id}/announcements \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
@@ -535,24 +537,25 @@ curl -X POST http://localhost:3002/courses/{course_id}/announcements \
|
||||
|
||||
---
|
||||
|
||||
### 6. Multi-tenancy and Global Management (Super Admin)
|
||||
OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Admin** can manage everything.
|
||||
### 6. Multi-tenencia y Gestión Global (Super Admin)
|
||||
OpenCCB está diseñado para multi-tenencia. Las organizaciones están aisladas, pero un **Super Admin** puede gestionarlo todo.
|
||||
|
||||
#### Super Admin Definition
|
||||
- **Default Organization ID**: `00000000-0000-0000-0000-000000000001`
|
||||
- Any user with `role: admin` in this organization is a **Super Admin**.
|
||||
#### Definición de Super Admin
|
||||
- **ID de Organización por Defecto**: `00000000-0000-0000-0000-000000000001`
|
||||
- Cualquier usuario con `role: admin` en esta organización es un **Super Admin**.
|
||||
#### Global Control Panel (`/admin`)
|
||||
- **Dashboard**: Resumen de organizaciones, usuarios y salud del sistema.
|
||||
- **Audit Logs**: Seguimiento detallado de todas las acciones administrativas.
|
||||
- **Service Monitor**: Estado en tiempo real del API Cluster, AI Services y Background Workers.
|
||||
|
||||
#### Global Courses
|
||||
Courses created by Super Admins in the **Default Organization** are automatically marked as **Global**.
|
||||
- They appear in the catalog of **all organizations**.
|
||||
- Users from any organization can enroll in global courses.
|
||||
#### Cursos Globales
|
||||
Los cursos creados por los Super Admins en la **Organización por Defecto** son automáticamente marcados como **Globales**.
|
||||
- Aparecen en el catálogo de **todas las organizaciones**.
|
||||
- Los usuarios de cualquier organización pueden inscribirse en cursos globales.
|
||||
Riverside
|
||||
|
||||
#### Cross-Tenant Publishing
|
||||
Super Admins can publish courses to **any organization**. When publishing through the Studio, a premium **Organization Selector** (with search-as-you-type) allows choosing the target destination.
|
||||
#### Publicación Entre Organizaciones
|
||||
Los Super Admins pueden publicar cursos en **cualquier organización**. Al publicar a través de Studio, un **Selector de Organizaciones** premium permite elegir el destino.
|
||||
|
||||
#### X-Organization-Id Header
|
||||
Super Admins can simulate the context of any organization by sending this header in their requests:
|
||||
@@ -570,32 +573,34 @@ Obtiene una lista de todas las organizaciones registradas.
|
||||
|
||||
---
|
||||
|
||||
## 🏆 Premium UI Components
|
||||
- **Course Portability**: Full JSON-based import/export system for multi-tenant content mobility.
|
||||
- **AI Course Wizard**: Instant curriculum generation from natural language prompts.
|
||||
- **Global Admin Console**: Centralized control for organizations, users, and audit logs.
|
||||
- **Experience Player**: A high-performance, accessible learning interface with glassmorphism design.
|
||||
- **Organization Selector**: A searchable combobox for managing large lists of tenants.
|
||||
- **Engagement Heatmaps**: Dynamic bar charts showing video retention signatures.
|
||||
- **Notification Center**: Real-time alerts for deadlines and achievements.
|
||||
- **Custom Report Builder**: Professional reports with one-click CSV export.
|
||||
- **Glassmorphism Design**: Consistent aesthetic across Studio and Experience portals.
|
||||
- **Global Localization**: Native support for English, Spanish, and Portuguese.
|
||||
- **PDF Integrated Viewer**: Read academic documents without leaving the platform.
|
||||
- **Interactive Video Markers**: Pause-and-answer questions embedded in video lessons.
|
||||
- **White-Label Branding**: Fully custom platform name, logo, favicon, and color themes per organization.
|
||||
- **Dynamic LAN Connectivity**: Automatic server IP detection for seamless multi-device access.
|
||||
- **Mobile-First Navigation**: Responsive sliding menus and adaptive layouts for all screen sizes.
|
||||
- **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers.
|
||||
- **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results.
|
||||
- **Color-Coded Navigation**: Real-time visual progress indicators for lessons and modules (Green/Yellow/Red).
|
||||
- **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions.
|
||||
- **Course Announcements**: Instructor-to-student communication system with automatic notifications and pinning functionality.
|
||||
- **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support.
|
||||
- **Mercado Pago Monetization**: Integrated payment gateway with automatic course unlocking and transaction tracking.
|
||||
- **Student Notes Panel**: Personal lesson annotations with glassmorphism UI and intelligent auto-save.
|
||||
- **Cohort Management**: Comprehensive group management system with member assignment and progress tracking.
|
||||
- **Advanced Gradebook**: Student performance tracking with cohort-based filtering and analytics.
|
||||
## 🏆 Componentes UI Premium
|
||||
- **Advanced Grading (Rubrics)**: Sistema completo de evaluación por rúbricas configurables.
|
||||
- **Content Libraries**: Repositorio reutilizable de bloques y lecciones para máxima eficiencia.
|
||||
- **Course Portability**: Sistema de importación/exportación basado en JSON para movilidad de contenidos.
|
||||
- **AI Course Wizard**: Generación instantánea de currículos a partir de prompts.
|
||||
- **Global Admin Console**: Control centralizado para organizaciones, usuarios y registros de auditoría.
|
||||
- **Experience Player**: Interfaz de aprendizaje de alto rendimiento y accesible con diseño glassmorphism.
|
||||
- **Organization Selector**: Combobox con búsqueda para gestionar grandes listas de inquilinos.
|
||||
- **Engagement Heatmaps**: Gráficos dinámicos que muestran la retención en videos.
|
||||
- **Notification Center**: Alertas en tiempo real para fechas límite y logros.
|
||||
- **Custom Report Builder**: Reportes profesionales con exportación a CSV en un clic.
|
||||
- **Glassmorphism Design**: Estética consistente en los portales Studio y Experience.
|
||||
- **Global Localization**: Soporte nativo para Inglés, Español y Portugués.
|
||||
- **PDF Integrated Viewer**: Lectura de documentos académicos sin salir de la plataforma.
|
||||
- **Interactive Video Markers**: Preguntas que pausan el video integradas en las lecciones.
|
||||
- **White-Label Branding**: Nombre de plataforma, logo, favicon y temas de color personalizados por organización.
|
||||
- **Dynamic LAN Connectivity**: Detección automática de la IP del servidor para acceso fluido multi-dispositivo.
|
||||
- **Mobile-First Navigation**: Menús laterales responsivos y diseños adaptativos para todas las pantallas.
|
||||
- **Context-Aware AI Tutor**: Asistente inteligente con RAG que recuerda lecciones pasadas y protege respuestas.
|
||||
- **Personalized AI Feedback**: Retroalimentación motivacional e instruccional generada únicamente para cada estudiante.
|
||||
- **Color-Coded Navigation**: Indicadores visuales de progreso en tiempo real (Verde/Amarillo/Rojo).
|
||||
- **Discussion Forums**: Sistema completo de foros con hilos, votos, moderación y suscripciones.
|
||||
- **Course Announcements**: Sistema de comunicación instructor-estudiante con notificaciones automáticas.
|
||||
- **Split Authentication**: Flujos de inicio de sesión separados para usuarios personales y empresas con soporte SSO.
|
||||
- **Mercado Pago Monetization**: Pasarela de pagos integrada con desbloqueo automático de cursos.
|
||||
- **Student Notes Panel**: Anotaciones personales con interfaz glassmorphism y autoguardado inteligente.
|
||||
- **Cohort Management**: Sistema de gestión de grupos con seguimiento de progreso por cohorte.
|
||||
- **Advanced Gradebook**: Seguimiento del desempeño estudiantil con analíticas y filtrado avanzado.
|
||||
|
||||
## 📄 Licencia
|
||||
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
||||
+6
-6
@@ -183,8 +183,8 @@
|
||||
- [x] **Student Notes**: Anotaciones personales por lección con exportación a PDF.
|
||||
- [x] **Peer Assessment**: Evaluación entre pares con rúbricas configurables.
|
||||
- [x] **Cohorts & Groups**: Segmentación de estudiantes con contenido específico.
|
||||
- [ ] **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
||||
- [ ] **Advanced Grading**: Rúbricas detalladas y workflows de calificación.
|
||||
- [x] **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
||||
- [x] **Advanced Grading**: Rúbricas detalladas y workflows de calificación.
|
||||
- [ ] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
||||
- [ ] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva.
|
||||
- [ ] **Course Teams**: Múltiples instructores con roles y permisos granulares.
|
||||
@@ -215,9 +215,9 @@
|
||||
|
||||
---
|
||||
|
||||
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios** y **monetización integrada con Mercado Pago**.
|
||||
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios**, **monetización integrada con Mercado Pago**, **Librerías de Contenido reutilizables** y **Sistema de Rúbricas Avanzado**.
|
||||
|
||||
**Próximas Prioridades**:
|
||||
1. **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
||||
2. **Advanced Grading**: Rúbricas detalladas y workflows de calificación.
|
||||
3. **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
||||
1. **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
||||
2. **Bulk Operations**: Inscripción masiva y exportación avanzada de datos.
|
||||
3. **Course Teams**: Gestión de múltiples instructores por curso.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Migration: Advanced Grading System (Rubrics)
|
||||
-- Description: Creates tables for rubric-based assessment and grading workflows
|
||||
-- Created: 2026-02-17
|
||||
|
||||
-- Rubrics table - Reusable evaluation templates
|
||||
CREATE TABLE IF NOT EXISTS rubrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
course_id UUID REFERENCES courses(id) ON DELETE CASCADE, -- NULL = reusable across courses
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
total_points INTEGER NOT NULL DEFAULT 100,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rubrics_org ON rubrics(organization_id);
|
||||
CREATE INDEX idx_rubrics_course ON rubrics(course_id);
|
||||
|
||||
-- Rubric Criteria - Evaluation dimensions
|
||||
CREATE TABLE IF NOT EXISTS rubric_criteria (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rubric_id UUID NOT NULL REFERENCES rubrics(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
max_points INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_criteria_rubric ON rubric_criteria(rubric_id);
|
||||
|
||||
-- Rubric Levels - Performance levels per criterion
|
||||
CREATE TABLE IF NOT EXISTS rubric_levels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
criterion_id UUID NOT NULL REFERENCES rubric_criteria(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL, -- e.g., "Excellent", "Proficient", "Developing"
|
||||
description TEXT,
|
||||
points INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_levels_criterion ON rubric_levels(criterion_id);
|
||||
|
||||
-- Lesson-Rubric Association
|
||||
CREATE TABLE IF NOT EXISTS lesson_rubrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
rubric_id UUID NOT NULL REFERENCES rubrics(id) ON DELETE CASCADE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(lesson_id, rubric_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lesson_rubrics_lesson ON lesson_rubrics(lesson_id);
|
||||
CREATE INDEX idx_lesson_rubrics_rubric ON lesson_rubrics(rubric_id);
|
||||
|
||||
-- Rubric Assessments - Student evaluation results
|
||||
CREATE TABLE IF NOT EXISTS rubric_assessments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
rubric_id UUID NOT NULL REFERENCES rubrics(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id), -- student being assessed
|
||||
graded_by UUID REFERENCES users(id), -- instructor or peer reviewer
|
||||
submission_id UUID, -- if linked to peer_submissions
|
||||
total_score DECIMAL(5,2) NOT NULL,
|
||||
max_score INTEGER NOT NULL,
|
||||
feedback TEXT,
|
||||
status VARCHAR(50) DEFAULT 'draft', -- draft, submitted, published
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_assessments_lesson ON rubric_assessments(lesson_id);
|
||||
CREATE INDEX idx_assessments_user ON rubric_assessments(user_id);
|
||||
CREATE INDEX idx_assessments_grader ON rubric_assessments(graded_by);
|
||||
CREATE INDEX idx_assessments_status ON rubric_assessments(status);
|
||||
|
||||
-- Assessment Scores - Individual criterion scores
|
||||
CREATE TABLE IF NOT EXISTS assessment_scores (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assessment_id UUID NOT NULL REFERENCES rubric_assessments(id) ON DELETE CASCADE,
|
||||
criterion_id UUID NOT NULL REFERENCES rubric_criteria(id),
|
||||
level_id UUID REFERENCES rubric_levels(id), -- selected performance level
|
||||
points DECIMAL(5,2) NOT NULL,
|
||||
feedback TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scores_assessment ON assessment_scores(assessment_id);
|
||||
CREATE INDEX idx_scores_criterion ON assessment_scores(criterion_id);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Migración para crear la tabla de dependencias de lecciones (Learning Sequences)
|
||||
|
||||
CREATE TABLE lesson_dependencies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
prerequisite_lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
min_score_percentage DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(lesson_id, prerequisite_lesson_id),
|
||||
CHECK (lesson_id != prerequisite_lesson_id)
|
||||
);
|
||||
|
||||
-- Índices para mejorar el rendimiento de las consultas
|
||||
CREATE INDEX idx_lesson_dependencies_lesson_id ON lesson_dependencies(lesson_id);
|
||||
CREATE INDEX idx_lesson_dependencies_prerequisite_id ON lesson_dependencies(prerequisite_lesson_id);
|
||||
CREATE INDEX idx_lesson_dependencies_org_id ON lesson_dependencies(organization_id);
|
||||
@@ -0,0 +1,102 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::middleware::Org;
|
||||
use common::models::LessonDependency;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AssignDependencyPayload {
|
||||
pub prerequisite_lesson_id: Uuid,
|
||||
pub min_score_percentage: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn assign_dependency(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
Json(payload): Json<AssignDependencyPayload>,
|
||||
) -> Result<Json<LessonDependency>, StatusCode> {
|
||||
// 1. Validar que ambas lecciones pertenecen a la organización
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM lessons WHERE id IN ($1, $2) AND organization_id = $3",
|
||||
)
|
||||
.bind(lesson_id)
|
||||
.bind(payload.prerequisite_lesson_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if count != 2 {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// 2. Insertar la dependencia
|
||||
let dependency = sqlx::query_as!(
|
||||
LessonDependency,
|
||||
r#"
|
||||
INSERT INTO lesson_dependencies (organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (lesson_id, prerequisite_lesson_id)
|
||||
DO UPDATE SET min_score_percentage = EXCLUDED.min_score_percentage
|
||||
RETURNING id, organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage, created_at
|
||||
"#,
|
||||
org_ctx.id,
|
||||
lesson_id,
|
||||
payload.prerequisite_lesson_id,
|
||||
payload.min_score_percentage
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to assign dependency: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(dependency))
|
||||
}
|
||||
|
||||
pub async fn remove_dependency(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path((lesson_id, prerequisite_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM lesson_dependencies WHERE lesson_id = $1 AND prerequisite_lesson_id = $2 AND organization_id = $3",
|
||||
lesson_id,
|
||||
prerequisite_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_lesson_dependencies(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<LessonDependency>>, StatusCode> {
|
||||
let dependencies = sqlx::query_as!(
|
||||
LessonDependency,
|
||||
"SELECT * FROM lesson_dependencies WHERE lesson_id = $1 AND organization_id = $2",
|
||||
lesson_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(dependencies))
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::auth::Claims;
|
||||
use common::middleware::Org;
|
||||
use common::models::{LessonRubric, Rubric, RubricCriterion, RubricLevel};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ==================== Payload Structs ====================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateRubricPayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub course_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateRubricPayload {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCriterionPayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub max_points: i32,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCriterionPayload {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub max_points: Option<i32>,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateLevelPayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub points: i32,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateLevelPayload {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub points: Option<i32>,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAssessmentPayload {
|
||||
pub lesson_id: Uuid,
|
||||
pub rubric_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub submission_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateAssessmentPayload {
|
||||
pub total_score: f32,
|
||||
pub feedback: Option<String>,
|
||||
pub scores: Vec<CriterionScorePayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CriterionScorePayload {
|
||||
pub criterion_id: Uuid,
|
||||
pub level_id: Option<Uuid>,
|
||||
pub points: f32,
|
||||
pub feedback: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RubricWithDetails {
|
||||
#[serde(flatten)]
|
||||
pub rubric: Rubric,
|
||||
pub criteria: Vec<CriterionWithLevels>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CriterionWithLevels {
|
||||
#[serde(flatten)]
|
||||
pub criterion: RubricCriterion,
|
||||
pub levels: Vec<RubricLevel>,
|
||||
}
|
||||
|
||||
// ==================== Rubric Management ====================
|
||||
|
||||
/// Create a new rubric
|
||||
pub async fn create_rubric(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
Json(payload): Json<CreateRubricPayload>,
|
||||
) -> Result<Json<Rubric>, (StatusCode, String)> {
|
||||
let rubric = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
INSERT INTO rubrics (organization_id, course_id, created_by, name, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at
|
||||
"#,
|
||||
org_ctx.id,
|
||||
payload.course_id.or(Some(course_id)),
|
||||
claims.sub,
|
||||
payload.name,
|
||||
payload.description
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(rubric))
|
||||
}
|
||||
|
||||
/// List all rubrics for a course
|
||||
pub async fn list_course_rubrics(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Rubric>>, (StatusCode, String)> {
|
||||
let rubrics = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at
|
||||
FROM rubrics
|
||||
WHERE organization_id = $1 AND (course_id = $2 OR course_id IS NULL)
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
org_ctx.id,
|
||||
course_id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(rubrics))
|
||||
}
|
||||
|
||||
/// Get a rubric with all criteria and levels
|
||||
pub async fn get_rubric_with_details(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(rubric_id): Path<Uuid>,
|
||||
) -> Result<Json<RubricWithDetails>, (StatusCode, String)> {
|
||||
// Get rubric
|
||||
let rubric = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at
|
||||
FROM rubrics
|
||||
WHERE id = $1 AND organization_id = $2
|
||||
"#,
|
||||
rubric_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||
|
||||
// Get criteria
|
||||
let criteria = sqlx::query_as!(
|
||||
RubricCriterion,
|
||||
r#"
|
||||
SELECT id, rubric_id, name, description, max_points, position, created_at
|
||||
FROM rubric_criteria
|
||||
WHERE rubric_id = $1
|
||||
ORDER BY position ASC
|
||||
"#,
|
||||
rubric_id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Get levels for each criterion
|
||||
let mut criteria_with_levels = Vec::new();
|
||||
for criterion in criteria {
|
||||
let levels = sqlx::query_as!(
|
||||
RubricLevel,
|
||||
r#"
|
||||
SELECT id, criterion_id, name, description, points, position, created_at
|
||||
FROM rubric_levels
|
||||
WHERE criterion_id = $1
|
||||
ORDER BY position ASC
|
||||
"#,
|
||||
criterion.id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
criteria_with_levels.push(CriterionWithLevels { criterion, levels });
|
||||
}
|
||||
|
||||
Ok(Json(RubricWithDetails {
|
||||
rubric,
|
||||
criteria: criteria_with_levels,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update a rubric
|
||||
pub async fn update_rubric(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(rubric_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateRubricPayload>,
|
||||
) -> Result<Json<Rubric>, (StatusCode, String)> {
|
||||
let rubric = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
UPDATE rubrics
|
||||
SET name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
updated_at = NOW()
|
||||
WHERE id = $3 AND organization_id = $4
|
||||
RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at
|
||||
"#,
|
||||
payload.name,
|
||||
payload.description,
|
||||
rubric_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||
|
||||
Ok(Json(rubric))
|
||||
}
|
||||
|
||||
/// Delete a rubric
|
||||
pub async fn delete_rubric(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(rubric_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM rubrics WHERE id = $1 AND organization_id = $2",
|
||||
rubric_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Rubric not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Criterion Management ====================
|
||||
|
||||
/// Add a criterion to a rubric
|
||||
pub async fn create_criterion(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(rubric_id): Path<Uuid>,
|
||||
Json(payload): Json<CreateCriterionPayload>,
|
||||
) -> Result<Json<RubricCriterion>, (StatusCode, String)> {
|
||||
// Verify rubric exists and belongs to org
|
||||
let _rubric = sqlx::query!(
|
||||
"SELECT id FROM rubrics WHERE id = $1 AND organization_id = $2",
|
||||
rubric_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||
|
||||
let position = payload.position.unwrap_or(0);
|
||||
|
||||
let criterion = sqlx::query_as!(
|
||||
RubricCriterion,
|
||||
r#"
|
||||
INSERT INTO rubric_criteria (rubric_id, name, description, max_points, position)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, rubric_id, name, description, max_points, position, created_at
|
||||
"#,
|
||||
rubric_id,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.max_points,
|
||||
position
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Update rubric total_points
|
||||
let _= sqlx::query!(
|
||||
r#"
|
||||
UPDATE rubrics
|
||||
SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
rubric_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(criterion))
|
||||
}
|
||||
|
||||
/// Update a criterion
|
||||
pub async fn update_criterion(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(criterion_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateCriterionPayload>,
|
||||
) -> Result<Json<RubricCriterion>, (StatusCode, String)> {
|
||||
let criterion = sqlx::query_as!(
|
||||
RubricCriterion,
|
||||
r#"
|
||||
UPDATE rubric_criteria
|
||||
SET name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
max_points = COALESCE($3, max_points),
|
||||
position = COALESCE($4, position)
|
||||
WHERE id = $5
|
||||
AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6)
|
||||
RETURNING id, rubric_id, name, description, max_points, position, created_at
|
||||
"#,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.max_points,
|
||||
payload.position,
|
||||
criterion_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||
|
||||
// Update rubric total_points if max_points changed
|
||||
if payload.max_points.is_some() {
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
UPDATE rubrics
|
||||
SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
criterion.rubric_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(Json(criterion))
|
||||
}
|
||||
|
||||
/// Delete a criterion
|
||||
pub async fn delete_criterion(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(criterion_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Get rubric_id before deleting
|
||||
let criterion = sqlx::query!(
|
||||
"SELECT rubric_id FROM rubric_criteria WHERE id = $1",
|
||||
criterion_id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM rubric_criteria
|
||||
WHERE id = $1
|
||||
AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)
|
||||
"#,
|
||||
criterion_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Criterion not found".to_string()));
|
||||
}
|
||||
|
||||
// Update rubric total_points
|
||||
let _ = sqlx::query!(
|
||||
r#"
|
||||
UPDATE rubrics
|
||||
SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
criterion.rubric_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Performance Level Management ====================
|
||||
|
||||
/// Add a performance level to a criterion
|
||||
pub async fn create_level(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(criterion_id): Path<Uuid>,
|
||||
Json(payload): Json<CreateLevelPayload>,
|
||||
) -> Result<Json<RubricLevel>, (StatusCode, String)> {
|
||||
// Verify criterion exists and belongs to org
|
||||
let _criterion = sqlx::query!(
|
||||
"SELECT id FROM rubric_criteria WHERE id = $1 AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)",
|
||||
criterion_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||
|
||||
let position = payload.position.unwrap_or(0);
|
||||
|
||||
let level = sqlx::query_as!(
|
||||
RubricLevel,
|
||||
r#"
|
||||
INSERT INTO rubric_levels (criterion_id, name, description, points, position)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, criterion_id, name, description, points, position, created_at
|
||||
"#,
|
||||
criterion_id,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.points,
|
||||
position
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(level))
|
||||
}
|
||||
|
||||
/// Update a performance level
|
||||
pub async fn update_level(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(level_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateLevelPayload>,
|
||||
) -> Result<Json<RubricLevel>, (StatusCode, String)> {
|
||||
let level = sqlx::query_as!(
|
||||
RubricLevel,
|
||||
r#"
|
||||
UPDATE rubric_levels
|
||||
SET name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
points = COALESCE($3, points),
|
||||
position = COALESCE($4, position)
|
||||
WHERE id = $5
|
||||
AND criterion_id IN (
|
||||
SELECT id FROM rubric_criteria
|
||||
WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6)
|
||||
)
|
||||
RETURNING id, criterion_id, name, description, points, position, created_at
|
||||
"#,
|
||||
payload.name,
|
||||
payload.description,
|
||||
payload.points,
|
||||
payload.position,
|
||||
level_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Level not found".to_string()))?;
|
||||
|
||||
Ok(Json(level))
|
||||
}
|
||||
|
||||
/// Delete a performance level
|
||||
pub async fn delete_level(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(level_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
DELETE FROM rubric_levels
|
||||
WHERE id = $1
|
||||
AND criterion_id IN (
|
||||
SELECT id FROM rubric_criteria
|
||||
WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)
|
||||
)
|
||||
"#,
|
||||
level_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Level not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ==================== Lesson-Rubric Association ====================
|
||||
|
||||
/// Assign a rubric to a lesson
|
||||
pub async fn assign_rubric_to_lesson(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<LessonRubric>, (StatusCode, String)> {
|
||||
let lesson_rubric = sqlx::query_as!(
|
||||
LessonRubric,
|
||||
r#"
|
||||
INSERT INTO lesson_rubrics (lesson_id, rubric_id, is_active)
|
||||
VALUES ($1, $2, true)
|
||||
ON CONFLICT (lesson_id, rubric_id) DO UPDATE SET is_active = true
|
||||
RETURNING id, lesson_id, rubric_id, is_active, assigned_at
|
||||
"#,
|
||||
lesson_id,
|
||||
rubric_id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(lesson_rubric))
|
||||
}
|
||||
|
||||
/// Unassign a rubric from a lesson
|
||||
pub async fn unassign_rubric_from_lesson(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path((lesson_id, rubric_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM lesson_rubrics WHERE lesson_id = $1 AND rubric_id = $2",
|
||||
lesson_id,
|
||||
rubric_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Get rubrics assigned to a lesson
|
||||
pub async fn get_lesson_rubrics(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(lesson_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Rubric>>, (StatusCode, String)> {
|
||||
let rubrics = sqlx::query_as!(
|
||||
Rubric,
|
||||
r#"
|
||||
SELECT r.id, r.organization_id, r.course_id, r.created_by, r.name, r.description, r.total_points, r.created_at, r.updated_at
|
||||
FROM rubrics r
|
||||
INNER JOIN lesson_rubrics lr ON lr.rubric_id = r.id
|
||||
WHERE lr.lesson_id = $1 AND lr.is_active = true AND r.organization_id = $2
|
||||
ORDER BY lr.assigned_at DESC
|
||||
"#,
|
||||
lesson_id,
|
||||
org_ctx.id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(rubrics))
|
||||
}
|
||||
@@ -3,14 +3,16 @@ pub mod exporter;
|
||||
mod external_handlers;
|
||||
mod handlers;
|
||||
mod handlers_branding;
|
||||
mod handlers_dependencies;
|
||||
mod handlers_library;
|
||||
mod handlers_rubrics;
|
||||
mod webhooks;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{delete, get, post},
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use dotenvy::dotenv;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
@@ -196,6 +198,52 @@ async fn main() {
|
||||
"/library/blocks/{id}/increment-usage",
|
||||
post(handlers_library::increment_block_usage),
|
||||
)
|
||||
// Advanced Grading (Rubrics) routes
|
||||
.route(
|
||||
"/courses/{id}/rubrics",
|
||||
get(handlers_rubrics::list_course_rubrics).post(handlers_rubrics::create_rubric),
|
||||
)
|
||||
.route(
|
||||
"/rubrics/{id}",
|
||||
get(handlers_rubrics::get_rubric_with_details)
|
||||
.put(handlers_rubrics::update_rubric)
|
||||
.delete(handlers_rubrics::delete_rubric),
|
||||
)
|
||||
.route(
|
||||
"/rubrics/{id}/criteria",
|
||||
post(handlers_rubrics::create_criterion),
|
||||
)
|
||||
.route(
|
||||
"/criteria/{id}",
|
||||
put(handlers_rubrics::update_criterion).delete(handlers_rubrics::delete_criterion),
|
||||
)
|
||||
.route(
|
||||
"/criteria/{id}/levels",
|
||||
post(handlers_rubrics::create_level),
|
||||
)
|
||||
.route(
|
||||
"/levels/{id}",
|
||||
put(handlers_rubrics::update_level).delete(handlers_rubrics::delete_level),
|
||||
)
|
||||
.route(
|
||||
"/lessons/{lesson_id}/rubrics/{rubric_id}",
|
||||
post(handlers_rubrics::assign_rubric_to_lesson)
|
||||
.delete(handlers_rubrics::unassign_rubric_from_lesson),
|
||||
)
|
||||
.route(
|
||||
"/lessons/{id}/rubrics",
|
||||
get(handlers_rubrics::get_lesson_rubrics),
|
||||
)
|
||||
// Learning Sequences (Dependencies) routes
|
||||
.route(
|
||||
"/lessons/{id}/dependencies",
|
||||
get(handlers_dependencies::list_lesson_dependencies)
|
||||
.post(handlers_dependencies::assign_dependency),
|
||||
)
|
||||
.route(
|
||||
"/lessons/{id}/dependencies/{prerequisite_id}",
|
||||
delete(handlers_dependencies::remove_dependency),
|
||||
)
|
||||
.route_layer(middleware::from_fn(
|
||||
common::middleware::org_extractor_middleware,
|
||||
));
|
||||
|
||||
@@ -600,11 +600,31 @@ pub async fn get_course_outline(
|
||||
pub_modules.push(common::models::PublishedModule { module, lessons });
|
||||
}
|
||||
|
||||
// 6. Fetch all dependencies for this course
|
||||
let dependencies = sqlx::query_as!(
|
||||
LessonDependency,
|
||||
r#"
|
||||
SELECT ld.*
|
||||
FROM lesson_dependencies ld
|
||||
JOIN lessons l ON ld.lesson_id = l.id
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
WHERE m.course_id = $1
|
||||
"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_course_outline: dependencies fetch failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(common::models::PublishedCourse {
|
||||
course,
|
||||
organization,
|
||||
grading_categories,
|
||||
modules: pub_modules,
|
||||
dependencies: Some(dependencies),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -620,7 +640,7 @@ pub async fn get_lesson_content(
|
||||
claims.sub
|
||||
);
|
||||
|
||||
// Check if user is enrolled in the course this lesson belongs to
|
||||
// 1. Check if user is enrolled in the course this lesson belongs to
|
||||
let lesson = sqlx::query_as::<_, Lesson>(
|
||||
"SELECT l.* FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
@@ -636,17 +656,64 @@ pub async fn get_lesson_content(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
match lesson {
|
||||
Some(l) => Ok(Json(l)),
|
||||
let lesson = match lesson {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"get_lesson_content: User {} not enrolled or lesson {} not found",
|
||||
claims.sub,
|
||||
id
|
||||
);
|
||||
Err(StatusCode::FORBIDDEN)
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Enforce Prerequisites
|
||||
// We check if there are any prerequisites that the user hasn't completed yet.
|
||||
// A prerequisite is completed if:
|
||||
// a) It's graded and the user has a grade >= min_score_percentage (default 0)
|
||||
// b) It's not graded and the user has a 'complete' interaction
|
||||
let unmet_dependencies = sqlx::query!(
|
||||
r#"
|
||||
SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage
|
||||
FROM lesson_dependencies ld
|
||||
JOIN lessons p ON ld.prerequisite_lesson_id = p.id
|
||||
LEFT JOIN user_grades ug ON ld.prerequisite_lesson_id = ug.lesson_id AND ug.user_id = $2
|
||||
LEFT JOIN lesson_interactions li ON ld.prerequisite_lesson_id = li.lesson_id
|
||||
AND li.user_id = $2 AND li.event_type = 'complete'
|
||||
WHERE ld.lesson_id = $1
|
||||
AND (
|
||||
(p.is_graded = true AND (ug.score IS NULL OR (ug.score * 100.0) < COALESCE(ld.min_score_percentage, 0.0)))
|
||||
OR
|
||||
(p.is_graded = false AND li.id IS NULL)
|
||||
)
|
||||
"#,
|
||||
id,
|
||||
claims.sub
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("get_lesson_content: failed to check dependencies: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
if !unmet_dependencies.is_empty() {
|
||||
let names: Vec<String> = unmet_dependencies
|
||||
.iter()
|
||||
.map(|d| d.prereq_title.clone())
|
||||
.collect();
|
||||
tracing::warn!(
|
||||
"get_lesson_content: User {} blocked for lesson {} by prerequisites: {:?}",
|
||||
claims.sub,
|
||||
id,
|
||||
names
|
||||
);
|
||||
// We could return a custom error body here, but for now 403 Forbidden is consistent.
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
Ok(Json(lesson))
|
||||
}
|
||||
|
||||
pub async fn get_user_enrollments(
|
||||
|
||||
@@ -211,6 +211,8 @@ pub struct PublishedCourse {
|
||||
pub organization: Organization,
|
||||
pub grading_categories: Vec<GradingCategory>,
|
||||
pub modules: Vec<PublishedModule>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dependencies: Option<Vec<LessonDependency>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -691,3 +693,87 @@ mod tests {
|
||||
assert_eq!(deserialized.modules[0].lessons[0].title, "Test Lesson");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Advanced Grading / Rubrics ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct Rubric {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub course_id: Option<Uuid>,
|
||||
pub created_by: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub total_points: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct RubricCriterion {
|
||||
pub id: Uuid,
|
||||
pub rubric_id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub max_points: i32,
|
||||
pub position: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct RubricLevel {
|
||||
pub id: Uuid,
|
||||
pub criterion_id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub points: i32,
|
||||
pub position: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct LessonRubric {
|
||||
pub id: Uuid,
|
||||
pub lesson_id: Uuid,
|
||||
pub rubric_id: Uuid,
|
||||
pub is_active: bool,
|
||||
pub assigned_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct RubricAssessment {
|
||||
pub id: Uuid,
|
||||
pub lesson_id: Uuid,
|
||||
pub rubric_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub graded_by: Option<Uuid>,
|
||||
pub submission_id: Option<Uuid>,
|
||||
pub total_score: f32,
|
||||
pub max_score: i32,
|
||||
pub feedback: Option<String>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct AssessmentScore {
|
||||
pub id: Uuid,
|
||||
pub assessment_id: Uuid,
|
||||
pub criterion_id: Uuid,
|
||||
pub level_id: Option<Uuid>,
|
||||
pub points: f32,
|
||||
pub feedback: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
// ==================== Learning Sequences / Dependencies ====================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct LessonDependency {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub lesson_id: Uuid,
|
||||
pub prerequisite_lesson_id: Uuid,
|
||||
pub min_score_percentage: Option<f64>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { lmsApi, Course, Module, Recommendation, UserGrade } from "@/lib/api";
|
||||
import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
|
||||
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import DiscussionBoard from "@/components/DiscussionBoard";
|
||||
import { AnnouncementsList } from "@/components/AnnouncementsList";
|
||||
@@ -17,6 +17,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
||||
const [loadingAI, setLoadingAI] = useState(false);
|
||||
const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
|
||||
const [isEnrolled, setIsEnrolled] = useState(false);
|
||||
const [lessonDependencies, setLessonDependencies] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -24,6 +25,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
||||
setLoading(true);
|
||||
const data = await lmsApi.getCourseOutline(params.id);
|
||||
setCourseData({ ...data.course, modules: data.modules });
|
||||
setLessonDependencies(data.dependencies || []);
|
||||
|
||||
if (user) {
|
||||
const grades = await lmsApi.getUserGrades(user.id, params.id);
|
||||
@@ -88,7 +90,23 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
||||
|
||||
if (!courseData) return <div className="text-center py-20 text-gray-500">Curso no encontrado.</div>;
|
||||
|
||||
const isLessonLocked = (lessonId: string) => {
|
||||
if (!isEnrolled) return false;
|
||||
const deps = lessonDependencies.filter(d => d.lesson_id === lessonId);
|
||||
if (deps.length === 0) return false;
|
||||
|
||||
return deps.some(dep => {
|
||||
const prereqGrade = userGrades.find(g => g.lesson_id === dep.prerequisite_lesson_id);
|
||||
if (!prereqGrade) return true; // Not completed at all
|
||||
if (dep.min_score_percentage && (prereqGrade.score * 100) < dep.min_score_percentage) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusIcon = (lessonId: string, isGraded: boolean, allowRetry: boolean) => {
|
||||
if (isLessonLocked(lessonId)) {
|
||||
return <Lock size={18} className="text-gray-600" />;
|
||||
}
|
||||
const grade = userGrades.find((g: UserGrade) => g.lesson_id === lessonId);
|
||||
if (!grade) {
|
||||
return <Circle size={18} className="text-white/20" />;
|
||||
@@ -263,43 +281,63 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 pl-14">
|
||||
{module.lessons.map((lesson: any) => (
|
||||
isEnrolled ? (
|
||||
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
|
||||
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
|
||||
{module.lessons.map((lesson: any) => {
|
||||
const locked = isLessonLocked(lesson.id);
|
||||
return isEnrolled ? (
|
||||
locked ? (
|
||||
<div key={lesson.id} className="glass-card !p-4 border-white/5 opacity-60 cursor-not-allowed">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
|
||||
{lesson.content_type === 'video' ? (
|
||||
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
|
||||
) : (
|
||||
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
|
||||
)}
|
||||
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center">
|
||||
<Lock size={18} className="text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
|
||||
</span>
|
||||
<h3 className="text-sm font-bold text-gray-400">{lesson.title}</h3>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-600">Bloqueado por Prerrequisitos</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
|
||||
{lesson.due_date && (
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
|
||||
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{new Date(lesson.due_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ChevronRight size={18} className="text-blue-500" />
|
||||
</div>
|
||||
<Lock size={18} className="text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
|
||||
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
|
||||
{lesson.content_type === 'video' ? (
|
||||
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
|
||||
) : (
|
||||
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
|
||||
{lesson.due_date && (
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
|
||||
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{new Date(lesson.due_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ChevronRight size={18} className="text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<div key={lesson.id} onClick={handleEnrollOrBuy} className="glass-card !p-4 group border-white/5 opacity-60 cursor-pointer hover:bg-white/5 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -319,8 +357,8 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -138,6 +138,15 @@ export interface GradingCategory {
|
||||
drop_count: number;
|
||||
}
|
||||
|
||||
export interface LessonDependency {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
lesson_id: string;
|
||||
prerequisite_lesson_id: string;
|
||||
min_score_percentage: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserGrade {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -366,7 +375,13 @@ export const lmsApi = {
|
||||
return apiFetch(`/catalog${query}`);
|
||||
},
|
||||
|
||||
async getCourseOutline(courseId: string): Promise<{ course: Course, modules: Module[], grading_categories: GradingCategory[], organization: Organization }> {
|
||||
async getCourseOutline(courseId: string): Promise<{
|
||||
course: Course,
|
||||
modules: Module[],
|
||||
grading_categories: GradingCategory[],
|
||||
organization: Organization,
|
||||
dependencies?: LessonDependency[]
|
||||
}> {
|
||||
return apiFetch(`/courses/${courseId}/outline`);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency } from '@/lib/api';
|
||||
import {
|
||||
Layout,
|
||||
CheckCircle2,
|
||||
Pencil,
|
||||
Save,
|
||||
Trash2,
|
||||
Plus,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Settings,
|
||||
Target,
|
||||
Eye,
|
||||
Brain,
|
||||
Library,
|
||||
BookMarked,
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
import DescriptionBlock from "@/components/blocks/DescriptionBlock";
|
||||
import MediaBlock from "@/components/blocks/MediaBlock";
|
||||
import QuizBlock from "@/components/blocks/QuizBlock";
|
||||
@@ -19,16 +38,6 @@ import PeerReviewBlock from "@/components/blocks/PeerReviewBlock";
|
||||
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
|
||||
import LibraryPanel from "@/components/LibraryPanel";
|
||||
import Modal from "@/components/Modal";
|
||||
import {
|
||||
Save,
|
||||
X,
|
||||
Pencil,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
BookMarked,
|
||||
Library
|
||||
} from "lucide-react";
|
||||
|
||||
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
|
||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||
@@ -50,15 +59,24 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
const [dueDate, setDueDate] = useState<string>("");
|
||||
const [importantDateType, setImportantDateType] = useState<string>("");
|
||||
|
||||
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
|
||||
const [aiQuizContext, setAiQuizContext] = useState("");
|
||||
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
|
||||
// Rubric State
|
||||
const [courseRubrics, setCourseRubrics] = useState<Rubric[]>([]);
|
||||
const [assignedRubricIds, setAssignedRubricIds] = useState<string[]>([]);
|
||||
|
||||
// Content Libraries states
|
||||
const [isSaveToLibraryModalOpen, setIsSaveToLibraryModalOpen] = useState(false);
|
||||
const [blockToSave, setBlockToSave] = useState<Block | null>(null);
|
||||
const [isLibraryPanelOpen, setIsLibraryPanelOpen] = useState(false);
|
||||
|
||||
// Learning Sequences State
|
||||
const [allLessons, setAllLessons] = useState<Lesson[]>([]);
|
||||
const [dependencies, setDependencies] = useState<LessonDependency[]>([]);
|
||||
|
||||
// AI Quiz Generation State
|
||||
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
|
||||
const [aiQuizContext, setAiQuizContext] = useState("");
|
||||
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
|
||||
|
||||
const [editValue, setEditValue] = useState("");
|
||||
|
||||
|
||||
@@ -120,6 +138,28 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
// Load grading categories
|
||||
const categories = await cmsApi.getGradingCategories(params.id);
|
||||
setGradingCategories(categories);
|
||||
|
||||
// Load course rubrics and lesson rubrics
|
||||
const [allRubrics, lessonRubrics, courseOutline, lessonDeps] = await Promise.all([
|
||||
cmsApi.listCourseRubrics(params.id),
|
||||
cmsApi.getLessonRubrics(params.lessonId),
|
||||
cmsApi.getCourseWithFullOutline(params.id),
|
||||
cmsApi.listLessonDependencies(params.lessonId)
|
||||
]);
|
||||
setCourseRubrics(allRubrics);
|
||||
setAssignedRubricIds(lessonRubrics.map(r => r.id));
|
||||
|
||||
// Extract all lessons from outline for prerequisite selection
|
||||
const lessons: Lesson[] = [];
|
||||
courseOutline.modules?.forEach(m => {
|
||||
m.lessons.forEach(l => {
|
||||
if (l.id !== params.lessonId) {
|
||||
lessons.push(l);
|
||||
}
|
||||
});
|
||||
});
|
||||
setAllLessons(lessons);
|
||||
setDependencies(lessonDeps);
|
||||
} catch {
|
||||
console.error("Failed to load lesson or categories");
|
||||
} finally {
|
||||
@@ -140,6 +180,21 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRubric = async (rubricId: string, isAssigned: boolean) => {
|
||||
try {
|
||||
if (isAssigned) {
|
||||
await cmsApi.unassignRubricFromLesson(params.lessonId, rubricId);
|
||||
setAssignedRubricIds(assignedRubricIds.filter(id => id !== rubricId));
|
||||
} else {
|
||||
await cmsApi.assignRubricToLesson(params.lessonId, rubricId);
|
||||
setAssignedRubricIds([...assignedRubricIds, rubricId]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle rubric", err);
|
||||
alert("Failed to update rubric assignment.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!lesson) return;
|
||||
setIsSaving(true);
|
||||
@@ -265,7 +320,22 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateQuiz = async () => {
|
||||
const toggleDependency = async (prerequisiteId: string, isAssigned: boolean) => {
|
||||
try {
|
||||
if (isAssigned) {
|
||||
await cmsApi.removeDependency(params.lessonId, prerequisiteId);
|
||||
setDependencies(dependencies.filter(d => d.prerequisite_lesson_id !== prerequisiteId));
|
||||
} else {
|
||||
const newDep = await cmsApi.assignDependency(params.lessonId, { prerequisite_lesson_id: prerequisiteId });
|
||||
setDependencies([...dependencies, newDep]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle dependency", err);
|
||||
alert("Failed to update prerequisite assignment.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateQuiz = () => {
|
||||
setIsAIQuizModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -368,22 +438,54 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
|
||||
{isGraded && (
|
||||
<>
|
||||
<div className="col-span-full space-y-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
|
||||
<select
|
||||
value={selectedCategoryId}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
|
||||
>
|
||||
<option value="" className="bg-gray-900 border-0">Select Category...</option>
|
||||
{gradingCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id} className="bg-gray-900 border-0">
|
||||
{cat.name} ({cat.weight}%)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
|
||||
Manage categories in <Link href={`/courses/${params.id}/grading`} className="text-blue-400 hover:underline">Grading Policy</Link>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
|
||||
<select
|
||||
value={selectedCategoryId}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
|
||||
>
|
||||
<option value="" className="bg-gray-900 border-0">Select Category...</option>
|
||||
{gradingCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id} className="bg-gray-900 border-0">
|
||||
{cat.name} ({cat.weight}%)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
|
||||
Manage categories in <Link href={`/courses/${params.id}/grading`} className="text-blue-400 hover:underline">Grading Policy</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Rubric (Optional)</span>
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-48 overflow-y-auto space-y-2">
|
||||
{courseRubrics.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic p-2 text-center">No rubrics found in this course.</p>
|
||||
) : (
|
||||
courseRubrics.map(rubric => {
|
||||
const isAssigned = assignedRubricIds.includes(rubric.id);
|
||||
return (
|
||||
<label key={rubric.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-white/5 transition-all cursor-pointer group">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAssigned}
|
||||
onChange={() => toggleRubric(rubric.id, isAssigned)}
|
||||
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-200 group-hover:text-blue-400 transition-colors">{rubric.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-gray-600 bg-white/5 px-1.5 py-0.5 rounded">{rubric.total_points} pts</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
|
||||
Manage rubrics in <Link href={`/courses/${params.id}/rubrics`} className="text-blue-400 hover:underline">Rubrics Manager</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,6 +521,104 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
<p className="text-[10px] text-gray-600 italic">Enables "Check Answer" buttons for individual blocks</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 border-t border-white/5 animate-in fade-in duration-500 delay-150">
|
||||
<div className="flex items-center gap-2 mb-6 uppercase tracking-[0.2em] text-[10px] font-black text-gray-500">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 shadow-lg shadow-blue-500/50"></span>
|
||||
Access & Prerequisites
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-bold text-gray-200">Prerequisites</h4>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
Students must complete these lessons before they can access this one.
|
||||
</p>
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 max-h-60 overflow-y-auto space-y-2">
|
||||
{allLessons.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic p-4 text-center">No other lessons available.</p>
|
||||
) : (
|
||||
allLessons.map(l => {
|
||||
const dep = dependencies.find(d => d.prerequisite_lesson_id === l.id);
|
||||
const isAssigned = !!dep;
|
||||
return (
|
||||
<div key={l.id} className="space-y-2 p-2 rounded-xl hover:bg-white/5 transition-all group border border-transparent hover:border-white/10">
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAssigned}
|
||||
onChange={() => toggleDependency(l.id, isAssigned)}
|
||||
className="w-4 h-4 rounded-md border-gray-700 bg-gray-800 text-blue-500 focus:ring-blue-500 transition-all"
|
||||
/>
|
||||
<span className={`text-sm font-medium transition-colors ${isAssigned ? 'text-blue-400' : 'text-gray-400 group-hover:text-gray-200'}`}>
|
||||
{l.title}
|
||||
</span>
|
||||
</div>
|
||||
{l.is_graded && (
|
||||
<span className="text-[9px] font-black uppercase tracking-tighter px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20">Graded</span>
|
||||
)}
|
||||
</label>
|
||||
{isAssigned && l.is_graded && (
|
||||
<div className="pl-7 animate-in slide-in-from-left-2 duration-300">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-gray-500 font-bold uppercase tracking-widest whitespace-nowrap">Min. Score %</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={dep.min_score_percentage || 0}
|
||||
onChange={async (e) => {
|
||||
const minScore = parseFloat(e.target.value);
|
||||
try {
|
||||
const updated = await cmsApi.assignDependency(params.lessonId, {
|
||||
prerequisite_lesson_id: l.id,
|
||||
min_score_percentage: minScore
|
||||
});
|
||||
setDependencies(dependencies.map(d => d.id === updated.id ? updated : d));
|
||||
} catch (err) {
|
||||
console.error("Failed to update min score", err);
|
||||
}
|
||||
}}
|
||||
className="w-16 bg-black/40 border border-white/10 rounded-lg px-2 py-1 text-xs text-blue-400 font-bold focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center gap-4 px-6 border-l border-white/5">
|
||||
<div className="flex items-start gap-4 p-4 rounded-2xl bg-indigo-500/5 border border-indigo-500/10">
|
||||
<div className="p-2 rounded-xl bg-indigo-500/20">
|
||||
<Layout className="w-5 h-5 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-bold text-indigo-300">Intelligent Sequences</h5>
|
||||
<p className="text-[11px] text-indigo-300/60 leading-relaxed mt-1">
|
||||
Locked lessons will be visible in the student outline with a lock icon 🔒 until they meet all prerequisites.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4 rounded-2xl bg-blue-500/5 border border-blue-500/10">
|
||||
<div className="p-2 rounded-xl bg-blue-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-bold text-blue-300">Completion Tracking</h5>
|
||||
<p className="text-[11px] text-blue-300/60 leading-relaxed mt-1">
|
||||
If a minimum score is set, students must pass the prerequisite before the next lesson is unlocked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div >
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||
import RubricList from "@/components/Rubrics/RubricList";
|
||||
import RubricEditor from "@/components/Rubrics/RubricEditor";
|
||||
import { ArrowLeft, FileText, Info } from "lucide-react";
|
||||
|
||||
export default function RubricsPage() {
|
||||
const { id } = useParams() as { id: string };
|
||||
const router = useRouter();
|
||||
const [editingRubricId, setEditingRubricId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f1115] text-white p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-12">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-6 h-6" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
|
||||
Rubrics Management
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Create and manage evaluation rubrics for your course</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2 bg-blue-500/10 border border-blue-500/20 px-4 py-2 rounded-2xl text-blue-400 text-sm">
|
||||
<Info className="w-4 h-4" />
|
||||
<span>Rubrics can be assigned to multiple lessons across the course.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CourseEditorLayout activeTab="rubrics">
|
||||
<div className="p-8">
|
||||
{editingRubricId ? (
|
||||
<RubricEditor
|
||||
rubricId={editingRubricId}
|
||||
courseId={id}
|
||||
onClose={() => setEditingRubricId(null)}
|
||||
/>
|
||||
) : (
|
||||
<RubricList
|
||||
courseId={id}
|
||||
onEdit={(rubricId) => setEditingRubricId(rubricId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CourseEditorLayout>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder, Graduation
|
||||
|
||||
interface CourseEditorLayoutProps {
|
||||
children: React.ReactNode;
|
||||
activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings" | "files" | "grades";
|
||||
activeTab: "outline" | "grading" | "rubrics" | "calendar" | "analytics" | "settings" | "files" | "grades";
|
||||
}
|
||||
|
||||
export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) {
|
||||
@@ -16,6 +16,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor
|
||||
const tabs = [
|
||||
{ key: "outline", label: "Outline", icon: Layout, href: `/courses/${id}` },
|
||||
{ key: "grading", label: "Grading Policy", icon: CheckCircle2, href: `/courses/${id}/grading` },
|
||||
{ key: "rubrics", label: "Rubrics", icon: Layout, href: `/courses/${id}/rubrics` },
|
||||
{ key: "grades", label: "Gradebook", icon: GraduationCap, href: `/courses/${id}/grades` },
|
||||
{ key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` },
|
||||
{ key: "analytics", label: "Analytics", icon: BarChart2, href: `/courses/${id}/analytics` },
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
RubricWithDetails,
|
||||
RubricCriterion,
|
||||
RubricLevel,
|
||||
cmsApi,
|
||||
CreateCriterionPayload,
|
||||
CreateLevelPayload
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
AlertCircle,
|
||||
Check
|
||||
} from "lucide-react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
interface RubricEditorProps {
|
||||
rubricId: string;
|
||||
courseId: string;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export default function RubricEditor({ rubricId, courseId, onClose, onSaved }: RubricEditorProps) {
|
||||
const [rubric, setRubric] = useState<RubricWithDetails | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadRubric();
|
||||
}, [rubricId]);
|
||||
|
||||
const loadRubric = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await cmsApi.getRubricWithDetails(rubricId);
|
||||
setRubric(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load rubric", err);
|
||||
setError("Failed to load rubric details.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRubric = async (name: string, description: string) => {
|
||||
if (!rubric) return;
|
||||
try {
|
||||
const updated = await cmsApi.updateRubric(rubricId, { name, description: description || undefined });
|
||||
setRubric({ ...rubric, ...updated });
|
||||
} catch (err) {
|
||||
console.error("Failed to update rubric", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCriterion = async () => {
|
||||
if (!rubric) return;
|
||||
const payload: CreateCriterionPayload = {
|
||||
name: "New Criterion",
|
||||
max_points: 10,
|
||||
position: rubric.criteria.length
|
||||
};
|
||||
try {
|
||||
const newCriterion = await cmsApi.createCriterion(rubricId, payload);
|
||||
setRubric({
|
||||
...rubric,
|
||||
criteria: [...rubric.criteria, { ...newCriterion, levels: [] }]
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to add criterion", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCriterion = async (criterionId: string, updates: Partial<RubricCriterion>) => {
|
||||
if (!rubric) return;
|
||||
try {
|
||||
const payload = {
|
||||
...updates,
|
||||
description: updates.description === null ? undefined : updates.description
|
||||
};
|
||||
const updated = await cmsApi.updateCriterion(criterionId, payload);
|
||||
setRubric({
|
||||
...rubric,
|
||||
criteria: rubric.criteria.map(c =>
|
||||
c.id === criterionId
|
||||
? { ...c, ...updated }
|
||||
: c
|
||||
)
|
||||
});
|
||||
// Total points might have changed
|
||||
if (updates.max_points !== undefined) {
|
||||
const updatedRubric = await cmsApi.getRubricWithDetails(rubricId);
|
||||
setRubric(updatedRubric);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update criterion", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCriterion = async (criterionId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this criterion?")) return;
|
||||
try {
|
||||
await cmsApi.deleteCriterion(criterionId);
|
||||
const updatedRubric = await cmsApi.getRubricWithDetails(rubricId);
|
||||
setRubric(updatedRubric);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete criterion", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLevel = async (criterionId: string) => {
|
||||
if (!rubric) return;
|
||||
const criterion = rubric.criteria.find(c => c.id === criterionId);
|
||||
if (!criterion) return;
|
||||
|
||||
const payload: CreateLevelPayload = {
|
||||
name: "New Level",
|
||||
points: 0,
|
||||
position: criterion.levels.length
|
||||
};
|
||||
try {
|
||||
const newLevel = await cmsApi.createLevel(criterionId, payload);
|
||||
setRubric({
|
||||
...rubric,
|
||||
criteria: rubric.criteria.map(c =>
|
||||
c.id === criterionId
|
||||
? { ...c, levels: [...c.levels, newLevel] }
|
||||
: c
|
||||
)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to add level", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateLevel = async (levelId: string, criterionId: string, updates: Partial<RubricLevel>) => {
|
||||
if (!rubric) return;
|
||||
try {
|
||||
const payload = {
|
||||
...updates,
|
||||
description: updates.description === null ? undefined : updates.description
|
||||
};
|
||||
const updated = await cmsApi.updateLevel(levelId, payload);
|
||||
setRubric({
|
||||
...rubric,
|
||||
criteria: rubric.criteria.map(c =>
|
||||
c.id === criterionId
|
||||
? { ...c, levels: c.levels.map(l => l.id === levelId ? updated : l) }
|
||||
: c
|
||||
)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to update level", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLevel = async (levelId: string, criterionId: string) => {
|
||||
try {
|
||||
await cmsApi.deleteLevel(levelId);
|
||||
setRubric({
|
||||
...rubric!,
|
||||
criteria: rubric!.criteria.map(c =>
|
||||
c.id === criterionId
|
||||
? { ...c, levels: c.levels.filter(l => l.id !== levelId) }
|
||||
: c
|
||||
)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to delete level", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error || !rubric) return (
|
||||
<div className="p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-red-400 font-medium">{error || "Rubric not found"}</p>
|
||||
<button onClick={onClose} className="mt-4 text-blue-400 hover:underline">Go Back</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-[#1a1d23] rounded-3xl border border-white/10 overflow-hidden shadow-2xl flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-white/10 flex items-center justify-between bg-white/[0.02]">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={rubric.name}
|
||||
onChange={(e) => handleUpdateRubric(e.target.value, rubric.description || "")}
|
||||
placeholder="Rubric Name"
|
||||
className="bg-transparent text-2xl font-bold text-white focus:outline-none focus:ring-1 focus:ring-blue-500/50 rounded px-2 w-full"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={rubric.description || ""}
|
||||
onChange={(e) => handleUpdateRubric(rubric.name, e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
className="bg-transparent text-sm text-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500/50 rounded px-2 mt-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-4">
|
||||
<div className="text-right mr-4">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-widest block font-semibold">Total Points</span>
|
||||
<span className="text-2xl font-bold text-blue-400">{rubric.total_points}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/10 rounded-full text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8 space-y-12">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2 text-gray-200">
|
||||
Criteria
|
||||
<span className="text-xs font-normal text-gray-500 bg-white/5 px-2 py-0.5 rounded-full">
|
||||
{rubric.criteria.length} items
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAddCriterion}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600/10 text-blue-400 border border-blue-500/30 rounded-xl hover:bg-blue-600 hover:text-white transition-all transform active:scale-95"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Criterion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{rubric.criteria.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-white/5 rounded-3xl bg-white/[0.01]">
|
||||
<p className="text-gray-500 italic">No criteria added yet. Add your first evaluation criterion.</p>
|
||||
</div>
|
||||
) : (
|
||||
rubric.criteria.map((item, idx) => (
|
||||
<div key={item.id} className="group bg-white/[0.03] border border-white/10 rounded-3xl p-6 hover:border-blue-500/30 transition-all">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-2 text-gray-600 group-hover:text-blue-500/50 transition-colors">
|
||||
<GripVertical className="w-5 h-5 cursor-grab active:cursor-grabbing" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={item.name}
|
||||
onChange={(e) => handleUpdateCriterion(item.id, { name: e.target.value })}
|
||||
className="bg-transparent text-lg font-bold text-gray-100 focus:outline-none focus:border-b border-blue-500/50 w-full"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.description || ""}
|
||||
onChange={(e) => handleUpdateCriterion(item.id, { description: e.target.value })}
|
||||
placeholder="Description of what to evaluate..."
|
||||
className="bg-transparent text-sm text-gray-500 focus:outline-none w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-end">
|
||||
<label className="text-[10px] text-gray-500 uppercase tracking-widest font-bold mb-1">Max Points</label>
|
||||
<input
|
||||
type="number"
|
||||
value={item.max_points}
|
||||
onChange={(e) => handleUpdateCriterion(item.id, { max_points: parseInt(e.target.value) || 0 })}
|
||||
className="bg-white/5 border border-white/10 rounded-lg px-2 py-1 w-16 text-center text-blue-400 font-bold focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteCriterion(item.id)}
|
||||
className="p-2 text-red-500/50 hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-all"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Levels */}
|
||||
<div className="pl-4 border-l-2 border-white/5 space-y-4 pt-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-widest">Performance Levels</h4>
|
||||
<button
|
||||
onClick={() => handleAddLevel(item.id)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 font-semibold"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Add Level
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{item.levels.map(level => (
|
||||
<div key={level.id} className="bg-white/5 border border-white/10 rounded-2xl p-4 flex flex-col gap-2 relative group/level hover:bg-white/[0.08] transition-all">
|
||||
<button
|
||||
onClick={() => handleDeleteLevel(level.id, item.id)}
|
||||
className="absolute top-2 right-2 p-1 text-gray-600 hover:text-red-500 opacity-0 group-level-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={level.name}
|
||||
onChange={(e) => handleUpdateLevel(level.id, item.id, { name: e.target.value })}
|
||||
placeholder="Level Name (e.g. Excellent)"
|
||||
className="bg-transparent text-sm font-semibold text-gray-200 focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
value={level.description || ""}
|
||||
onChange={(e) => handleUpdateLevel(level.id, item.id, { description: e.target.value })}
|
||||
placeholder="Criteria description..."
|
||||
className="bg-transparent text-xs text-gray-500 focus:outline-none resize-none h-12"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-gray-600 uppercase font-bold">Points:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={level.points}
|
||||
onChange={(e) => handleUpdateLevel(level.id, item.id, { points: parseInt(e.target.value) || 0 })}
|
||||
className="bg-white/5 border border-white/10 rounded px-1.5 w-12 text-xs text-center text-blue-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-white/10 bg-white/[0.02] flex justify-end gap-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onSaved) onSaved();
|
||||
onClose();
|
||||
}}
|
||||
className="px-8 py-2 bg-blue-600 text-white rounded-xl font-bold hover:bg-blue-500 shadow-lg shadow-blue-500/20 active:scale-95 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Rubric, cmsApi } from "@/lib/api";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Edit2,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
Copy
|
||||
} from "lucide-react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
interface RubricListProps {
|
||||
courseId: string;
|
||||
onEdit: (rubricId: string) => void;
|
||||
}
|
||||
|
||||
export default function RubricList({ courseId, onEdit }: RubricListProps) {
|
||||
const [rubrics, setRubrics] = useState<Rubric[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newRubricName, setNewRubricName] = useState("");
|
||||
|
||||
const loadRubrics = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await cmsApi.listCourseRubrics(courseId);
|
||||
setRubrics(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load rubrics", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRubrics();
|
||||
}, [loadRubrics]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newRubricName) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const newRubric = await cmsApi.createRubric(courseId, {
|
||||
name: newRubricName,
|
||||
course_id: courseId
|
||||
});
|
||||
setRubrics([newRubric, ...rubrics]);
|
||||
setNewRubricName("");
|
||||
onEdit(newRubric.id);
|
||||
} catch (err) {
|
||||
console.error("Failed to create rubric", err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this rubric? This will affect any lessons using it.")) return;
|
||||
try {
|
||||
await cmsApi.deleteRubric(id);
|
||||
setRubrics(rubrics.filter(r => r.id !== id));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete rubric", err);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRubrics = rubrics.filter(r =>
|
||||
r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(r.description && r.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header & Search */}
|
||||
<div className="flex flex-col md:flex-row gap-6 justify-between items-start md:items-center">
|
||||
<div className="relative flex-1 w-full max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search rubrics..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl pl-11 pr-4 py-3 focus:outline-none focus:border-blue-500/50 transition-all text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New Rubric Name"
|
||||
value={newRubricName}
|
||||
onChange={(e) => setNewRubricName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
className="flex-1 md:w-64 bg-white/5 border border-white/10 rounded-xl px-4 py-2 focus:outline-none focus:border-blue-500/50 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newRubricName}
|
||||
className="bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white font-bold px-6 py-2 rounded-xl transition-all shadow-lg shadow-blue-500/20 active:scale-95 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rubrics Grid */}
|
||||
{filteredRubrics.length === 0 ? (
|
||||
<div className="bg-white/5 border border-white/10 rounded-3xl p-16 text-center">
|
||||
<div className="w-16 h-16 bg-blue-500/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<FileText className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-200 mb-2">No rubrics found</h3>
|
||||
<p className="text-gray-500 max-w-md mx-auto">
|
||||
Create a rubric to start using advanced grading criteria in your course lessons.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{filteredRubrics.map((rubric) => (
|
||||
<div
|
||||
key={rubric.id}
|
||||
className="group bg-white/5 border border-white/10 rounded-3xl p-6 hover:border-blue-500/50 hover:bg-white/[0.08] transition-all duration-300 flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400">
|
||||
<FileText className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onEdit(rubric.id)}
|
||||
className="p-2 text-gray-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-lg transition-all"
|
||||
title="Edit Rubric"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(rubric.id)}
|
||||
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
|
||||
title="Delete Rubric"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-100 group-hover:text-blue-400 transition-colors mb-2">
|
||||
{rubric.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm line-clamp-2 min-h-[2.5rem]">
|
||||
{rubric.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-gray-500 uppercase tracking-widest font-bold mb-1">Total Points</span>
|
||||
<span className="text-xl font-black text-white">{rubric.total_points}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onEdit(rubric.id)}
|
||||
className="px-4 py-2 bg-white/5 border border-white/10 rounded-xl text-sm font-semibold hover:bg-blue-600 hover:text-white hover:border-blue-600 transition-all active:scale-95"
|
||||
>
|
||||
Manage Criteria
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -235,6 +235,118 @@ export interface LibraryBlockFilters {
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ==================== Advanced Grading / Rubrics ====================
|
||||
|
||||
export interface Rubric {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
course_id: string | null;
|
||||
created_by: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
total_points: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RubricCriterion {
|
||||
id: string;
|
||||
rubric_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
max_points: number;
|
||||
position: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RubricLevel {
|
||||
id: string;
|
||||
criterion_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
points: number;
|
||||
position: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RubricWithDetails {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
course_id: string | null;
|
||||
created_by: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
total_points: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
criteria: CriterionWithLevels[];
|
||||
}
|
||||
|
||||
export interface CriterionWithLevels {
|
||||
id: string;
|
||||
rubric_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
max_points: number;
|
||||
position: number;
|
||||
created_at: string;
|
||||
levels: RubricLevel[];
|
||||
}
|
||||
|
||||
export interface CreateRubricPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
course_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRubricPayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateCriterionPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
max_points: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCriterionPayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
max_points?: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface CreateLevelPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
points: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface UpdateLevelPayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
points?: number;
|
||||
position?: number;
|
||||
}
|
||||
// ==================== Learning Sequences / Dependencies ====================
|
||||
|
||||
export interface LessonDependency {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
lesson_id: string;
|
||||
prerequisite_lesson_id: string;
|
||||
min_score_percentage: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AssignDependencyPayload {
|
||||
prerequisite_lesson_id: string;
|
||||
min_score_percentage?: number;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -570,6 +682,50 @@ export const cmsApi = {
|
||||
apiFetch(`/library/blocks/${id}`, { method: 'DELETE' }),
|
||||
incrementBlockUsage: (id: string): Promise<void> =>
|
||||
apiFetch(`/library/blocks/${id}/increment-usage`, { method: 'POST' }),
|
||||
|
||||
// Advanced Grading / Rubrics
|
||||
createRubric: (courseId: string, payload: CreateRubricPayload): Promise<Rubric> =>
|
||||
apiFetch(`/courses/${courseId}/rubrics`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||
listCourseRubrics: (courseId: string): Promise<Rubric[]> =>
|
||||
apiFetch(`/courses/${courseId}/rubrics`),
|
||||
getRubricWithDetails: (rubricId: string): Promise<RubricWithDetails> =>
|
||||
apiFetch(`/rubrics/${rubricId}`),
|
||||
updateRubric: (rubricId: string, payload: UpdateRubricPayload): Promise<Rubric> =>
|
||||
apiFetch(`/rubrics/${rubricId}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
deleteRubric: (rubricId: string): Promise<void> =>
|
||||
apiFetch(`/rubrics/${rubricId}`, { method: 'DELETE' }),
|
||||
|
||||
// Rubric Criteria
|
||||
createCriterion: (rubricId: string, payload: CreateCriterionPayload): Promise<RubricCriterion> =>
|
||||
apiFetch(`/rubrics/${rubricId}/criteria`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateCriterion: (criterionId: string, payload: UpdateCriterionPayload): Promise<RubricCriterion> =>
|
||||
apiFetch(`/criteria/${criterionId}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
deleteCriterion: (criterionId: string): Promise<void> =>
|
||||
apiFetch(`/criteria/${criterionId}`, { method: 'DELETE' }),
|
||||
|
||||
// Rubric Levels
|
||||
createLevel: (criterionId: string, payload: CreateLevelPayload): Promise<RubricLevel> =>
|
||||
apiFetch(`/criteria/${criterionId}/levels`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateLevel: (levelId: string, payload: UpdateLevelPayload): Promise<RubricLevel> =>
|
||||
apiFetch(`/levels/${levelId}`, { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
deleteLevel: (levelId: string): Promise<void> =>
|
||||
apiFetch(`/levels/${levelId}`, { method: 'DELETE' }),
|
||||
|
||||
// Lesson-Rubric Association
|
||||
assignRubricToLesson: (lessonId: string, rubricId: string): Promise<void> =>
|
||||
apiFetch(`/lessons/${lessonId}/rubrics/${rubricId}`, { method: 'POST' }),
|
||||
unassignRubricFromLesson: (lessonId: string, rubricId: string): Promise<void> =>
|
||||
apiFetch(`/lessons/${lessonId}/rubrics/${rubricId}`, { method: 'DELETE' }),
|
||||
getLessonRubrics: (lessonId: string): Promise<Rubric[]> =>
|
||||
apiFetch(`/lessons/${lessonId}/rubrics`),
|
||||
|
||||
// Learning Sequences (Dependencies)
|
||||
listLessonDependencies: (lessonId: string): Promise<LessonDependency[]> =>
|
||||
apiFetch(`/lessons/${lessonId}/dependencies`),
|
||||
assignDependency: (lessonId: string, payload: AssignDependencyPayload): Promise<LessonDependency> =>
|
||||
apiFetch(`/lessons/${lessonId}/dependencies`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||
removeDependency: (lessonId: string, prerequisiteId: string): Promise<void> =>
|
||||
apiFetch(`/lessons/${lessonId}/dependencies/${prerequisiteId}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export const lmsApi = {
|
||||
|
||||
Reference in New Issue
Block a user