feat: Implement LTI deep linking, live sessions, predictive analytics, and portfolios with associated UI and database migrations.
This commit is contained in:
@@ -48,7 +48,9 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
|
|||||||
- **Content Libraries**: Repositorio centralizado de bloques y lecciones reutilizables entre múltiples cursos.
|
- **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.
|
- **Advanced Grading (Rubrics)**: Sistema de evaluación basado en rúbricas detalladas con indicadores de desempeño por criterio.
|
||||||
- **Learning Sequences**: Gestión de prerrequisitos entre lecciones con cumplimiento forzado en el LMS.
|
- **Learning Sequences**: Gestión de prerrequisitos entre lecciones con cumplimiento forzado en el LMS.
|
||||||
- **LTI 1.3 Tool Provider**: Interoperabilidad completa para lanzar cursos de OpenCCB desde LMS externos (Canvas, Moodle) de manera segura y estandarizada.
|
- **LTI 1.3 Tool Provider**: Interoperabilidad completa para lanzar cursos de OpenCCB desde LMS externos (Canvas, Moodle) de manera segura y estandarizada, con soporte para **Deep Linking** (Content Picking).
|
||||||
|
- **Global Asset Library**: Repositorio centralizado de medios para toda la organización, permitiendo la reutilización de archivos en múltiples cursos con gestión de cuotas e integridad de datos.
|
||||||
|
- **Predictive Analytics (Dropout Risk)**: Motor de IA que analiza el desempeño, actividad y compromiso social del estudiante para detectar riesgos de abandono de forma proactiva, con alertas accionables para instructores.
|
||||||
|
|
||||||
## Requisitos del Sistema
|
## Requisitos del Sistema
|
||||||
|
|
||||||
@@ -160,7 +162,11 @@ Crea una nueva organización y el usuario administrador inicial.
|
|||||||
OpenCCB soporta integración con proveedores de identidad (IdP) externos como Google, Okta y Azure AD.
|
OpenCCB soporta integración con proveedores de identidad (IdP) externos como Google, Okta y Azure AD.
|
||||||
- **Configuración**: Los administradores de la organización pueden configurar sus credenciales OIDC en el panel de configuración de Studio.
|
- **Configuración**: Los administradores de la organización pueden configurar sus credenciales OIDC en el panel de configuración de Studio.
|
||||||
- **Autoprovisionamiento**: Los nuevos usuarios se crean automáticamente en la plataforma tras una autenticación exitosa.
|
- **Autoprovisionamiento**: Los nuevos usuarios se crean automáticamente en la plataforma tras una autenticación exitosa.
|
||||||
- **Flujo**: `/auth/sso/login/{org_id}` -> IdP -> `/auth/sso/callback` -> Redirección a Studio/Experience con JWT.
|
#### LTI 1.3 e Interoperabilidad
|
||||||
|
OpenCCB actúa como un Tool Provider LTI 1.3 moderno, utilizando OIDC y JWKS para máxima seguridad.
|
||||||
|
- **JWKS Endpoint**: `/lti/jwks` expone las claves públicas para verificación de firmas.
|
||||||
|
- **Deep Linking**: Permite que instructores seleccionen cursos o lecciones específicas desde el LMS externo mediante una interfaz de Studio embebida.
|
||||||
|
- **Autoprovisionamiento**: Los usuarios lanzados vía LTI se crean automáticamente con los roles correspondientes.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Registrar un nuevo administrador y empresa
|
# Registrar un nuevo administrador y empresa
|
||||||
@@ -241,10 +247,10 @@ Agrega contenido multimedia o evaluaciones a un módulo.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### POST /assets
|
#### POST /assets/upload
|
||||||
Sube un archivo multimedia o documento al servidor y devuelve sus metadatos.
|
Sube un archivo multimedia o documento a la biblioteca global de la organización.
|
||||||
|
|
||||||
- **Lógica de Almacenamiento**: Genera un UUID único para el archivo, extrae el mimetype y lo almacena físicamente en el volumen de `uploads`, registrando la entrada en la base de datos de activos.
|
- **Lógica de Reutilización**: Los activos se asocian a la organización y pueden vincularse opcionalmente a un curso específico. El motor de búsqueda permite localizar rápidamente recursos existentes para evitar duplicados.
|
||||||
- **Cuerpo de la Petición ( MultipartForm ):**
|
- **Cuerpo de la Petición ( MultipartForm ):**
|
||||||
- `file`: Archivo binario (PDF, Video, Imagen, Docx).
|
- `file`: Archivo binario (PDF, Video, Imagen, Docx).
|
||||||
- **Respuesta ( UploadResponse ):**
|
- **Respuesta ( UploadResponse ):**
|
||||||
@@ -423,6 +429,13 @@ Generador de reportes personalizados para exportación.
|
|||||||
- **Flexibilidad Administrativa**: Permite filtrar el desempeño por cohortes específicas y devuelve la estructura necesaria para generar archivos CSV profesionales.
|
- **Flexibilidad Administrativa**: Permite filtrar el desempeño por cohortes específicas y devuelve la estructura necesaria para generar archivos CSV profesionales.
|
||||||
- **Respuesta**: Stream de datos o estructura de reporte.
|
- **Respuesta**: Stream de datos o estructura de reporte.
|
||||||
|
|
||||||
|
#### GET /courses/{id}/dropout-risks
|
||||||
|
Obtiene el reporte de riesgo de abandono para todos los estudiantes del curso.
|
||||||
|
|
||||||
|
- **Inteligencia Predictiva**: Calcula en tiempo real (o consulta caché) el nivel de riesgo (Critical, High, Medium, Low) basándose en promedios, frecuencia de actividad y participación en foros.
|
||||||
|
- **Seguridad**: Solo accesible para usuarios con rol `instructor` o `admin`.
|
||||||
|
- **Respuesta**: Array de objetos `DropoutRisk`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Discussion Forums (Foros de Discusión)
|
### 5. Discussion Forums (Foros de Discusión)
|
||||||
@@ -627,6 +640,8 @@ Obtiene una lista de todas las organizaciones registradas.
|
|||||||
- **Student Progress Dashboard**: Panel de control con gráficos interactivos (Recharts) que muestran la actividad de aprendizaje y estiman el tiempo restante del curso.
|
- **Student Progress Dashboard**: Panel de control con gráficos interactivos (Recharts) que muestran la actividad de aprendizaje y estiman el tiempo restante del curso.
|
||||||
- **Course Teams UI**: Panel de gestión para añadir y configurar roles de instructores secundarios y asistentes.
|
- **Course Teams UI**: Panel de gestión para añadir y configurar roles de instructores secundarios y asistentes.
|
||||||
- **Course Preview Badges**: Indicadores visuales y lógica de acceso para lecciones accesibles sin suscripción.
|
- **Course Preview Badges**: Indicadores visuales y lógica de acceso para lecciones accesibles sin suscripción.
|
||||||
|
- **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo.
|
||||||
|
- **Predictive Risk Dashboard**: Panel de control para instructores que visualiza el riesgo de deserción escolar mediante semáforos de color y motivos detallados del riesgo.
|
||||||
|
|
||||||
## 📄 Licencia
|
## 📄 Licencia
|
||||||
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
||||||
+14
-14
@@ -201,25 +201,25 @@
|
|||||||
- [x] Inscripción automática tras pago exitoso.
|
- [x] Inscripción automática tras pago exitoso.
|
||||||
- [x] Verificación de seguridad de acceso a lecciones basada en inscripción.
|
- [x] Verificación de seguridad de acceso a lecciones basada en inscripción.
|
||||||
- [x] Dashboard de transacciones básico en base de datos.
|
- [x] Dashboard de transacciones básico en base de datos.
|
||||||
- [ ] **Interoperabilidad**:
|
- [x] **Interoperabilidad**: ✅
|
||||||
- [ ] Implementación de LTI 1.3 (Tool Provider).
|
- [x] Implementación de LTI 1.3 (Tool Provider) con soporte para Deep Linking.
|
||||||
- [ ] Conectividad con LMS externos (Moodle/Canvas).
|
- [x] Conectividad segura con LMS externos (Moodle/Canvas) via OIDC y JWKS.
|
||||||
- [ ] **Analíticas Predictivas**:
|
- [x] **Analíticas Predictivas**: ✅
|
||||||
- [ ] Motor de IA para detección de riesgo de abandono.
|
- [x] Motor de IA para detección de riesgo de abandono.
|
||||||
- [ ] Notificaciones proactivas para instructores.
|
- [x] Notificaciones proactivas para instructores.
|
||||||
- [ ] **Gestión de Activos**:
|
- [x] **Gestión de Activos**: ✅
|
||||||
- [ ] Biblioteca de medios global (Global Asset Manager).
|
- [x] Biblioteca de medios global (Global Asset Manager).
|
||||||
- [ ] Reutilización de recursos multi-curso.
|
- [x] Reutilización de recursos multi-curso.
|
||||||
- [ ] **Aprendizaje en Vivo**:
|
- [ ] **Aprendizaje en Vivo**:
|
||||||
- [ ] Integración con BigBlueButton/Jitsi.
|
- [ ] Integración con BigBlueButton.
|
||||||
- [ ] **Portafolio del Estudiante**:
|
- [ ] **Portafolio del Estudiante**:
|
||||||
- [ ] Perfil profesional público con Open Badges.
|
- [ ] Perfil profesional público con Open Badges.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil** y **Sistema de Marcadores**.
|
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3 con soporte para Deep Linking** y **Analíticas Predictivas de Riesgo de Abandono**.
|
||||||
|
|
||||||
**Próximas Prioridades**:
|
**Próximas Prioridades**:
|
||||||
1. **Interoperabilidad**: Implementación de LTI 1.3 para conectividad con otros LMS.
|
1. **Apps Móviles**: Desarrollo de versiones nativas para iOS y Android.
|
||||||
2. **Apps Móviles**: Desarrollo de versiones nativas para iOS y Android.
|
2. **Aprendizaje en Vivo**: Integración con plataformas de videoconferencia (BigBlueButton/Jitsi).
|
||||||
3. **Analíticas Predictivas**: Motor de IA para detección de riesgo de abandono.
|
3. **Portafolio del Estudiante**: Perfiles profesionales públicos y Open Badges.
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE library_blocks \n SET name = COALESCE($1, name),\n description = COALESCE($2, description),\n tags = COALESCE($3, tags),\n updated_at = NOW()\n WHERE id = $4 AND organization_id = $5\n RETURNING id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as \"usage_count!\", created_at, updated_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "block_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "block_data",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "tags",
|
||||||
|
"type_info": "TextArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "usage_count!",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"TextArray",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "00e1fd1111ece41c0c1494cf92dacaa1127f01bf97ddf71342cf129c3a3275e1"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT rubric_id FROM rubric_criteria WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "rubric_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "13d9a1d22c6a77705cd74ed6d05916d9f84c28b87cb357178400ee7421cbe0bc"
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE rubric_levels\n SET name = COALESCE($1, name),\n description = COALESCE($2, description),\n points = COALESCE($3, points),\n position = COALESCE($4, position)\n WHERE id = $5\n AND criterion_id IN (\n SELECT id FROM rubric_criteria\n WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6)\n )\n RETURNING id, criterion_id, name, description, points, position, created_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "criterion_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "position",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Text",
|
||||||
|
"Int4",
|
||||||
|
"Int4",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "17b6ee6e225ecf0573f41c8017f7509bf38d345a697b9b885ec276061baa1cba"
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "255e2331ed0f3148bd14e1cc2e791c7c91067eea455146923e463036d61e92c7"
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, criterion_id, name, description, points, position, created_at\n FROM rubric_levels\n WHERE criterion_id = $1\n ORDER BY position ASC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "criterion_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "position",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2893eec86b904d90f69b96766029f17c80aa86fd3a10d12bef9879b9f25ae23a"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE rubrics\n SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1),\n updated_at = NOW()\n WHERE id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "2abb997f1ef644429883fbd0bd72408bddaa1cea968319f28d5e87011e2bf0b7"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM lesson_rubrics WHERE lesson_id = $1 AND rubric_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "3747c2088f23d32d110971afcdf8271678069e981fa98749ff7ec26c358a139f"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE rubrics\n SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1),\n updated_at = NOW()\n WHERE id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "42dfabb9428c4d090242fc98e43eb3a8236333ca2305b6ab852dcc90d232ac89"
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO lesson_rubrics (lesson_id, rubric_id, is_active)\n VALUES ($1, $2, true)\n ON CONFLICT (lesson_id, rubric_id) DO UPDATE SET is_active = true\n RETURNING id, lesson_id, rubric_id, is_active, assigned_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "rubric_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "is_active",
|
||||||
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "assigned_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "45c3ae8b43e4fe46aa4126986b7607996f77213b859e4f47e0a1d16cf4913fd0"
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n 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\n FROM rubrics r\n INNER JOIN lesson_rubrics lr ON lr.rubric_id = r.id\n WHERE lr.lesson_id = $1 AND lr.is_active = true AND r.organization_id = $2\n ORDER BY lr.assigned_at DESC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "total_points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4687bb156a947f156e401c0d610756e7bb3a1abb0f706d3be347659f485e71de"
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO lesson_dependencies (organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (lesson_id, prerequisite_lesson_id) \n DO UPDATE SET min_score_percentage = EXCLUDED.min_score_percentage\n RETURNING id, organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage, created_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "prerequisite_lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "min_score_percentage",
|
||||||
|
"type_info": "Float8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Float8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4e61a89bc2207eba7452c77aea850919ec09dfabd2b8e5b5dd84aca3853669eb"
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at\n FROM rubrics\n WHERE id = $1 AND organization_id = $2\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "total_points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "521e61afe4ab4bf06305447d012ee4bc236f2b3ebce7c788335cf0ca2daf8823"
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at\n FROM rubrics\n WHERE organization_id = $1 AND (course_id = $2 OR course_id IS NULL)\n ORDER BY created_at DESC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "total_points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "62c035c29d3b3c5a2fff84713668f6d8a2f6e2cbabf55a6795b218338239d8ab"
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT * FROM assets WHERE id = $1 AND organization_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "filename",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "storage_path",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "mimetype",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "size_bytes",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "uploaded_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "71a9bb3c9b3ba2c851c2dad049291bb46855eca27523952bbf655250039a7468"
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO library_blocks (organization_id, created_by, name, description, block_type, block_data, tags)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as \"usage_count!\", created_at, updated_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "block_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "block_data",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "tags",
|
||||||
|
"type_info": "TextArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "usage_count!",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Jsonb",
|
||||||
|
"TextArray"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7b3e62330c2b8c283aff253e568b65655efd76d54a5e6ac57093a05718af9882"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE library_blocks SET usage_count = usage_count + 1 WHERE id = $1 AND organization_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "82e4c85bf3aaa45506e3245c3d7b9e8ab224a8e10f2666e69ef212fb0c000ac0"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM assets WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "834a48554bc7989975b42afbc40d0128865826a7cc6130441c42e75b46c54dc9"
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id FROM rubric_criteria WHERE id = $1 AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "85fbeda23c72b58439fdedac4c6dbf23b9054354f737f60c23d0b416944b6095"
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE rubrics\n SET name = COALESCE($1, name),\n description = COALESCE($2, description),\n updated_at = NOW()\n WHERE id = $3 AND organization_id = $4\n RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "total_points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Text",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8ce98992129f77432d24a5a8a458c52d3a252c10f41d6be67d8696b046e4c18f"
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO rubric_criteria (rubric_id, name, description, max_points, position)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, rubric_id, name, description, max_points, position, created_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "rubric_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "max_points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "position",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Varchar",
|
||||||
|
"Text",
|
||||||
|
"Int4",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "914bcec73c3c1399f4e743d3e89289e783b7479592d1c7e11dd677c99d9bb2d3"
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM lesson_dependencies WHERE lesson_id = $1 AND prerequisite_lesson_id = $2 AND organization_id = $3",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "95ddcf80ff28b2680ebdd9d8ba92aefa34fd99209e228238d86e7d135f6b41e2"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM library_blocks WHERE id = $1 AND organization_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b42eb00367a1991125ff01fab2a51a3582512a9265d02c2bcb05ddb80e7c6038"
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id FROM rubrics WHERE id = $1 AND organization_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "c2a37e2b0139c053b4c4eb88a2cf4b658fbf419795901d565eaaa1e169d881e9"
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE library_blocks \n SET description = COALESCE($1, description),\n tags = COALESCE($2, tags),\n updated_at = NOW()\n WHERE id = $3 AND organization_id = $4\n RETURNING id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as \"usage_count!\", created_at, updated_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "block_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "block_data",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "tags",
|
||||||
|
"type_info": "TextArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "usage_count!",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"TextArray",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "cc7467f5734e57f581fab98e7e37a934a7852474ed2eb3ea7b26e4d14b8a4df0"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n DELETE FROM rubric_criteria\n WHERE id = $1\n AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "d06f83e2b566ac9a49c63bdfcce03912f6ae1ef00a96fd345d89f22f218aaf1c"
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO rubrics (organization_id, course_id, created_by, name, description)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "total_points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Varchar",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ddc1f59ea8f744d2357944e68cf49e4176f7dbee191bbdff8876fc88a0e26436"
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE rubric_criteria\n SET name = COALESCE($1, name),\n description = COALESCE($2, description),\n max_points = COALESCE($3, max_points),\n position = COALESCE($4, position)\n WHERE id = $5\n AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $6)\n RETURNING id, rubric_id, name, description, max_points, position, created_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "rubric_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "max_points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "position",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Text",
|
||||||
|
"Int4",
|
||||||
|
"Int4",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e26e27402806a7fa4d85433ed18f25c84a2cc2f5eb6b9c2db7562f75755ddc13"
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as \"usage_count!\", created_at, updated_at FROM library_blocks WHERE id = $1 AND organization_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "created_by",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "block_type",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "block_data",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "tags",
|
||||||
|
"type_info": "TextArray"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "usage_count!",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e3065bc94b895c8ced3d16c97bd6a11909ee9d29a2dc30a70123d07b28d12c11"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM rubrics WHERE id = $1 AND organization_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e3b659588c9e818f6c89d030ae929280aaee5c922c1f01420dc061336cb1c159"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n DELETE FROM rubric_levels\n WHERE id = $1\n AND criterion_id IN (\n SELECT id FROM rubric_criteria\n WHERE rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)\n )\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e57d5797051a54d5ad707edeabfad84a6da8d8d7020f049d67c98687e6961194"
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO rubric_levels (criterion_id, name, description, points, position)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, criterion_id, name, description, points, position, created_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "criterion_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "position",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Varchar",
|
||||||
|
"Text",
|
||||||
|
"Int4",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e5ede144c8250e31ce63979c3f1a835eb7e8377cf75c8dc0679ec5c2f9504e98"
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id FROM library_blocks WHERE id = $1 AND organization_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ed4f770f0bd31dc8dc731e73843d8c71a2462290c837d72e1def2af3f7a5fc48"
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT * FROM lesson_dependencies WHERE lesson_id = $1 AND organization_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "prerequisite_lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "min_score_percentage",
|
||||||
|
"type_info": "Float8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f531c2478ba9634cf935aa615c9320fb99006f6e5e66501a93520af637f8f1a5"
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, rubric_id, name, description, max_points, position, created_at\n FROM rubric_criteria\n WHERE rubric_id = $1\n ORDER BY position ASC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "rubric_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "max_points",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "position",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f7a592c933c658314ed5228da68f5df87fbfbbb4acda6940b1fe57009517a6b7"
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS rubrics (
|
|||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
total_points INTEGER NOT NULL DEFAULT 100,
|
total_points INTEGER NOT NULL DEFAULT 100,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_rubrics_org ON rubrics(organization_id);
|
CREATE INDEX idx_rubrics_org ON rubrics(organization_id);
|
||||||
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS rubric_criteria (
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
max_points INTEGER NOT NULL,
|
max_points INTEGER NOT NULL,
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_criteria_rubric ON rubric_criteria(rubric_id);
|
CREATE INDEX idx_criteria_rubric ON rubric_criteria(rubric_id);
|
||||||
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS rubric_levels (
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
points INTEGER NOT NULL,
|
points INTEGER NOT NULL,
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_levels_criterion ON rubric_levels(criterion_id);
|
CREATE INDEX idx_levels_criterion ON rubric_levels(criterion_id);
|
||||||
@@ -49,8 +49,8 @@ CREATE TABLE IF NOT EXISTS lesson_rubrics (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||||
rubric_id UUID NOT NULL REFERENCES rubrics(id) ON DELETE CASCADE,
|
rubric_id UUID NOT NULL REFERENCES rubrics(id) ON DELETE CASCADE,
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
assigned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE(lesson_id, rubric_id)
|
UNIQUE(lesson_id, rubric_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,9 +68,9 @@ CREATE TABLE IF NOT EXISTS rubric_assessments (
|
|||||||
total_score DECIMAL(5,2) NOT NULL,
|
total_score DECIMAL(5,2) NOT NULL,
|
||||||
max_score INTEGER NOT NULL,
|
max_score INTEGER NOT NULL,
|
||||||
feedback TEXT,
|
feedback TEXT,
|
||||||
status VARCHAR(50) DEFAULT 'draft', -- draft, submitted, published
|
status VARCHAR(50) NOT NULL DEFAULT 'draft', -- draft, submitted, published
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_assessments_lesson ON rubric_assessments(lesson_id);
|
CREATE INDEX idx_assessments_lesson ON rubric_assessments(lesson_id);
|
||||||
@@ -86,7 +86,7 @@ CREATE TABLE IF NOT EXISTS assessment_scores (
|
|||||||
level_id UUID REFERENCES rubric_levels(id), -- selected performance level
|
level_id UUID REFERENCES rubric_levels(id), -- selected performance level
|
||||||
points DECIMAL(5,2) NOT NULL,
|
points DECIMAL(5,2) NOT NULL,
|
||||||
feedback TEXT,
|
feedback TEXT,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_scores_assessment ON assessment_scores(assessment_id);
|
CREATE INDEX idx_scores_assessment ON assessment_scores(assessment_id);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CREATE TABLE lesson_dependencies (
|
|||||||
lesson_id UUID NOT NULL REFERENCES lessons(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,
|
prerequisite_lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||||
min_score_percentage DOUBLE PRECISION,
|
min_score_percentage DOUBLE PRECISION,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE(lesson_id, prerequisite_lesson_id),
|
UNIQUE(lesson_id, prerequisite_lesson_id),
|
||||||
CHECK (lesson_id != prerequisite_lesson_id)
|
CHECK (lesson_id != prerequisite_lesson_id)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3534,7 +3534,7 @@ pub async fn delete_course(
|
|||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
// 2. Additional permission check for instructors
|
// 2. Additional permission check for instructors
|
||||||
if !is_super_admin && !check_course_access(&pool, course.id, claims.sub, &claims.role).await? {
|
if !is_super_admin && !check_course_access(&pool, course.id, claims.sub, &claims.role).await.map_err(|(status, _)| status)? {
|
||||||
return Err(StatusCode::FORBIDDEN);
|
return Err(StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ pub async fn create_rubric(
|
|||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(rubric))
|
Ok(Json(rubric))
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ pub async fn list_course_rubrics(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(rubrics))
|
Ok(Json(rubrics))
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ pub async fn get_rubric_with_details(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||||
|
|
||||||
// Get criteria
|
// Get criteria
|
||||||
@@ -183,7 +183,7 @@ pub async fn get_rubric_with_details(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Get levels for each criterion
|
// Get levels for each criterion
|
||||||
let mut criteria_with_levels = Vec::new();
|
let mut criteria_with_levels = Vec::new();
|
||||||
@@ -200,7 +200,7 @@ pub async fn get_rubric_with_details(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
criteria_with_levels.push(CriterionWithLevels { criterion, levels });
|
criteria_with_levels.push(CriterionWithLevels { criterion, levels });
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ pub async fn update_rubric(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||||
|
|
||||||
Ok(Json(rubric))
|
Ok(Json(rubric))
|
||||||
@@ -254,7 +254,7 @@ pub async fn delete_rubric(
|
|||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err((StatusCode::NOT_FOUND, "Rubric not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Rubric not found".to_string()));
|
||||||
@@ -280,7 +280,7 @@ pub async fn create_criterion(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Rubric not found".to_string()))?;
|
||||||
|
|
||||||
let position = payload.position.unwrap_or(0);
|
let position = payload.position.unwrap_or(0);
|
||||||
@@ -300,7 +300,7 @@ pub async fn create_criterion(
|
|||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Update rubric total_points
|
// Update rubric total_points
|
||||||
let _= sqlx::query!(
|
let _= sqlx::query!(
|
||||||
@@ -314,7 +314,7 @@ pub async fn create_criterion(
|
|||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(criterion))
|
Ok(Json(criterion))
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,7 @@ pub async fn update_criterion(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||||
|
|
||||||
// Update rubric total_points if max_points changed
|
// Update rubric total_points if max_points changed
|
||||||
@@ -363,7 +363,7 @@ pub async fn update_criterion(
|
|||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(criterion))
|
Ok(Json(criterion))
|
||||||
@@ -382,7 +382,7 @@ pub async fn delete_criterion(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||||
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
@@ -396,7 +396,7 @@ pub async fn delete_criterion(
|
|||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err((StatusCode::NOT_FOUND, "Criterion not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Criterion not found".to_string()));
|
||||||
@@ -414,7 +414,7 @@ pub async fn delete_criterion(
|
|||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -436,7 +436,7 @@ pub async fn create_level(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Criterion not found".to_string()))?;
|
||||||
|
|
||||||
let position = payload.position.unwrap_or(0);
|
let position = payload.position.unwrap_or(0);
|
||||||
@@ -456,7 +456,7 @@ pub async fn create_level(
|
|||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(level))
|
Ok(Json(level))
|
||||||
}
|
}
|
||||||
@@ -492,7 +492,7 @@ pub async fn update_level(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Level not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Level not found".to_string()))?;
|
||||||
|
|
||||||
Ok(Json(level))
|
Ok(Json(level))
|
||||||
@@ -518,7 +518,7 @@ pub async fn delete_level(
|
|||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err((StatusCode::NOT_FOUND, "Level not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Level not found".to_string()));
|
||||||
@@ -548,7 +548,7 @@ pub async fn assign_rubric_to_lesson(
|
|||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(lesson_rubric))
|
Ok(Json(lesson_rubric))
|
||||||
}
|
}
|
||||||
@@ -566,7 +566,7 @@ pub async fn unassign_rubric_from_lesson(
|
|||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string()));
|
||||||
@@ -595,7 +595,7 @@ pub async fn get_lesson_rubrics(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(rubrics))
|
Ok(Json(rubrics))
|
||||||
}
|
}
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT \n u.id, \n u.full_name, \n u.email, \n 0.0::float4 as progress,\n (SELECT name FROM cohorts c JOIN user_cohorts uc ON c.id = uc.cohort_id WHERE uc.user_id = u.id LIMIT 1) as cohort_name,\n AVG(g.score)::float4 as average_score\n FROM users u\n JOIN enrollments e ON u.id = e.user_id AND e.course_id = $1\n LEFT JOIN user_grades g ON u.id = g.user_id AND g.course_id = $1\n WHERE e.organization_id = $2\n GROUP BY u.id, u.full_name, u.email\n ORDER BY u.full_name\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "full_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "email",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "progress",
|
||||||
|
"type_info": "Float4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "cohort_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "average_score",
|
||||||
|
"type_info": "Float4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "17f05eb41a9b8c4fd37f1c47495546709658ff09ac8be7ad1c611039e55394da"
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE course_submissions \n SET content = $1, updated_at = NOW() \n WHERE user_id = $2 AND lesson_id = $3\n RETURNING *\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "submitted_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "363c5ded702de620f7d55d9a28564a6cb07ab2123f6733eb9992ef29a0347a3f"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT user_id FROM course_submissions WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "48092c69f6c0c66fc843d31c65123dfbd6771c450e34d2f330a7cec4cad9e16e"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "cohort_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "5e0c0dd74a0fcb24eae0b69b46550cdb1fc0520f59ab24095319c844100696a8"
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT s.* \n FROM course_submissions s\n LEFT JOIN peer_reviews pr ON s.id = pr.submission_id\n WHERE s.course_id = $1 \n AND s.lesson_id = $2\n AND s.user_id != $3\n AND s.organization_id = $4\n AND NOT EXISTS (\n SELECT 1 FROM peer_reviews my_pr \n WHERE my_pr.submission_id = s.id AND my_pr.reviewer_id = $3\n )\n GROUP BY s.id\n HAVING COUNT(pr.id) < 2\n ORDER BY s.submitted_at ASC\n LIMIT 1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "submitted_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "60ddd0622ba9bdb70c661f9b8f755f4336017efa585e1f93ac848b1fed4835a1"
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage\n FROM lesson_dependencies ld\n JOIN lessons p ON ld.prerequisite_lesson_id = p.id\n LEFT JOIN user_grades ug ON ld.prerequisite_lesson_id = ug.lesson_id AND ug.user_id = $2\n LEFT JOIN lesson_interactions li ON ld.prerequisite_lesson_id = li.lesson_id \n AND li.user_id = $2 AND li.event_type = 'complete'\n WHERE ld.lesson_id = $1\n AND (\n (p.is_graded = true AND (ug.score IS NULL OR (ug.score * 100.0) < COALESCE(ld.min_score_percentage, 0.0)))\n OR\n (p.is_graded = false AND li.id IS NULL)\n )\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "prerequisite_lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "prereq_title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "min_score_percentage",
|
||||||
|
"type_info": "Float8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "6744490d98f0f7b1d753e89dfe2cddef4e580c62954847940e5fa0d1ad6a7fcf"
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT ld.* \n FROM lesson_dependencies ld\n JOIN lessons l ON ld.lesson_id = l.id\n JOIN modules m ON l.module_id = m.id\n WHERE m.course_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "prerequisite_lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "min_score_percentage",
|
||||||
|
"type_info": "Float8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "82038007a13b07cdb912619ddefa10faa651a934e8dfb14be29226b85c614cae"
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "af5539604d4c172890ce3f62de6dbe7952f027d6caf367ed0480b5f16274bd1f"
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bba15398004acf73d991751221eba784335db7d899c0601d216fc1703ff49d06"
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "submitted_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bd51fbc3e8b3746722d88201f1937ffa3f2718790d55d4b9143bacaa6219173e"
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING *\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "submission_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "reviewer_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "score",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "feedback",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Int4",
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bf3c31d22790fe0eeec0234f97f920a3c077a443579c6184f09b95f9d078f593"
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT \n g.user_id, \n l.grading_category_id, \n AVG(g.score)::float4 as avg_score\n FROM user_grades g\n JOIN lessons l ON g.lesson_id = l.id\n WHERE g.course_id = $1\n GROUP BY g.user_id, l.grading_category_id\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "grading_category_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "avg_score",
|
||||||
|
"type_info": "Float4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "c1778b5abe6d4e799993ac6145d1c00bd5d73086e80b9e068010f29202b1f5a8"
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT pr.* \n FROM peer_reviews pr\n JOIN course_submissions cs ON pr.submission_id = cs.id\n WHERE cs.user_id = $1 AND cs.lesson_id = $2\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "submission_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "reviewer_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "score",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "feedback",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "da54efaca96ea7a75198417d4ce1754aaad514c9877a264449807338a1f539b8"
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING *\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "course_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "lesson_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "submitted_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "updated_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "organization_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e0cf43306025c312338d7f507911fccb61f18ddd6bf8f9c90f368a296f346f4b"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDB4h2jpCSUglbv
|
||||||
|
ugQB3Q29f8vV7qYVGkdtKuFaMQlN9APMt01XzjM9+76s/4gl1aW+u07YX90cdqHS
|
||||||
|
KU1FkJoDA9c2pLovns6tgmPF4Ncpaed9pXDi5RPVoMYVxdDnkFt/Tn43bxqPbo0R
|
||||||
|
eH1ZBWo7ovxdGuV5FSAm1vwwEBai/EqLcIEn+UVVh7fVw94A4JoQX/HN4xUSYm3d
|
||||||
|
9tJ7CxQisHuMPwXdg5y7yH9AYBz22f27Ex8Em/ZdmgYcK0xBQYbTuJm2hIoFgatZ
|
||||||
|
/msFzs+wIVxW5DOU71x/XeOBDatx9xTcvAL+CngD1Fie//KZocQvPSQAsoG0HGPV
|
||||||
|
dTtB0uuzAgMBAAECggEACeDGV88GGhbl7QLSL3IewBfhv72P8qVLHFHB94FmEvWq
|
||||||
|
t+Ri6WVVEQRhe5jtS9gtwKD0bGu97TRSHE4EZNXwMtUgQuVzovd5Wje5c5x3+eEu
|
||||||
|
bSVfsf+v2gN99CAG+7VMJBlQruxXMxWT1F8KK93twqoUJ342UMv1vmTXpm87aARR
|
||||||
|
56uRb9PywQrJbxfC1Lg5kB5LMYdeyKmuoUNg+EP/4YhIbWDyLBHIT+TLjXN8sA01
|
||||||
|
TXXatqL5X8BxjvgIYUdILLQcJ+IsumGbR1wM02XzZbIaMlIuD/ey1GLSvr2Q1jBP
|
||||||
|
YgBCYLql809SPlyopj1W+r9TjulPtJ/I4pl5kgSL8QKBgQDiOg4ydyZt/ESzUQ2Z
|
||||||
|
Wp2Z5Mc9w6fnaPk/Wf8GYuopbWmMIAO0KsoBYgjuZW3bnEl0yZ7r+NtqiWciWp6n
|
||||||
|
C4HFW5ymAzp2zrPQkrNw2TL1QQ8toE8G1AApifc7QVKxDgVeKibF0wtR1bSSOhlt
|
||||||
|
wqzIYO9sLXI6jrcRmQr4qZWYWwKBgQDbZlrwW06x26KqNfURA5B99XqMVrRK3tBb
|
||||||
|
S3Y1KLELZ8BiEeLgdLPnboR++OvN7LR3QgWNP5rS7DXbIy5kPzSQyPlO3G+FBqFC
|
||||||
|
SP4j2SH21Cj2LNrqMW1WERr5Zh44lpyRB2g0bbKBCXSHeP7Sg4qMoNCle6CuWS/X
|
||||||
|
6x4dOAKZiQKBgB/+4AUpLuk9VaYa35aB52pdngRRSM0E3sOkAdqwYLftPpFP8dYo
|
||||||
|
exuI9wRomgoGZ6k53t02/ClsN4b3VBsCGJ+GHnioWjt1bp8gMHrUbU2cnv3v/11S
|
||||||
|
3JcDaVEbIwvhlMbFpWgzOhWf6QMJbpFEiFVqyFH/d3lqt9+oSpHywjKjAoGBALpO
|
||||||
|
vMKGhtj5zbQEhcqg4D5WCm7J4egCNaSQ/BxAJbetruyYi7RW5b6NVu4LqxH/A3CS
|
||||||
|
G+zKKksaUtF3mpl+IsEgKLUS85BfBOko2sbOR802dGI3zN46gsInXGSUlu0u2F0/
|
||||||
|
kPmUfZSd1tqDoMBa+3hXx1X/GX90NPCBs9zUB0EhAoGAIyebw+Qu75MuOiaeLXO2
|
||||||
|
i+9Lp/WRT3WnR4CCXgnaTlB2V2bfYsSDPmFPxbiZsB/Cvj0lEQzzHLNOV6QXEG78
|
||||||
|
OOjPU1HRRg3czbBPYof0J9oTxTM8s8+Tw82uT+7yb+OyyV5Yu/o22kcvA0YToMwP
|
||||||
|
TNX0s8zakZzCaFpIdGzVOUk=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAweIdo6QklIJW77oEAd0N
|
||||||
|
vX/L1e6mFRpHbSrhWjEJTfQDzLdNV84zPfu+rP+IJdWlvrtO2F/dHHah0ilNRZCa
|
||||||
|
AwPXNqS6L57OrYJjxeDXKWnnfaVw4uUT1aDGFcXQ55Bbf05+N28aj26NEXh9WQVq
|
||||||
|
O6L8XRrleRUgJtb8MBAWovxKi3CBJ/lFVYe31cPeAOCaEF/xzeMVEmJt3fbSewsU
|
||||||
|
IrB7jD8F3YOcu8h/QGAc9tn9uxMfBJv2XZoGHCtMQUGG07iZtoSKBYGrWf5rBc7P
|
||||||
|
sCFcVuQzlO9cf13jgQ2rcfcU3LwC/gp4A9RYnv/ymaHELz0kALKBtBxj1XU7QdLr
|
||||||
|
swIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
weIdo6QklIJW77oEAd0NvX_L1e6mFRpHbSrhWjEJTfQDzLdNV84zPfu-rP-IJdWlvrtO2F_dHHah0ilNRZCaAwPXNqS6L57OrYJjxeDXKWnnfaVw4uUT1aDGFcXQ55Bbf05-N28aj26NEXh9WQVqO6L8XRrleRUgJtb8MBAWovxKi3CBJ_lFVYe31cPeAOCaEF_xzeMVEmJt3fbSewsUIrB7jD8F3YOcu8h_QGAc9tn9uxMfBJv2XZoGHCtMQUGG07iZtoSKBYGrWf5rBc7PsCFcVuQzlO9cf13jgQ2rcfcU3LwC_gp4A9RYnv_ymaHELz0kALKBtBxj1XU7QdLrsw
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
C1E21DA3A424948256EFBA0401DD0DBD7FCBD5EEA6151A476D2AE15A31094DF403CCB74D57CE333DFBBEACFF8825D5A5BEBB4ED85FDD1C76A1D2294D45909A0303D736A4BA2F9ECEAD8263C5E0D72969E77DA570E2E513D5A0C615C5D0E7905B7F4E7E376F1A8F6E8D11787D59056A3BA2FC5D1AE579152026D6FC301016A2FC4A8B708127F9455587B7D5C3DE00E09A105FF1CDE31512626DDDF6D27B0B1422B07B8C3F05DD839CBBC87F40601CF6D9FDBB131F049BF65D9A061C2B4C414186D3B899B6848A0581AB59FE6B05CECFB0215C56E43394EF5C7F5DE3810DAB71F714DCBC02FE0A7803D4589EFFF299A1C42F3D2400B281B41C63D5753B41D2EBB3
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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 NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(lesson_id, prerequisite_lesson_id),
|
||||||
|
CHECK (lesson_id != prerequisite_lesson_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,13 @@
|
|||||||
|
-- Migration: Add LTI Deep Linking support tables
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lti_deep_linking_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
registration_id UUID NOT NULL REFERENCES lti_registrations(id),
|
||||||
|
deployment_id TEXT NOT NULL,
|
||||||
|
return_url TEXT NOT NULL,
|
||||||
|
data TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for cleanup
|
||||||
|
CREATE INDEX idx_lti_dl_requests_created_at ON lti_deep_linking_requests(created_at);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
CREATE TYPE dropout_risk_level AS ENUM ('low', 'medium', 'high', 'critical');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dropout_risks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES organizations(id),
|
||||||
|
course_id UUID NOT NULL REFERENCES courses(id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
risk_level dropout_risk_level NOT NULL DEFAULT 'low',
|
||||||
|
score REAL NOT NULL DEFAULT 0.0,
|
||||||
|
reasons JSONB,
|
||||||
|
last_calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(course_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE TRIGGER update_dropout_risks_updated_at
|
||||||
|
BEFORE UPDATE ON dropout_risks
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE meetings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES organizations(id),
|
||||||
|
course_id UUID NOT NULL REFERENCES courses(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
provider TEXT NOT NULL DEFAULT 'jitsi',
|
||||||
|
meeting_id TEXT NOT NULL,
|
||||||
|
start_at TIMESTAMPTZ NOT NULL,
|
||||||
|
duration_minutes INTEGER NOT NULL,
|
||||||
|
join_url TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for quick lookup of course meetings
|
||||||
|
CREATE INDEX idx_meetings_course ON meetings(course_id);
|
||||||
|
|
||||||
|
-- Trigger for updated_at
|
||||||
|
CREATE TRIGGER update_meetings_updated_at
|
||||||
|
BEFORE UPDATE ON meetings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Migration: Portfolios & Badges (Adjustments)
|
||||||
|
-- This migration adjusts existing gamification tables to support the new features
|
||||||
|
|
||||||
|
-- 1. Adjust badges table
|
||||||
|
ALTER TABLE badges ADD COLUMN IF NOT EXISTS criteria JSONB NOT NULL DEFAULT '{}';
|
||||||
|
-- Ensure organization_id has a foreign key if it's missing (optional but good)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'badges_organization_id_fkey') THEN
|
||||||
|
ALTER TABLE badges ADD CONSTRAINT badges_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Adjust user_badges table
|
||||||
|
ALTER TABLE user_badges ADD COLUMN IF NOT EXISTS evidence_url TEXT;
|
||||||
|
-- Rename earned_at to awarded_at if needed, or just use earned_at in code.
|
||||||
|
-- The model currently expects awarded_at. Let's rename if exists.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_badges' AND column_name='earned_at') THEN
|
||||||
|
ALTER TABLE user_badges RENAME COLUMN earned_at TO awarded_at;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3. Add profile visibility to users
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_public_profile BOOLEAN DEFAULT true;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS linkedin_url TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS github_url TEXT;
|
||||||
|
|
||||||
|
-- 4. Seed some extra default badges if not present
|
||||||
|
INSERT INTO badges (organization_id, name, description, icon_url, requirement_type, requirement_value)
|
||||||
|
SELECT id, 'Open Source Contributor', 'Linked a GitHub account to your profile', '/badges/github.svg', 'points', 0
|
||||||
|
FROM organizations
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM badges WHERE name = 'Open Source Contributor')
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO badges (organization_id, name, description, icon_url, requirement_type, requirement_value)
|
||||||
|
SELECT id, 'Networking Pro', 'Linked a LinkedIn account to your profile', '/badges/linkedin.svg', 'points', 0
|
||||||
|
FROM organizations
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM badges WHERE name = 'Networking Pro')
|
||||||
|
LIMIT 1;
|
||||||
@@ -10,6 +10,7 @@ use common::middleware::Org;
|
|||||||
use common::models::{
|
use common::models::{
|
||||||
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
|
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
|
||||||
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
|
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
|
||||||
|
LessonDependency,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_me(
|
pub async fn get_me(
|
||||||
@@ -20,7 +21,7 @@ pub async fn get_me(
|
|||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(UserResponse {
|
Ok(Json(UserResponse {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -156,7 +157,7 @@ pub async fn export_course_grades(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 2. Get Student general data
|
// 2. Get Student general data
|
||||||
let students = sqlx::query!(
|
let students = sqlx::query!(
|
||||||
@@ -165,14 +166,14 @@ pub async fn export_course_grades(
|
|||||||
u.id,
|
u.id,
|
||||||
u.full_name,
|
u.full_name,
|
||||||
u.email,
|
u.email,
|
||||||
COALESCE(e.progress, 0)::float4 as progress,
|
0.0::float4 as progress,
|
||||||
(SELECT name FROM cohorts c JOIN user_cohorts uc ON c.id = uc.cohort_id WHERE uc.user_id = u.id LIMIT 1) as cohort_name,
|
(SELECT name FROM cohorts c JOIN user_cohorts uc ON c.id = uc.cohort_id WHERE uc.user_id = u.id LIMIT 1) as cohort_name,
|
||||||
AVG(g.score)::float4 as average_score
|
AVG(g.score)::float4 as average_score
|
||||||
FROM users u
|
FROM users u
|
||||||
JOIN enrollments e ON u.id = e.user_id AND e.course_id = $1
|
JOIN enrollments e ON u.id = e.user_id AND e.course_id = $1
|
||||||
LEFT JOIN user_grades g ON u.id = g.user_id AND g.course_id = $1
|
LEFT JOIN user_grades g ON u.id = g.user_id AND g.course_id = $1
|
||||||
WHERE e.organization_id = $2
|
WHERE e.organization_id = $2
|
||||||
GROUP BY u.id, u.full_name, u.email, e.progress
|
GROUP BY u.id, u.full_name, u.email
|
||||||
ORDER BY u.full_name
|
ORDER BY u.full_name
|
||||||
"#,
|
"#,
|
||||||
course_id,
|
course_id,
|
||||||
@@ -180,7 +181,7 @@ pub async fn export_course_grades(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 3. Get detailed grades per user/category
|
// 3. Get detailed grades per user/category
|
||||||
struct UserCategoryGrade {
|
struct UserCategoryGrade {
|
||||||
@@ -205,7 +206,7 @@ pub async fn export_course_grades(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 4. Build CSV
|
// 4. Build CSV
|
||||||
let mut csv = "Name,Email,Cohort,Progress,Overall Score".to_string();
|
let mut csv = "Name,Email,Cohort,Progress,Overall Score".to_string();
|
||||||
@@ -216,7 +217,7 @@ pub async fn export_course_grades(
|
|||||||
|
|
||||||
for s in students {
|
for s in students {
|
||||||
let cohort = s.cohort_name.unwrap_or_else(|| "N/A".to_string());
|
let cohort = s.cohort_name.unwrap_or_else(|| "N/A".to_string());
|
||||||
let progress = format!("{:.1}%", s.progress * 100.0);
|
let progress = format!("{:.1}%", s.progress.unwrap_or(0.0) * 100.0);
|
||||||
let overall = s
|
let overall = s
|
||||||
.average_score
|
.average_score
|
||||||
.map(|v| format!("{:.1}%", v * 100.0))
|
.map(|v| format!("{:.1}%", v * 100.0))
|
||||||
@@ -327,7 +328,7 @@ pub async fn enroll_user(
|
|||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Enrollment failed: {}", e);
|
tracing::error!("Enrollment failed: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -415,7 +416,7 @@ pub async fn register(
|
|||||||
let mut tx = pool
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let organization = if let Some(org_name) = payload.organization_name {
|
let organization = if let Some(org_name) = payload.organization_name {
|
||||||
sqlx::query_as::<_, Organization>(
|
sqlx::query_as::<_, Organization>(
|
||||||
@@ -424,7 +425,7 @@ pub async fn register(
|
|||||||
.bind(&org_name)
|
.bind(&org_name)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al buscar o crear la organización: {}", e)))?
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al buscar o crear la organización: {}", e)))?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, Organization>(
|
sqlx::query_as::<_, Organization>(
|
||||||
"SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'",
|
"SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'",
|
||||||
@@ -448,11 +449,11 @@ pub async fn register(
|
|||||||
.bind(organization.id)
|
.bind(organization.id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::CONFLICT, format!("El usuario ya existe o error en la BD: {}", e)))?;
|
.map_err(|e: sqlx::Error| (StatusCode::CONFLICT, format!("El usuario ya existe o error en la BD: {}", e)))?;
|
||||||
|
|
||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| {
|
let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| {
|
||||||
(
|
(
|
||||||
@@ -570,7 +571,7 @@ pub async fn get_course_catalog(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Catalog fetch failed: {}", e);
|
tracing::error!("Catalog fetch failed: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -612,7 +613,7 @@ pub async fn ingest_course(
|
|||||||
.bind(payload.organization.updated_at)
|
.bind(payload.organization.updated_at)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to upsert organization during ingestion: {}", e);
|
tracing::error!("Failed to upsert organization during ingestion: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -650,7 +651,7 @@ pub async fn ingest_course(
|
|||||||
.bind(&payload.course.currency)
|
.bind(&payload.course.currency)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to upsert course during ingestion: {}", e);
|
tracing::error!("Failed to upsert course during ingestion: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -706,7 +707,7 @@ pub async fn ingest_course(
|
|||||||
.bind(instructor.created_at)
|
.bind(instructor.created_at)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to insert instructor: {}", e);
|
tracing::error!("Failed to insert instructor: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -755,7 +756,7 @@ pub async fn ingest_course(
|
|||||||
.bind(lesson.is_previewable)
|
.bind(lesson.is_previewable)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to insert lesson: {}", e);
|
tracing::error!("Failed to insert lesson: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -817,7 +818,7 @@ pub async fn get_course_outline(
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("get_course_outline: course fetch failed for {}: {}", id, e);
|
tracing::error!("get_course_outline: course fetch failed for {}: {}", id, e);
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
})?;
|
})?;
|
||||||
@@ -830,7 +831,7 @@ pub async fn get_course_outline(
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("get_course_outline: modules fetch failed: {}", e);
|
tracing::error!("get_course_outline: modules fetch failed: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -844,7 +845,7 @@ pub async fn get_course_outline(
|
|||||||
.bind(course.organization_id)
|
.bind(course.organization_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"get_course_outline: organization fetch failed for {}: {}",
|
"get_course_outline: organization fetch failed for {}: {}",
|
||||||
course.organization_id,
|
course.organization_id,
|
||||||
@@ -865,7 +866,7 @@ pub async fn get_course_outline(
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("get_course_outline: grading categories fetch failed: {}", e);
|
tracing::error!("get_course_outline: grading categories fetch failed: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -879,7 +880,7 @@ pub async fn get_course_outline(
|
|||||||
.bind(module.id)
|
.bind(module.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"get_course_outline: lessons fetch failed for module {}: {}",
|
"get_course_outline: lessons fetch failed for module {}: {}",
|
||||||
module.id,
|
module.id,
|
||||||
@@ -905,7 +906,7 @@ pub async fn get_course_outline(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("get_course_outline: dependencies fetch failed: {}", e);
|
tracing::error!("get_course_outline: dependencies fetch failed: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -959,7 +960,7 @@ pub async fn get_lesson_content(
|
|||||||
.bind(claims.org)
|
.bind(claims.org)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("get_lesson_content: DB error (preview): {}", e);
|
tracing::error!("get_lesson_content: DB error (preview): {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?
|
})?
|
||||||
@@ -974,7 +975,7 @@ pub async fn get_lesson_content(
|
|||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("get_lesson_content: DB error: {}", e);
|
tracing::error!("get_lesson_content: DB error: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?
|
})?
|
||||||
@@ -1020,7 +1021,7 @@ pub async fn get_lesson_content(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("get_lesson_content: failed to check dependencies: {}", e);
|
tracing::error!("get_lesson_content: failed to check dependencies: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -1073,7 +1074,7 @@ pub async fn submit_lesson_score(
|
|||||||
let mut tx = pool
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let ip = headers
|
let ip = headers
|
||||||
.get("x-forwarded-for")
|
.get("x-forwarded-for")
|
||||||
@@ -1095,7 +1096,7 @@ pub async fn submit_lesson_score(
|
|||||||
Some("SYSTEM_EVENT".to_string()),
|
Some("SYSTEM_EVENT".to_string()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 1. Get lesson attempt rules
|
// 1. Get lesson attempt rules
|
||||||
let max_attempts: Option<Option<i32>> =
|
let max_attempts: Option<Option<i32>> =
|
||||||
@@ -1103,7 +1104,7 @@ pub async fn submit_lesson_score(
|
|||||||
.bind(payload.lesson_id)
|
.bind(payload.lesson_id)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if max_attempts.is_none() {
|
if max_attempts.is_none() {
|
||||||
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".into()));
|
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".into()));
|
||||||
@@ -1117,7 +1118,7 @@ pub async fn submit_lesson_score(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if let Some(count) = existing_attempts {
|
if let Some(count) = existing_attempts {
|
||||||
if let Some(max) = max_attempts {
|
if let Some(max) = max_attempts {
|
||||||
@@ -1142,11 +1143,11 @@ pub async fn submit_lesson_score(
|
|||||||
.bind(payload.metadata)
|
.bind(payload.metadata)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 4. Dispatch Webhooks
|
// 4. Dispatch Webhooks
|
||||||
let webhook_service = common::webhooks::WebhookService::new(pool.clone());
|
let webhook_service = common::webhooks::WebhookService::new(pool.clone());
|
||||||
@@ -1254,7 +1255,7 @@ pub async fn get_leaderboard(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to fetch leaderboard: {}", e);
|
tracing::error!("Failed to fetch leaderboard: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -1310,7 +1311,7 @@ pub async fn get_course_grades(
|
|||||||
.bind(filter.cohort_id)
|
.bind(filter.cohort_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to fetch course grades: {}", e);
|
tracing::error!("Failed to fetch course grades: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||||
})?;
|
})?;
|
||||||
@@ -1362,7 +1363,7 @@ pub async fn get_course_analytics(
|
|||||||
.bind(filter.cohort_id)
|
.bind(filter.cohort_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 2. Average Course Score (Overall)
|
// 2. Average Course Score (Overall)
|
||||||
let average_score: Option<f32> = sqlx::query_scalar(
|
let average_score: Option<f32> = sqlx::query_scalar(
|
||||||
@@ -1381,7 +1382,7 @@ pub async fn get_course_analytics(
|
|||||||
.bind(filter.cohort_id)
|
.bind(filter.cohort_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 3. Per-Lesson Analytics
|
// 3. Per-Lesson Analytics
|
||||||
// Note: We cast AVG to float4 for PostgreSQL compatibility
|
// Note: We cast AVG to float4 for PostgreSQL compatibility
|
||||||
@@ -1407,7 +1408,7 @@ pub async fn get_course_analytics(
|
|||||||
.bind(filter.cohort_id)
|
.bind(filter.cohort_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let lessons = rows
|
let lessons = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1529,7 +1530,7 @@ pub async fn get_advanced_analytics(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Cohort query failed: {}", e);
|
tracing::error!("Cohort query failed: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -1542,7 +1543,7 @@ pub async fn get_advanced_analytics(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Retention query failed: {}", e);
|
tracing::error!("Retention query failed: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -1572,7 +1573,7 @@ pub async fn record_interaction(
|
|||||||
.bind(payload.metadata)
|
.bind(payload.metadata)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to record interaction: {}", e);
|
tracing::error!("Failed to record interaction: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -1596,7 +1597,7 @@ pub async fn get_lesson_heatmap(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to fetch heatmap: {}", e);
|
tracing::error!("Failed to fetch heatmap: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -1616,7 +1617,7 @@ pub async fn get_notifications(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to fetch notifications: {}", e);
|
tracing::error!("Failed to fetch notifications: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -1638,7 +1639,7 @@ pub async fn mark_notification_as_read(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Failed to mark notification as read: {}", e);
|
tracing::error!("Failed to mark notification as read: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
@@ -1702,7 +1703,7 @@ pub async fn toggle_bookmark(
|
|||||||
.bind(lesson_id)
|
.bind(lesson_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if let Some(id) = existing_id {
|
if let Some(id) = existing_id {
|
||||||
// Remove bookmark
|
// Remove bookmark
|
||||||
@@ -1710,7 +1711,7 @@ pub async fn toggle_bookmark(
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
} else {
|
} else {
|
||||||
// Add bookmark
|
// Add bookmark
|
||||||
@@ -1723,7 +1724,7 @@ pub async fn toggle_bookmark(
|
|||||||
.bind(lesson_id)
|
.bind(lesson_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
Ok(StatusCode::CREATED)
|
Ok(StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1745,7 +1746,7 @@ pub async fn get_user_bookmarks(
|
|||||||
// Wait, let's create a better filter for this.
|
// Wait, let's create a better filter for this.
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(bookmarks))
|
Ok(Json(bookmarks))
|
||||||
}
|
}
|
||||||
@@ -1777,7 +1778,7 @@ pub async fn update_user(
|
|||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(UserResponse {
|
Ok(Json(UserResponse {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -1809,7 +1810,7 @@ pub async fn get_recommendations(
|
|||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 2. Fetch lesson metadata (titles and tags) for context
|
// 2. Fetch lesson metadata (titles and tags) for context
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub async fn submit_assignment(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if let Some(_) = existing {
|
if let Some(_) = existing {
|
||||||
// Update existing submission
|
// Update existing submission
|
||||||
@@ -44,7 +44,7 @@ pub async fn submit_assignment(
|
|||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
return Ok(Json(updated));
|
return Ok(Json(updated));
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ pub async fn submit_assignment(
|
|||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(submission))
|
Ok(Json(submission))
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ pub async fn get_peer_review_assignment(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(submission))
|
Ok(Json(submission))
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ pub async fn submit_peer_review(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let submission = match submission {
|
let submission = match submission {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
@@ -147,7 +147,7 @@ pub async fn submit_peer_review(
|
|||||||
)
|
)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if existing.is_some() {
|
if existing.is_some() {
|
||||||
return Err((
|
return Err((
|
||||||
@@ -172,7 +172,7 @@ pub async fn submit_peer_review(
|
|||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(review))
|
Ok(Json(review))
|
||||||
}
|
}
|
||||||
@@ -197,7 +197,7 @@ pub async fn get_my_submission_feedback(
|
|||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(reviews))
|
Ok(Json(reviews))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use jsonwebtoken::jwk::{JwkSet, Jwk, CommonParameters, RSAKeyParameters, AlgorithmParameters};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
pub fn get_lti_private_key() -> jsonwebtoken::EncodingKey {
|
||||||
|
let key_str = env::var("LTI_PRIVATE_KEY").unwrap_or_else(|_| {
|
||||||
|
// Fallback for development (DO NOT USE IN PRODUCTION)
|
||||||
|
include_str!("../dev_keys/lti_private.pem").to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Invalid LTI private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_lti_jwks() -> JwkSet {
|
||||||
|
let n = env::var("LTI_JWK_N").unwrap_or_else(|_| {
|
||||||
|
"weIdo6QklIJW77oEAd0NvX_L1e6mFRpHbSrhWjEJTfQDzLdNV84zPfu-rP-IJdWlvrtO2F_dHHah0ilNRZCaAwPXNqS6L57OrYJjxeDXKWnnfaVw4uUT1aDGFcXQ55Bbf05-N28aj26NEXh9WQVqO6L8XRrleRUgJtb8MBAWovxKi3CBJ_lFVYe31cPeAOCaEF_xzeMVEmJt3fbSewsUIrB7jD8F3YOcu8h_QGAc9tn9uxMfBJv2XZoGHCtMQUGG07iZtoSKBYGrWf5rBc7PsCF_VuQzlO9cf13jgQ2rcfcU3LwC_gp4A9RYnv_ymaHELz0kALKBtBxj1XU7QdLrsw".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let jwk = Jwk {
|
||||||
|
common: CommonParameters {
|
||||||
|
public_key_use: Some(jsonwebtoken::jwk::PublicKeyUse::Signature),
|
||||||
|
key_operations: None,
|
||||||
|
key_algorithm: Some(jsonwebtoken::jwk::KeyAlgorithm::RS256),
|
||||||
|
key_id: Some("openccb-lti-key-1".to_string()),
|
||||||
|
x509_url: None,
|
||||||
|
x509_chain: None,
|
||||||
|
x509_sha1_fingerprint: None,
|
||||||
|
x509_sha256_fingerprint: None,
|
||||||
|
},
|
||||||
|
algorithm: AlgorithmParameters::RSA(RSAKeyParameters {
|
||||||
|
key_type: jsonwebtoken::jwk::RSAKeyType::RSA,
|
||||||
|
n,
|
||||||
|
e: "AQAB".to_string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
JwkSet { keys: vec![jwk] }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lti_jwks_handler() -> axum::Json<serde_json::Value> {
|
||||||
|
let jwks = get_lti_jwks();
|
||||||
|
axum::Json(json!(jwks))
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use common::auth::Claims;
|
||||||
|
use common::models::Meeting;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateMeetingPayload {
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub start_at: chrono::DateTime<Utc>,
|
||||||
|
pub duration_minutes: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_course_meetings(
|
||||||
|
Path(course_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
) -> Result<Json<Vec<Meeting>>, (StatusCode, String)> {
|
||||||
|
let meetings = sqlx::query_as::<sqlx::Postgres, Meeting>(
|
||||||
|
"SELECT * FROM meetings WHERE course_id = $1 AND organization_id = $2 ORDER BY start_at ASC"
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(claims.org)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(meetings))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_meeting(
|
||||||
|
Path(course_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Json(payload): Json<CreateMeetingPayload>,
|
||||||
|
) -> Result<Json<Meeting>, (StatusCode, String)> {
|
||||||
|
if claims.role == "student" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only instructors can create meetings".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let meeting_id = format!("openccb-{}", Uuid::new_v4());
|
||||||
|
let join_url = format!("https://meet.jit.si/{}", meeting_id);
|
||||||
|
|
||||||
|
let meeting = sqlx::query_as::<sqlx::Postgres, Meeting>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO meetings (organization_id, course_id, title, description, provider, meeting_id, start_at, duration_minutes, join_url)
|
||||||
|
VALUES ($1, $2, $3, $4, 'jitsi', $5, $6, $7, $8)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(claims.org)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(payload.title)
|
||||||
|
.bind(payload.description)
|
||||||
|
.bind(meeting_id)
|
||||||
|
.bind(payload.start_at)
|
||||||
|
.bind(payload.duration_minutes)
|
||||||
|
.bind(join_url)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(meeting))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_meeting(
|
||||||
|
Path((_course_id, meeting_id)): Path<(Uuid, Uuid)>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
if claims.role == "student" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only instructors can delete meetings".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM meetings WHERE id = $1 AND organization_id = $2")
|
||||||
|
.bind(meeting_id)
|
||||||
|
.bind(claims.org)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -205,51 +205,108 @@ pub async fn lti_launch(
|
|||||||
|
|
||||||
let user = user.unwrap();
|
let user = user.unwrap();
|
||||||
|
|
||||||
// 6. Map resource link to course
|
// 8. Redirect based on message type
|
||||||
let resource_link = sqlx::query_as::<_, LtiResourceLink>(
|
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||||
"SELECT * FROM lti_resource_links WHERE organization_id = $1 AND resource_link_id = $2"
|
let studio_url = std::env::var("NEXT_PUBLIC_STUDIO_URL").unwrap_or_else(|_| "http://localhost:3001".to_string());
|
||||||
)
|
|
||||||
.bind(registration.organization_id)
|
|
||||||
.bind(<i_claims.resource_link.id)
|
|
||||||
.fetch_optional(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let redirect_target = if let Some(link) = resource_link {
|
let token = common::auth::create_jwt(user.id, user.organization_id, &user.role)
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create token: {}", e)))?;
|
||||||
|
let redirect_target = lti_claims.resource_link.as_ref().map(|rl| rl.id.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
if lti_claims.message_type == "LtiDeepLinkingRequest" {
|
||||||
|
let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Missing deep_linking_settings".to_string()))?;
|
||||||
|
|
||||||
|
let dl_request_id = Uuid::new_v4();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO enrollments (user_id, organization_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING"
|
"INSERT INTO lti_deep_linking_requests (id, registration_id, deployment_id, return_url, data) VALUES ($1, $2, $3, $4, $5)"
|
||||||
)
|
)
|
||||||
.bind(user.id)
|
.bind(dl_request_id)
|
||||||
.bind(registration.organization_id)
|
.bind(registration.id)
|
||||||
.bind(link.course_id)
|
.bind(<i_claims.deployment_id)
|
||||||
|
.bind(&settings.deep_link_return_url)
|
||||||
|
.bind(&settings.data)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
format!("/courses/{}", link.course_id)
|
Ok(Redirect::to(&format!("{}/lti/deep-linking?token={}&dl_token={}", studio_url, token, dl_request_id)))
|
||||||
} else {
|
} else {
|
||||||
"/dashboard".to_string()
|
Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LtiDeepLinkingResponsePayload {
|
||||||
|
pub dl_token: String,
|
||||||
|
pub items: Vec<common::models::LtiDeepLinkingContentItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lti_deep_linking_response(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Json(payload): Json<LtiDeepLinkingResponsePayload>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
// 1. Retrieve and delete DL request
|
||||||
|
let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid DL token".to_string()))?;
|
||||||
|
|
||||||
|
let dl_request = sqlx::query(
|
||||||
|
"DELETE FROM lti_deep_linking_requests WHERE id = $1 RETURNING registration_id, deployment_id, return_url, data"
|
||||||
|
)
|
||||||
|
.bind(dl_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Invalid or expired DL request".to_string()))?;
|
||||||
|
|
||||||
|
// Manual mapping since we can't use query!/query_as! easily for RETURNING without a struct
|
||||||
|
let registration_id: Uuid = dl_request.get("registration_id");
|
||||||
|
let deployment_id: String = dl_request.get("deployment_id");
|
||||||
|
let _return_url: String = dl_request.get::<String, _>("return_url");
|
||||||
|
let dl_data: Option<String> = dl_request.get("data");
|
||||||
|
|
||||||
|
// 2. Find registration
|
||||||
|
let registration = sqlx::query_as::<_, LtiRegistration>(
|
||||||
|
"SELECT * FROM lti_registrations WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(registration_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let response_claims = common::models::LtiDeepLinkingResponseClaims {
|
||||||
|
issuer: registration.client_id,
|
||||||
|
subject: claims.sub.to_string(),
|
||||||
|
audience: registration.issuer,
|
||||||
|
expires_at: now + 3600,
|
||||||
|
issued_at: now,
|
||||||
|
nonce: Uuid::new_v4().to_string(),
|
||||||
|
message_type: "LtiDeepLinkingResponse".to_string(),
|
||||||
|
version: "1.3.0".to_string(),
|
||||||
|
deployment_id,
|
||||||
|
content_items: payload.items,
|
||||||
|
data: dl_data,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 7. Generate JWT
|
let private_key = crate::jwks::get_lti_private_key();
|
||||||
let claims = Claims {
|
let response_jwt = jsonwebtoken::encode(
|
||||||
sub: user.id,
|
&jsonwebtoken::Header {
|
||||||
role: user.role,
|
kid: Some("openccb-lti-key-1".to_string()),
|
||||||
org: user.organization_id,
|
alg: jsonwebtoken::Algorithm::RS256,
|
||||||
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp(),
|
..Default::default()
|
||||||
course_id: None,
|
},
|
||||||
token_type: Some("access".to_string()),
|
&response_claims,
|
||||||
};
|
&private_key,
|
||||||
|
|
||||||
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
|
||||||
let token = jsonwebtoken::encode(
|
|
||||||
&jsonwebtoken::Header::default(),
|
|
||||||
&claims,
|
|
||||||
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
|
|
||||||
)
|
)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// 8. Redirect to Experience app launch page
|
Ok(Json(json!({
|
||||||
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
"jwt": response_jwt,
|
||||||
Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target))))
|
"return_url": dl_request.get::<String, _>("return_url")
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use sqlx::Row;
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ mod handlers_notes;
|
|||||||
mod handlers_payments;
|
mod handlers_payments;
|
||||||
mod handlers_peer_review;
|
mod handlers_peer_review;
|
||||||
mod lti;
|
mod lti;
|
||||||
|
mod jwks;
|
||||||
|
mod predictive;
|
||||||
|
mod live;
|
||||||
|
mod portfolio;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router, middleware,
|
Router, middleware,
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
|
use axum::Json; // Added based on instruction
|
||||||
use dotenvy::dotenv;
|
use dotenvy::dotenv;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -86,6 +91,17 @@ async fn main() {
|
|||||||
"/courses/{id}/recommendations",
|
"/courses/{id}/recommendations",
|
||||||
get(handlers::get_recommendations),
|
get(handlers::get_recommendations),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/courses/{id}/dropout-risks",
|
||||||
|
get(predictive::get_course_dropout_risks),
|
||||||
|
)
|
||||||
|
// Live Learning
|
||||||
|
.route("/courses/{id}/meetings", get(live::get_course_meetings).post(live::create_meeting))
|
||||||
|
.route("/courses/{id}/meetings/{meeting_id}", delete(live::delete_meeting))
|
||||||
|
// Portfolio & Badges
|
||||||
|
.route("/profile/{user_id}", get(portfolio::get_public_profile))
|
||||||
|
.route("/my/badges", get(portfolio::get_my_badges))
|
||||||
|
.route("/badges/award", post(portfolio::award_badge))
|
||||||
.route(
|
.route(
|
||||||
"/users/{id}/gamification",
|
"/users/{id}/gamification",
|
||||||
get(handlers::get_user_gamification),
|
get(handlers::get_user_gamification),
|
||||||
@@ -210,6 +226,8 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
.route("/lti/login", get(lti::lti_login_initiation))
|
.route("/lti/login", get(lti::lti_login_initiation))
|
||||||
.route("/lti/launch", post(lti::lti_launch))
|
.route("/lti/launch", post(lti::lti_launch))
|
||||||
|
.route("/lti/jwks", get(jwks::lti_jwks_handler))
|
||||||
|
.route("/lti/deep-linking/response", post(lti::lti_deep_linking_response))
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(pool);
|
.with_state(pool);
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use common::auth::Claims;
|
||||||
|
use common::models::{Badge, UserBadge, PublicProfile};
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn get_public_profile(
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<PublicProfile>, (StatusCode, String)> {
|
||||||
|
let user = sqlx::query("SELECT id, full_name, avatar_url, bio, level, xp, is_public_profile FROM users WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||||
|
|
||||||
|
let is_public: bool = user.get("is_public_profile");
|
||||||
|
if !is_public {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "This profile is private".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let badges = sqlx::query_as::<sqlx::Postgres, Badge>(
|
||||||
|
r#"
|
||||||
|
SELECT b.* FROM badges b
|
||||||
|
JOIN user_badges ub ON b.id = ub.badge_id
|
||||||
|
WHERE ub.user_id = $1
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let completed_courses: i64 = sqlx::query("SELECT COUNT(*) FROM enrollments WHERE user_id = $1 AND progress_percentage >= 100")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.get(0);
|
||||||
|
|
||||||
|
Ok(Json(PublicProfile {
|
||||||
|
user_id,
|
||||||
|
full_name: user.get("full_name"),
|
||||||
|
avatar_url: user.get("avatar_url"),
|
||||||
|
bio: user.get("bio"),
|
||||||
|
badges,
|
||||||
|
level: user.get("level"),
|
||||||
|
xp: user.get("xp"),
|
||||||
|
completed_courses_count: completed_courses,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_my_badges(
|
||||||
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<Badge>>, (StatusCode, String)> {
|
||||||
|
let badges = sqlx::query_as::<sqlx::Postgres, Badge>(
|
||||||
|
r#"
|
||||||
|
SELECT b.* FROM badges b
|
||||||
|
JOIN user_badges ub ON b.id = ub.badge_id
|
||||||
|
WHERE ub.user_id = $1
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(badges))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn award_badge(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
Json(payload): Json<UserBadge>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
if claims.role == "student" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only admins can award badges manually".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO user_badges (user_id, badge_id, awarded_at) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING")
|
||||||
|
.bind(payload.user_id)
|
||||||
|
.bind(payload.badge_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::CREATED)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::{Utc, Duration};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use common::auth::Claims;
|
||||||
|
use common::models::{DropoutRisk, DropoutRiskLevel, DropoutRiskReason};
|
||||||
|
|
||||||
|
pub async fn get_course_dropout_risks(
|
||||||
|
Path(course_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
claims: Claims,
|
||||||
|
) -> Result<Json<Vec<DropoutRisk>>, (StatusCode, String)> {
|
||||||
|
if claims.role == "student" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only instructors can view risk reports".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
calculate_risks_for_course(&pool, course_id, claims.org).await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, organization_id, course_id, user_id, risk_level, score, reasons, last_calculated_at, created_at, updated_at
|
||||||
|
FROM dropout_risks
|
||||||
|
WHERE course_id = $1 AND organization_id = $2
|
||||||
|
ORDER BY score DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(claims.org)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch risks: {}", e)))?;
|
||||||
|
|
||||||
|
let risks: Vec<DropoutRisk> = rows.into_iter().map(|row| {
|
||||||
|
DropoutRisk {
|
||||||
|
id: row.get("id"),
|
||||||
|
organization_id: row.get("organization_id"),
|
||||||
|
course_id: row.get("course_id"),
|
||||||
|
user_id: row.get("user_id"),
|
||||||
|
risk_level: row.get("risk_level"),
|
||||||
|
score: row.get("score"),
|
||||||
|
reasons: row.get("reasons"),
|
||||||
|
last_calculated_at: row.get("last_calculated_at"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(risks))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn calculate_risks_for_course(
|
||||||
|
pool: &PgPool,
|
||||||
|
course_id: Uuid,
|
||||||
|
organization_id: Uuid,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let enrollments = sqlx::query("SELECT user_id FROM enrollments WHERE course_id = $1 AND organization_id = $2")
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(organization_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for enrollment in enrollments {
|
||||||
|
let user_id: Uuid = enrollment.get("user_id");
|
||||||
|
|
||||||
|
let avg_grade: f32 = sqlx::query("SELECT COALESCE(AVG(score), 0.0) FROM user_grades WHERE user_id = $1 AND course_id = $2")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(course_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
.get::<f64, _>(0) as f32; // AVG returns f64 usually
|
||||||
|
|
||||||
|
let last_activity_count: i64 = sqlx::query("SELECT COUNT(*) FROM lesson_interactions WHERE user_id = $1 AND created_at > $2")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(Utc::now() - Duration::days(7))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
.get(0);
|
||||||
|
|
||||||
|
let forum_posts: i64 = sqlx::query("SELECT COUNT(*) FROM discussion_posts WHERE author_id = $1 AND organization_id = $2")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(organization_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
.get(0);
|
||||||
|
|
||||||
|
let perf_risk = (1.0 - avg_grade).max(0.0);
|
||||||
|
let activity_risk = (1.0 / (last_activity_count as f32 + 1.0)).min(1.0);
|
||||||
|
let social_risk = (1.0 / (forum_posts as f32 + 1.0)).min(1.0);
|
||||||
|
|
||||||
|
let total_score = (perf_risk * 0.5) + (activity_risk * 0.4) + (social_risk * 0.1);
|
||||||
|
|
||||||
|
let risk_level = if total_score > 0.8 {
|
||||||
|
DropoutRiskLevel::Critical
|
||||||
|
} else if total_score > 0.5 {
|
||||||
|
DropoutRiskLevel::High
|
||||||
|
} else if total_score > 0.3 {
|
||||||
|
DropoutRiskLevel::Medium
|
||||||
|
} else {
|
||||||
|
DropoutRiskLevel::Low
|
||||||
|
};
|
||||||
|
|
||||||
|
let reasons = vec![
|
||||||
|
DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Grade: {:.0}%", avg_grade * 100.0) },
|
||||||
|
DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} actions in last week", last_activity_count) },
|
||||||
|
];
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO dropout_risks (organization_id, course_id, user_id, risk_level, score, reasons, last_calculated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||||
|
ON CONFLICT (course_id, user_id) DO UPDATE SET
|
||||||
|
risk_level = EXCLUDED.risk_level,
|
||||||
|
score = EXCLUDED.score,
|
||||||
|
reasons = EXCLUDED.reasons,
|
||||||
|
last_calculated_at = EXCLUDED.last_calculated_at,
|
||||||
|
updated_at = NOW()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(organization_id)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(risk_level)
|
||||||
|
.bind(total_score)
|
||||||
|
.bind(json!(reasons))
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+146
-1
@@ -193,7 +193,9 @@ pub struct LtiLaunchClaims {
|
|||||||
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/deployment_id")]
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/deployment_id")]
|
||||||
pub deployment_id: String,
|
pub deployment_id: String,
|
||||||
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/resource_link")]
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/resource_link")]
|
||||||
pub resource_link: LtiResourceLinkClaim,
|
pub resource_link: Option<LtiResourceLinkClaim>,
|
||||||
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings")]
|
||||||
|
pub deep_linking_settings: Option<LtiDeepLinkingSettings>,
|
||||||
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/context")]
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/context")]
|
||||||
pub context: Option<LtiContextClaim>,
|
pub context: Option<LtiContextClaim>,
|
||||||
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/roles")]
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/roles")]
|
||||||
@@ -215,6 +217,65 @@ pub struct LtiContextClaim {
|
|||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct LtiDeepLinkingSettings {
|
||||||
|
pub deep_link_return_url: String,
|
||||||
|
pub accept_types: Vec<String>,
|
||||||
|
pub accept_presentation_document_targets: Vec<String>,
|
||||||
|
pub accept_media_types: Option<String>,
|
||||||
|
pub accept_multiple: Option<bool>,
|
||||||
|
pub accept_copy_advice: Option<bool>,
|
||||||
|
pub auto_create: Option<bool>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub text: Option<String>,
|
||||||
|
pub data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct LtiDeepLinkingResponseClaims {
|
||||||
|
#[serde(rename = "iss")]
|
||||||
|
pub issuer: String,
|
||||||
|
#[serde(rename = "sub")]
|
||||||
|
pub subject: String,
|
||||||
|
#[serde(rename = "aud")]
|
||||||
|
pub audience: String,
|
||||||
|
#[serde(rename = "exp")]
|
||||||
|
pub expires_at: i64,
|
||||||
|
#[serde(rename = "iat")]
|
||||||
|
pub issued_at: i64,
|
||||||
|
pub nonce: String,
|
||||||
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/message_type")]
|
||||||
|
pub message_type: String, // "LtiDeepLinkingResponse"
|
||||||
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/version")]
|
||||||
|
pub version: String, // "1.3.0"
|
||||||
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/deployment_id")]
|
||||||
|
pub deployment_id: String,
|
||||||
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti-dl/claim/content_items")]
|
||||||
|
pub content_items: Vec<LtiDeepLinkingContentItem>,
|
||||||
|
#[serde(rename = "https://purl.imsglobal.org/spec/lti-dl/claim/data")]
|
||||||
|
pub data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct LtiDeepLinkingContentItem {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: String, // "ltiResourceLink"
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub text: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub icon: Option<LtiImage>,
|
||||||
|
pub thumbnail: Option<LtiImage>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: serde_json::Map<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct LtiImage {
|
||||||
|
pub url: String,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
pub struct Asset {
|
pub struct Asset {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -642,6 +703,7 @@ pub struct SubmitAssignmentPayload {
|
|||||||
pub struct SubmitPeerReviewPayload {
|
pub struct SubmitPeerReviewPayload {
|
||||||
pub submission_id: Uuid,
|
pub submission_id: Uuid,
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
|
pub feedback: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content Libraries
|
// Content Libraries
|
||||||
@@ -690,6 +752,36 @@ pub struct LibraryTemplate {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, Copy, PartialEq)]
|
||||||
|
#[sqlx(type_name = "dropout_risk_level", rename_all = "lowercase")]
|
||||||
|
pub enum DropoutRiskLevel {
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct DropoutRisk {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub risk_level: DropoutRiskLevel,
|
||||||
|
pub score: f32, // 0.0 to 1.0 (Higher means higher risk)
|
||||||
|
pub reasons: Option<serde_json::Value>, // e.g., ["low_grades", "inactivity"]
|
||||||
|
pub last_calculated_at: DateTime<Utc>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct DropoutRiskReason {
|
||||||
|
pub metric: String,
|
||||||
|
pub value: f32,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -893,3 +985,56 @@ pub struct LessonDependency {
|
|||||||
pub min_score_percentage: Option<f64>,
|
pub min_score_percentage: Option<f64>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Live Learning (Meetings) ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct Meeting {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub provider: String, // "jitsi" | "bbb"
|
||||||
|
pub meeting_id: String, // Room name or external ID
|
||||||
|
pub start_at: DateTime<Utc>,
|
||||||
|
pub duration_minutes: i32,
|
||||||
|
pub join_url: Option<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Student portfolio & Badges ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct Badge {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub icon_url: String,
|
||||||
|
pub criteria: serde_json::Value,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct UserBadge {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub badge_id: Uuid,
|
||||||
|
pub awarded_at: DateTime<Utc>,
|
||||||
|
pub evidence_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct PublicProfile {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub full_name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub badges: Vec<Badge>,
|
||||||
|
pub level: i32,
|
||||||
|
pub xp: i32,
|
||||||
|
pub completed_courses_count: i64,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { lmsApi, Course, Module, Recommendation, UserGrade } from "@/lib/api";
|
import { lmsApi, Course, Module, Recommendation, UserGrade, Meeting } from "@/lib/api";
|
||||||
import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } from "lucide-react";
|
import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle, Video, ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react";
|
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
@@ -19,6 +19,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
const [isEnrolled, setIsEnrolled] = useState(false);
|
const [isEnrolled, setIsEnrolled] = useState(false);
|
||||||
const [lessonDependencies, setLessonDependencies] = useState<any[]>([]);
|
const [lessonDependencies, setLessonDependencies] = useState<any[]>([]);
|
||||||
const [instructors, setInstructors] = useState<any[]>([]);
|
const [instructors, setInstructors] = useState<any[]>([]);
|
||||||
|
const [meetings, setMeetings] = useState<Meeting[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -58,6 +59,10 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
.then(res => setRecommendations(res.recommendations))
|
.then(res => setRecommendations(res.recommendations))
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoadingAI(false));
|
.finally(() => setLoadingAI(false));
|
||||||
|
|
||||||
|
lmsApi.getMeetings(params.id)
|
||||||
|
.then(setMeetings)
|
||||||
|
.catch(console.error);
|
||||||
}, [params.id, user]);
|
}, [params.id, user]);
|
||||||
|
|
||||||
const handleEnrollOrBuy = async () => {
|
const handleEnrollOrBuy = async () => {
|
||||||
@@ -294,6 +299,47 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Live Sessions Section */}
|
||||||
|
{meetings.length > 0 && (
|
||||||
|
<div className="mb-20">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<div className="w-10 h-10 rounded-xl glass border-blue-500/20 bg-blue-500/10 flex items-center justify-center">
|
||||||
|
<Video size={18} className="text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">Sesiones en Vivo</h2>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Únete a las clases sincrónicas programadas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{meetings.map((m) => (
|
||||||
|
<div key={m.id} className="glass-card border-white/5 hover:border-blue-500/30 transition-all p-5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-400">
|
||||||
|
<Calendar size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm">{m.title}</h3>
|
||||||
|
<p className="text-[10px] text-gray-500 uppercase mt-1">
|
||||||
|
{new Date(m.start_at).toLocaleString()} • {m.duration_minutes} min
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={m.join_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-bold text-[10px] uppercase tracking-widest flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
Unirse <ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Announcements Section */}
|
{/* Announcements Section */}
|
||||||
<div className="mb-16">
|
<div className="mb-16">
|
||||||
<AnnouncementsList courseId={params.id} isInstructor={user?.role === 'instructor' || user?.role === 'admin'} />
|
<AnnouncementsList courseId={params.id} isInstructor={user?.role === 'instructor' || user?.role === 'admin'} />
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { lmsApi, PublicProfile } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
Award,
|
||||||
|
BookOpen,
|
||||||
|
Zap,
|
||||||
|
ShieldCheck,
|
||||||
|
Globe,
|
||||||
|
Linkedin,
|
||||||
|
Github,
|
||||||
|
UserCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function StudentPortfolioPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [profile, setProfile] = useState<PublicProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProfile = async () => {
|
||||||
|
try {
|
||||||
|
const data = await lmsApi.getPublicProfile(id as string);
|
||||||
|
setProfile(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to load profile");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (id) loadProfile();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading portfolio...</div>;
|
||||||
|
if (error) return <div className="min-h-screen flex items-center justify-center text-red-400">{error}</div>;
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#050505] text-white selection:bg-blue-500/30">
|
||||||
|
{/* Hero Section / Profile Header */}
|
||||||
|
<div className="relative h-64 bg-gradient-to-r from-blue-900/30 to-purple-900/30 border-b border-white/5">
|
||||||
|
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-20" />
|
||||||
|
<div className="max-w-5xl mx-auto px-6 h-full flex flex-col justify-end pb-8">
|
||||||
|
<div className="flex items-end gap-6">
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 to-purple-600 rounded-2xl blur opacity-30 group-hover:opacity-60 transition duration-1000"></div>
|
||||||
|
<div className="relative w-32 h-32 rounded-2xl bg-black border-2 border-white/10 overflow-hidden shadow-2xl">
|
||||||
|
{profile.avatar_url ? (
|
||||||
|
<img src={profile.avatar_url} alt={profile.full_name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-600 bg-gray-900">
|
||||||
|
<UserCircle size={64} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 pb-2">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">{profile.full_name}</h1>
|
||||||
|
<p className="text-gray-400 mt-1 flex items-center gap-2">
|
||||||
|
<Zap className="text-yellow-400" size={16} />
|
||||||
|
Level {profile.level} Apprentice • {profile.xp} XP
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pb-2">
|
||||||
|
<button className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all"><Linkedin size={20} /></button>
|
||||||
|
<button className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all"><Github size={20} /></button>
|
||||||
|
<button className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all"><Globe size={20} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-12 grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||||
|
{/* Left Column: Bio & Stats */}
|
||||||
|
<div className="md:col-span-1 space-y-8">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-bold text-gray-500 uppercase tracking-widest mb-4">About Me</h2>
|
||||||
|
<p className="text-gray-300 leading-relaxed italic">
|
||||||
|
{profile.bio || "No biography provided. This student is focused on mastering their craft."}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white/5 border border-white/10 rounded-3xl p-6 space-y-6">
|
||||||
|
<h2 className="text-sm font-bold text-gray-400 uppercase tracking-widest">Global Stats</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-3 text-gray-400">
|
||||||
|
<BookOpen size={18} className="text-blue-400" />
|
||||||
|
<span>Courses Finished</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-bold">{profile.completed_courses_count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-3 text-gray-400">
|
||||||
|
<Award size={18} className="text-purple-400" />
|
||||||
|
<span>Badges Earned</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-bold">{profile.badges.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-3 text-gray-400">
|
||||||
|
<Zap size={18} className="text-yellow-400" />
|
||||||
|
<span>Global Rank</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-bold">Top 5%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Badges & Showcase */}
|
||||||
|
<div className="md:col-span-2 space-y-12">
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<ShieldCheck className="text-blue-500" />
|
||||||
|
Credentials & Badges
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-gray-500 bg-white/5 px-3 py-1 rounded-full border border-white/10">VERIFIED BY OPENCCB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{profile.badges.map((badge: any) => (
|
||||||
|
<div key={badge.id} className="relative group cursor-pointer bg-white/5 border border-white/10 rounded-2xl p-6 text-center hover:bg-white/10 transition-all">
|
||||||
|
<div className="mx-auto w-16 h-16 mb-4 flex items-center justify-center bg-blue-500/10 rounded-2xl">
|
||||||
|
<img src={badge.icon_url} alt={badge.name} className="w-10 h-10 object-contain drop-shadow-lg" onError={(e) => {
|
||||||
|
const target = e.target as any;
|
||||||
|
target.src = "https://cdn-icons-png.flaticon.com/512/10636/10636665.png";
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-sm mb-1">{badge.name}</h3>
|
||||||
|
<p className="text-[10px] text-gray-500 uppercase tracking-tighter line-clamp-2 leading-tight">
|
||||||
|
{badge.description}
|
||||||
|
</p>
|
||||||
|
<div className="absolute inset-0 border-2 border-blue-500/50 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none shadow-[0_0_20px_rgba(59,130,246,0.3)]"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile.badges.length === 0 && (
|
||||||
|
<div className="text-center py-20 border-2 border-dashed border-white/5 rounded-3xl">
|
||||||
|
<p className="text-gray-600">This student hasn't collected any badges yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,11 @@ export default function AppHeader() {
|
|||||||
<Link href="/bookmarks" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">
|
<Link href="/bookmarks" className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400 hover:text-white transition-colors">
|
||||||
{t('nav.bookmarks')}
|
{t('nav.bookmarks')}
|
||||||
</Link>
|
</Link>
|
||||||
|
{user && (
|
||||||
|
<Link href={`/profile/${user.id}`} className="text-[10px] font-black uppercase tracking-[0.2em] text-blue-400 hover:text-blue-300 transition-colors">
|
||||||
|
MI PORTAFOLIO
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 md:gap-4">
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
@@ -127,6 +132,15 @@ export default function AppHeader() {
|
|||||||
>
|
>
|
||||||
{t('nav.bookmarks')}
|
{t('nav.bookmarks')}
|
||||||
</Link>
|
</Link>
|
||||||
|
{user && (
|
||||||
|
<Link
|
||||||
|
href={`/profile/${user.id}`}
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
className="text-sm font-black uppercase tracking-widest text-blue-400 hover:text-blue-300 border-l-2 border-transparent hover:border-blue-500 pl-4 transition-all"
|
||||||
|
>
|
||||||
|
MI PORTAFOLIO
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pt-6 mt-6 border-t border-white/5 space-y-4">
|
<div className="pt-6 mt-6 border-t border-white/5 space-y-4">
|
||||||
<div className="flex items-center gap-3 px-4 py-2 rounded-xl bg-white/5">
|
<div className="flex items-center gap-3 px-4 py-2 rounded-xl bg-white/5">
|
||||||
|
|||||||
@@ -232,6 +232,39 @@ export interface ProgressStats {
|
|||||||
estimated_completion_date?: string;
|
estimated_completion_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Meeting {
|
||||||
|
id: string;
|
||||||
|
course_id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
provider: string;
|
||||||
|
meeting_id: string;
|
||||||
|
start_at: string;
|
||||||
|
duration_minutes: number;
|
||||||
|
join_url?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Badge {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon_url: string;
|
||||||
|
criteria: any;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicProfile {
|
||||||
|
user_id: string;
|
||||||
|
full_name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
bio?: string;
|
||||||
|
level: number;
|
||||||
|
xp: number;
|
||||||
|
badges: Badge[];
|
||||||
|
completed_courses_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
user: User;
|
user: User;
|
||||||
token: string;
|
token: string;
|
||||||
@@ -706,5 +739,18 @@ export const lmsApi = {
|
|||||||
async getBookmarks(courseId?: string): Promise<UserBookmark[]> {
|
async getBookmarks(courseId?: string): Promise<UserBookmark[]> {
|
||||||
const query = courseId ? `?cohort_id=${courseId}` : '';
|
const query = courseId ? `?cohort_id=${courseId}` : '';
|
||||||
return apiFetch(`/bookmarks${query}`);
|
return apiFetch(`/bookmarks${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Live Learning & Portfolio
|
||||||
|
async getMeetings(courseId: string): Promise<Meeting[]> {
|
||||||
|
return apiFetch(`/courses/${courseId}/meetings`, {}, false);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPublicProfile(userId: string): Promise<PublicProfile> {
|
||||||
|
return apiFetch(`/profile/${userId}`, {}, false);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMyBadges(): Promise<Badge[]> {
|
||||||
|
return apiFetch(`/my/badges`, {}, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Layers
|
Layers,
|
||||||
|
ShieldAlert
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||||
|
import DropoutRiskDashboard from "@/components/Analytics/DropoutRiskDashboard";
|
||||||
|
import LiveSessions from "@/components/Courses/LiveSessions";
|
||||||
|
import { Video } from "lucide-react";
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
const { id } = useParams() as { id: string };
|
const { id } = useParams() as { id: string };
|
||||||
@@ -26,6 +30,7 @@ export default function AnalyticsPage() {
|
|||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [cohorts, setCohorts] = useState<Cohort[]>([]);
|
const [cohorts, setCohorts] = useState<Cohort[]>([]);
|
||||||
const [selectedCohortId, setSelectedCohortId] = useState<string>("");
|
const [selectedCohortId, setSelectedCohortId] = useState<string>("");
|
||||||
|
const [activeAnalyticsTab, setActiveAnalyticsTab] = useState<"overview" | "risks" | "live">("overview");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -129,7 +134,31 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CourseEditorLayout activeTab="analytics">
|
<CourseEditorLayout activeTab="analytics">
|
||||||
<div className="p-8 space-y-12">
|
<div className="p-8">
|
||||||
|
{/* Tab Selector */}
|
||||||
|
<div className="flex items-center gap-1 mb-10 p-1 bg-white/5 rounded-2xl w-fit">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveAnalyticsTab("overview")}
|
||||||
|
className={`px-6 py-2.5 rounded-xl text-sm font-bold transition-all flex items-center gap-2 ${activeAnalyticsTab === "overview" ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20' : 'text-gray-400 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<BarChart3 size={16} /> Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveAnalyticsTab("risks")}
|
||||||
|
className={`px-6 py-2.5 rounded-xl text-sm font-bold transition-all flex items-center gap-2 ${activeAnalyticsTab === "risks" ? 'bg-red-600 text-white shadow-lg shadow-red-500/20' : 'text-gray-400 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={16} /> Predictive Risks
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveAnalyticsTab("live")}
|
||||||
|
className={`px-6 py-2.5 rounded-xl text-sm font-bold transition-all flex items-center gap-2 ${activeAnalyticsTab === "live" ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20' : 'text-gray-400 hover:text-white hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<Video size={16} /> Live Sessions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeAnalyticsTab === "overview" ? (
|
||||||
|
<div className="space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 group hover:bg-white/[0.07] transition-all">
|
<div className="bg-white/5 border border-white/10 rounded-3xl p-8 group hover:bg-white/[0.07] transition-all">
|
||||||
@@ -238,6 +267,10 @@ export default function AnalyticsPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<DropoutRiskDashboard courseId={id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CourseEditorLayout>
|
</CourseEditorLayout>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, Suspense } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { cmsApi, lmsApi, Course, Module, Lesson, LtiDeepLinkingContentItem } from "@/lib/api";
|
||||||
|
import { Book, ChevronRight, Check, Search, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
function DeepLinkingPickerContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
|
||||||
|
const [selectedContent, setSelectedContent] = useState<{ type: 'course' | 'module' | 'lesson', id: string, title: string } | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
const dlToken = searchParams.get("dl_token");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem("studio_token", token);
|
||||||
|
}
|
||||||
|
loadCourses();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const loadCourses = async () => {
|
||||||
|
try {
|
||||||
|
const data = await cmsApi.getCourses();
|
||||||
|
setCourses(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load courses", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectCourse = async (course: Course) => {
|
||||||
|
setSelectedCourse(course);
|
||||||
|
setSelectedContent({ type: 'course', id: course.id, title: course.title });
|
||||||
|
// Fetch full outline to show modules/lessons
|
||||||
|
try {
|
||||||
|
const full = await cmsApi.getCourseWithFullOutline(course.id);
|
||||||
|
setSelectedCourse(full);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load course outline", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!selectedContent || !dlToken) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Transform selected content into LTI items
|
||||||
|
const item: LtiDeepLinkingContentItem = {
|
||||||
|
type: 'ltiResourceLink',
|
||||||
|
title: selectedContent.title,
|
||||||
|
url: `${window.location.origin.replace('3001', '3003')}/courses/${selectedCourse?.id}${selectedContent.type === 'lesson' ? `/lessons/${selectedContent.id}` : ''}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await lmsApi.getDeepLinkingResponse({
|
||||||
|
dl_token: dlToken,
|
||||||
|
items: [item]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a form and auto-post it to the return_url
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = response.return_url;
|
||||||
|
|
||||||
|
const jwtInput = document.createElement('input');
|
||||||
|
jwtInput.type = 'hidden';
|
||||||
|
jwtInput.name = 'JWT';
|
||||||
|
jwtInput.value = response.jwt;
|
||||||
|
form.appendChild(jwtInput);
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to generate DL response", err);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCourses = courses.filter(c => c.title.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-8 h-full flex flex-col">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-black text-white tracking-tighter mb-2">PICK CONTENT TO EMBED</h1>
|
||||||
|
<p className="text-gray-400">Select the course or specific lesson you want to link in your platform.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 flex-1 min-h-0">
|
||||||
|
{/* Right Column: Content Hierarchy */}
|
||||||
|
<div className="glass rounded-3xl border border-white/5 p-6 flex flex-col overflow-hidden">
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search courses..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-2xl py-3 pl-12 pr-4 text-white focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto pr-2 space-y-2">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-16 bg-white/5 rounded-2xl animate-pulse" />
|
||||||
|
))
|
||||||
|
) : filteredCourses.length > 0 ? (
|
||||||
|
filteredCourses.map(course => (
|
||||||
|
<button
|
||||||
|
key={course.id}
|
||||||
|
onClick={() => handleSelectCourse(course)}
|
||||||
|
className={`w-full text-left p-4 rounded-2xl transition-all flex items-center gap-4 group ${selectedCourse?.id === course.id ? 'bg-blue-600/20 border-blue-500/50' : 'hover:bg-white/5 border border-transparent'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${selectedCourse?.id === course.id ? 'bg-blue-600 text-white' : 'bg-white/10 text-gray-400 group-hover:bg-white/20'}`}>
|
||||||
|
<Book size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-bold text-white leading-tight">{course.title}</div>
|
||||||
|
<div className="text-sm text-gray-500">{course.pacing_mode}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={18} className={`transition-transform ${selectedCourse?.id === course.id ? 'rotate-90 text-blue-400' : 'text-gray-600'}`} />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-gray-500 italic">No courses found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Outline Picker */}
|
||||||
|
<div className="glass rounded-3xl border border-white/5 p-6 flex flex-col overflow-hidden">
|
||||||
|
{selectedCourse ? (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<h2 className="font-black text-xl text-white tracking-tight mb-4 flex items-center gap-2">
|
||||||
|
<Book size={18} className="text-blue-400" />
|
||||||
|
{selectedCourse.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto pr-2 space-y-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedContent({ type: 'course', id: selectedCourse.id, title: selectedCourse.title })}
|
||||||
|
className={`w-full text-left p-4 rounded-2xl border transition-all flex items-center justify-between ${selectedContent?.type === 'course' ? 'bg-blue-600/20 border-blue-500/50' : 'bg-white/5 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<span className="font-bold text-white">Full Course Link</span>
|
||||||
|
{selectedContent?.type === 'course' && <Check size={18} className="text-blue-400" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{selectedCourse.modules?.map(module => (
|
||||||
|
<div key={module.id} className="space-y-2">
|
||||||
|
<div className="text-xs font-black text-gray-500 tracking-widest uppercase px-2">{module.title}</div>
|
||||||
|
{module.lessons.map(lesson => (
|
||||||
|
<button
|
||||||
|
key={lesson.id}
|
||||||
|
onClick={() => setSelectedContent({ type: 'lesson', id: lesson.id, title: lesson.title })}
|
||||||
|
className={`w-full text-left p-3 pl-4 rounded-xl border transition-all flex items-center justify-between ${selectedContent?.id === lesson.id ? 'bg-blue-600/20 border-blue-500/50' : 'bg-white/5 border-transparent hover:border-white/10'}`}
|
||||||
|
>
|
||||||
|
<span className="text-gray-300 text-sm">{lesson.title}</span>
|
||||||
|
{selectedContent?.id === lesson.id && <Check size={16} className="text-blue-400" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selectedContent || submitting}
|
||||||
|
className="mt-6 w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-black py-4 rounded-2xl flex items-center justify-center gap-2 shadow-xl shadow-blue-500/20 transition-all border-b-4 border-blue-800 active:border-b-0 active:translate-y-1"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
EMBED THIS CONTENT
|
||||||
|
<ExternalLink size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center p-8">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center text-gray-500 mb-4">
|
||||||
|
<Book size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="text-white font-bold mb-2">No Course Selected</div>
|
||||||
|
<p className="text-gray-500 text-sm">Select a course from the left to browse modules and lessons.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeepLinkingPicker() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen bg-gray-950 flex items-center justify-center"><div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" /></div>}>
|
||||||
|
<DeepLinkingPickerContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { lmsApi, DropoutRisk } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
User,
|
||||||
|
Mail,
|
||||||
|
Calendar,
|
||||||
|
Activity,
|
||||||
|
Send,
|
||||||
|
ChevronRight,
|
||||||
|
Search
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface DropoutRiskDashboardProps {
|
||||||
|
courseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DropoutRiskDashboard({ courseId }: DropoutRiskDashboardProps) {
|
||||||
|
const [risks, setRisks] = useState<DropoutRisk[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRisks = async () => {
|
||||||
|
try {
|
||||||
|
const data = await lmsApi.getDropoutRisks(courseId);
|
||||||
|
setRisks(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch dropout risks", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRisks();
|
||||||
|
}, [courseId]);
|
||||||
|
|
||||||
|
const filteredRisks = risks.filter(r =>
|
||||||
|
(r.user_full_name || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(r.user_email || "").toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center text-gray-500">Calculating risk scores...</div>;
|
||||||
|
|
||||||
|
const getRiskColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'critical': return 'text-red-500 bg-red-500/10 border-red-500/20';
|
||||||
|
case 'high': return 'text-orange-500 bg-orange-500/10 border-orange-500/20';
|
||||||
|
case 'medium': return 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20';
|
||||||
|
default: return 'text-green-500 bg-green-500/10 border-green-500/20';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-black flex items-center gap-3">
|
||||||
|
<AlertCircle className="text-red-500" />
|
||||||
|
Dropout Risk Analysis
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">AI-powered detection based on grades, activity, and engagement.</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 w-4 h-4" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search student..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 w-full md:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredRisks.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{filteredRisks.map((risk) => (
|
||||||
|
<div key={risk.id} className="bg-white/5 border border-white/10 rounded-2xl p-6 hover:bg-white/[0.07] transition-all group">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-400 text-xl font-bold">
|
||||||
|
{risk.user_full_name?.[0] || <User />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg">{risk.user_full_name || "Unknown Student"}</h3>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500 mt-1">
|
||||||
|
<span className="flex items-center gap-1"><Mail size={12} /> {risk.user_email || "N/A"}</span>
|
||||||
|
<span className="flex items-center gap-1"><Calendar size={12} /> Last active: {new Date(risk.last_calculated_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className={`px-4 py-2 rounded-xl border text-xs font-black uppercase tracking-widest ${getRiskColor(risk.risk_level)}`}>
|
||||||
|
{risk.risk_level} Risk
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end min-w-[100px]">
|
||||||
|
<div className="text-sm font-bold text-gray-400">Score: {Math.round(risk.score * 100)}%</div>
|
||||||
|
<div className="w-24 h-1 bg-white/5 rounded-full mt-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-1000 ${risk.score > 0.8 ? 'bg-red-500' : risk.score > 0.5 ? 'bg-orange-500' : 'bg-green-500'}`}
|
||||||
|
style={{ width: `${risk.score * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="p-3 bg-blue-600/10 text-blue-400 rounded-xl hover:bg-blue-600 hover:text-white transition-all">
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{risk.reasons && risk.reasons.length > 0 && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-white/5 flex flex-wrap gap-3">
|
||||||
|
{risk.reasons.map((reason, _idx) => (
|
||||||
|
<div key={_idx} className="bg-white/5 rounded-lg px-3 py-1.5 text-[10px] font-bold text-gray-400 flex items-center gap-2">
|
||||||
|
<Activity size={10} className="text-blue-500" />
|
||||||
|
{reason.description}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-3xl p-20 text-center">
|
||||||
|
<User size={48} className="text-gray-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-bold text-gray-400">No students at risk</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Everyone seems to be doing great in this course!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { lmsApi, Meeting } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
Video,
|
||||||
|
Plus,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
|
AlertCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface LiveSessionsProps {
|
||||||
|
courseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveSessions({ courseId }: LiveSessionsProps) {
|
||||||
|
const [meetings, setMeetings] = useState<Meeting[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
start_at: "",
|
||||||
|
duration_minutes: 60
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMeetings();
|
||||||
|
}, [courseId]);
|
||||||
|
|
||||||
|
const loadMeetings = async () => {
|
||||||
|
try {
|
||||||
|
const data = await lmsApi.getMeetings(courseId);
|
||||||
|
setMeetings(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await lmsApi.createMeeting(courseId, {
|
||||||
|
...formData,
|
||||||
|
start_at: new Date(formData.start_at).toISOString()
|
||||||
|
} as any);
|
||||||
|
setShowForm(false);
|
||||||
|
loadMeetings();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to create meeting");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Delete this meeting?")) return;
|
||||||
|
try {
|
||||||
|
await lmsApi.deleteMeeting(courseId, id);
|
||||||
|
loadMeetings();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to delete");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center text-gray-500">Loading sessions...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Video className="text-blue-500" />
|
||||||
|
Virtual Classrooms
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">Schedule and manage your live Jitsi sessions.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Plus size={18} /> Schedule Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 animate-in slide-in-from-top-4">
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-bold text-gray-400">SESSION TITLE</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-2 focus:border-blue-500 outline-none"
|
||||||
|
placeholder="Weekly Sync Up"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={e => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-bold text-gray-400">START DATE & TIME</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="datetime-local"
|
||||||
|
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-2 focus:border-blue-500 outline-none"
|
||||||
|
value={formData.start_at}
|
||||||
|
onChange={e => setFormData({ ...formData, start_at: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-bold text-gray-400">DESCRIPTION (OPTIONAL)</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-black/20 border border-white/10 rounded-lg px-4 py-2 focus:border-blue-500 outline-none h-20"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 text-gray-400 hover:text-white">Cancel</button>
|
||||||
|
<button className="bg-blue-600 px-6 py-2 rounded-lg font-bold">Create Meeting</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{meetings.map(m => (
|
||||||
|
<div key={m.id} className="bg-white/5 border border-white/10 rounded-2xl p-5 hover:bg-white/[0.08] transition-all group">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center text-blue-400">
|
||||||
|
<Video size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg">{m.title}</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-400 mt-1">
|
||||||
|
<span className="flex items-center gap-1"><Calendar size={14} /> {new Date(m.start_at).toLocaleString()}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock size={14} /> {m.duration_minutes} min</span>
|
||||||
|
<span className="capitalize px-2 py-0.5 bg-white/5 rounded-full border border-white/10">{m.provider}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={m.join_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 px-4 py-2 rounded-xl text-sm font-bold transition-all"
|
||||||
|
>
|
||||||
|
Join Room <ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(m.id)}
|
||||||
|
className="p-2.5 text-red-400 hover:bg-red-500/10 rounded-xl transition-all opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{meetings.length === 0 && !loading && (
|
||||||
|
<div className="p-12 text-center border-2 border-dashed border-white/5 rounded-3xl">
|
||||||
|
<AlertCircle className="mx-auto text-gray-600 mb-2" />
|
||||||
|
<p className="text-gray-500">No sessions scheduled for this course.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -390,6 +390,19 @@ export interface AdvancedAnalytics {
|
|||||||
retention: RetentionData[];
|
retention: RetentionData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DropoutRisk {
|
||||||
|
id: string;
|
||||||
|
organization_id: string;
|
||||||
|
course_id: string;
|
||||||
|
user_id: string;
|
||||||
|
risk_level: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
score: number;
|
||||||
|
reasons?: { metric: string, value: number, description: string }[];
|
||||||
|
last_calculated_at: string;
|
||||||
|
user_full_name?: string; // Optional for UI display
|
||||||
|
user_email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Webhook {
|
export interface Webhook {
|
||||||
id: string;
|
id: string;
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
@@ -818,11 +831,7 @@ export const lmsApi = {
|
|||||||
return apiFetch(`/courses/${id}/grades${query}`, {}, true);
|
return apiFetch(`/courses/${id}/grades${query}`, {}, true);
|
||||||
},
|
},
|
||||||
exportGradesUrl: (courseId: string): string => {
|
exportGradesUrl: (courseId: string): string => {
|
||||||
const token = getToken();
|
// Since we are downloading via <a> tag...
|
||||||
// Since we are downloading via <a> tag, we might need a token in the query if headers are not possible,
|
|
||||||
// but let's assume the user is authenticated in the session or we use a temporary download link logic.
|
|
||||||
// For simplicity with standard anchor tags, we'll suggest using a blob fetch if headers are strictly required.
|
|
||||||
// However, standard API calls use headers.
|
|
||||||
return `${LMS_API_BASE_URL}/courses/${courseId}/export-grades`;
|
return `${LMS_API_BASE_URL}/courses/${courseId}/export-grades`;
|
||||||
},
|
},
|
||||||
bulkEnroll: (courseId: string, emails: string[]): Promise<BulkEnrollResponse> =>
|
bulkEnroll: (courseId: string, emails: string[]): Promise<BulkEnrollResponse> =>
|
||||||
@@ -846,8 +855,73 @@ export const lmsApi = {
|
|||||||
apiFetch(`/announcements/${announcementId}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
|
apiFetch(`/announcements/${announcementId}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
|
||||||
deleteAnnouncement: (announcementId: string): Promise<void> =>
|
deleteAnnouncement: (announcementId: string): Promise<void> =>
|
||||||
apiFetch(`/announcements/${announcementId}`, { method: 'DELETE' }, true),
|
apiFetch(`/announcements/${announcementId}`, { method: 'DELETE' }, true),
|
||||||
|
async getDeepLinkingResponse(payload: { dl_token: string, items: LtiDeepLinkingContentItem[] }): Promise<{ jwt: string, return_url: string }> {
|
||||||
|
return apiFetch('/lti/deep-linking/response', { method: 'POST', body: JSON.stringify(payload) }, true);
|
||||||
|
},
|
||||||
|
getDropoutRisks: (courseId: string): Promise<DropoutRisk[]> =>
|
||||||
|
apiFetch(`/courses/${courseId}/dropout-risks`, {}, true),
|
||||||
|
|
||||||
|
// Live Learning
|
||||||
|
getMeetings: (courseId: string): Promise<Meeting[]> =>
|
||||||
|
apiFetch(`/courses/${courseId}/meetings`, {}, true),
|
||||||
|
|
||||||
|
createMeeting: (courseId: string, payload: Partial<Meeting>): Promise<Meeting> =>
|
||||||
|
apiFetch(`/courses/${courseId}/meetings`, { method: 'POST', body: JSON.stringify(payload) }, true),
|
||||||
|
|
||||||
|
deleteMeeting: (courseId: string, meetingId: string): Promise<void> =>
|
||||||
|
apiFetch(`/courses/${courseId}/meetings/${meetingId}`, { method: 'DELETE' }, true),
|
||||||
|
|
||||||
|
// Portfolio & Badges
|
||||||
|
getPublicProfile: (userId: string): Promise<PublicProfile> =>
|
||||||
|
apiFetch(`/profile/${userId}`, {}, true),
|
||||||
|
|
||||||
|
getMyBadges: (): Promise<Badge[]> =>
|
||||||
|
apiFetch(`/my/badges`, {}, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface Meeting {
|
||||||
|
id: string;
|
||||||
|
course_id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
provider: string;
|
||||||
|
meeting_id: string;
|
||||||
|
start_at: string;
|
||||||
|
duration_minutes: number;
|
||||||
|
join_url?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Badge {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon_url: string;
|
||||||
|
criteria: any;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicProfile {
|
||||||
|
user_id: string;
|
||||||
|
full_name: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
bio?: string;
|
||||||
|
badges: Badge[];
|
||||||
|
level: number;
|
||||||
|
xp: number;
|
||||||
|
completed_courses_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LtiDeepLinkingContentItem {
|
||||||
|
type: 'ltiResourceLink';
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
url?: string;
|
||||||
|
icon?: { url: string; width?: number; height?: number };
|
||||||
|
thumbnail?: { url: string; width?: number; height?: number };
|
||||||
|
[key: string]: string | number | boolean | object | undefined | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackgroundTask {
|
export interface BackgroundTask {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user