From 04dbe057041688822ffb25fceccd53d82c1839e2 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 24 Feb 2026 09:37:16 -0300 Subject: [PATCH] feat: Implement LTI deep linking, live sessions, predictive analytics, and portfolios with associated UI and database migrations. --- README.md | 25 +- roadmap.md | 28 +-- ...acaa1127f01bf97ddf71342cf129c3a3275e1.json | 86 +++++++ ...916d9f84c28b87cb357178400ee7421cbe0bc.json | 22 ++ ...7509bf38d345a697b9b885ec276061baa1cba.json | 63 +++++ ...91c7c91067eea455146923e463036d61e92c7.json | 21 ++ ...9f17c80aa86fd3a10d12bef9879b9f25ae23a.json | 58 +++++ ...2408bddaa1cea968319f28d5e87011e2bf0b7.json | 14 ++ ...8271678069e981fa98749ff7ec26c358a139f.json | 15 ++ ...eb3a8236333ca2305b6ab852dcc90d232ac89.json | 14 ++ ...607996f77213b859e4f47e0a1d16cf4913fd0.json | 47 ++++ ...756e7bb3a1abb0f706d3be347659f485e71de.json | 71 ++++++ ...50919ec09dfabd2b8e5b5dd84aca3853669eb.json | 55 +++++ ...ee4bc236f2b3ebce7c788335cf0ca2daf8823.json | 71 ++++++ ...8f6d8a2f6e2cbabf55a6795b218338239d8ab.json | 71 ++++++ ...91bb46855eca27523952bbf655250039a7468.json | 71 ++++++ ...b65655efd76d54a5e6ac57093a05718af9882.json | 88 +++++++ ...b9e8ab224a8e10f2666e69ef212fb0c000ac0.json | 15 ++ ...d0128865826a7cc6130441c42e75b46c54dc9.json | 14 ++ ...dbf23b9054354f737f60c23d0b416944b6095.json | 23 ++ ...8c52d3a252c10f41d6be67d8696b046e4c18f.json | 73 ++++++ ...289e783b7479592d1c7e11dd677c99d9bb2d3.json | 62 +++++ ...2aefa34fd99209e228238d86e7d135f6b41e2.json | 16 ++ ...51a3582512a9265d02c2bcb05ddb80e7c6038.json | 15 ++ ...f4b658fbf419795901d565eaaa1e169d881e9.json | 23 ++ ...7a934a7852474ed2eb3ea7b26e4d14b8a4df0.json | 85 +++++++ ...03912f6ae1ef00a96fd345d89f22f218aaf1c.json | 15 ++ ...49e4176f7dbee191bbdff8876fc88a0e26436.json | 74 ++++++ ...f25c84a2cc2f5eb6b9c2db7562f75755ddc13.json | 63 +++++ ...6a11909ee9d29a2dc30a70123d07b28d12c11.json | 83 +++++++ ...29280aaee5c922c1f01420dc061336cb1c159.json | 15 ++ ...ad84a6da8d8d7020f049d67c98687e6961194.json | 15 ++ ...a835eb7e8377cf75c8dc0679ec5c2f9504e98.json | 62 +++++ ...d8c71a2462290c837d72e1def2af3f7a5fc48.json | 23 ++ ...320fb99006f6e5e66501a93520af637f8f1a5.json | 53 ++++ ...f5df87fbfbbb4acda6940b1fe57009517a6b7.json | 58 +++++ .../20260217000001_advanced_grading.sql | 20 +- .../20260218000001_lesson_dependencies.sql | 2 +- services/cms-service/src/handlers.rs | 2 +- services/cms-service/src/handlers_rubrics.rs | 44 ++-- ...546709658ff09ac8be7ad1c611039e55394da.json | 53 ++++ ...64a6cb07ab2123f6733eb9992ef29a0347a3f.json | 66 +++++ ...23dfbd6771c450e34d2f330a7cec4cad9e16e.json | 22 ++ ...50cdb1fc0520f59ab24095319c844100696a8.json | 22 ++ ...55f4336017efa585e1f93ac848b1fed4835a1.json | 67 ++++++ ...cddef4e580c62954847940e5fa0d1ad6a7fcf.json | 35 +++ ...a10faa651a934e8dfb14be29226b85c614cae.json | 52 ++++ ...dbe7952f027d6caf367ed0480b5f16274bd1f.json | 28 +++ ...ba784335db7d899c0601d216fc1703ff49d06.json | 23 ++ ...37ffa3f2718790d55d4b9143bacaa6219173e.json | 65 +++++ ...920a3c077a443579c6184f09b95f9d078f593.json | 68 ++++++ ...1c00bd5d73086e80b9e068010f29202b1f5a8.json | 34 +++ ...1754aaad514c9877a264449807338a1f539b8.json | 65 +++++ ...1fccb61f18ddd6bf8f9c90f368a296f346f4b.json | 68 ++++++ services/lms-service/dev_keys/lti_private.pem | 28 +++ services/lms-service/dev_keys/lti_public.pem | 9 + services/lms-service/dev_keys/modulus_b64.txt | 1 + services/lms-service/dev_keys/modulus_hex.txt | 1 + ...60218000002_lesson_dependencies_mirror.sql | 14 ++ .../20260226000000_lti_dl_requests.sql | 13 + .../20260226010000_dropout_risks.sql | 29 +++ .../20260226020000_live_learning.sql | 24 ++ .../migrations/20260226030000_portfolios.sql | 41 ++++ services/lms-service/src/handlers.rs | 99 ++++---- .../lms-service/src/handlers_peer_review.rs | 16 +- services/lms-service/src/jwks.rs | 43 ++++ services/lms-service/src/live.rs | 90 +++++++ services/lms-service/src/lti.rs | 125 +++++++--- services/lms-service/src/main.rs | 18 ++ services/lms-service/src/portfolio.rs | 94 ++++++++ services/lms-service/src/predictive.rs | 136 +++++++++++ shared/common/src/models.rs | 147 +++++++++++- web/experience/src/app/courses/[id]/page.tsx | 50 +++- web/experience/src/app/profile/[id]/page.tsx | 153 ++++++++++++ web/experience/src/components/AppHeader.tsx | 14 ++ web/experience/src/lib/api.ts | 46 ++++ .../src/app/courses/[id]/analytics/page.tsx | 227 ++++++++++-------- web/studio/src/app/lti/deep-linking/page.tsx | 209 ++++++++++++++++ .../Analytics/DropoutRiskDashboard.tsx | 138 +++++++++++ .../src/components/Courses/LiveSessions.tsx | 176 ++++++++++++++ web/studio/src/lib/api.ts | 84 ++++++- 81 files changed, 4119 insertions(+), 249 deletions(-) create mode 100644 services/cms-service/.sqlx/query-00e1fd1111ece41c0c1494cf92dacaa1127f01bf97ddf71342cf129c3a3275e1.json create mode 100644 services/cms-service/.sqlx/query-13d9a1d22c6a77705cd74ed6d05916d9f84c28b87cb357178400ee7421cbe0bc.json create mode 100644 services/cms-service/.sqlx/query-17b6ee6e225ecf0573f41c8017f7509bf38d345a697b9b885ec276061baa1cba.json create mode 100644 services/cms-service/.sqlx/query-255e2331ed0f3148bd14e1cc2e791c7c91067eea455146923e463036d61e92c7.json create mode 100644 services/cms-service/.sqlx/query-2893eec86b904d90f69b96766029f17c80aa86fd3a10d12bef9879b9f25ae23a.json create mode 100644 services/cms-service/.sqlx/query-2abb997f1ef644429883fbd0bd72408bddaa1cea968319f28d5e87011e2bf0b7.json create mode 100644 services/cms-service/.sqlx/query-3747c2088f23d32d110971afcdf8271678069e981fa98749ff7ec26c358a139f.json create mode 100644 services/cms-service/.sqlx/query-42dfabb9428c4d090242fc98e43eb3a8236333ca2305b6ab852dcc90d232ac89.json create mode 100644 services/cms-service/.sqlx/query-45c3ae8b43e4fe46aa4126986b7607996f77213b859e4f47e0a1d16cf4913fd0.json create mode 100644 services/cms-service/.sqlx/query-4687bb156a947f156e401c0d610756e7bb3a1abb0f706d3be347659f485e71de.json create mode 100644 services/cms-service/.sqlx/query-4e61a89bc2207eba7452c77aea850919ec09dfabd2b8e5b5dd84aca3853669eb.json create mode 100644 services/cms-service/.sqlx/query-521e61afe4ab4bf06305447d012ee4bc236f2b3ebce7c788335cf0ca2daf8823.json create mode 100644 services/cms-service/.sqlx/query-62c035c29d3b3c5a2fff84713668f6d8a2f6e2cbabf55a6795b218338239d8ab.json create mode 100644 services/cms-service/.sqlx/query-71a9bb3c9b3ba2c851c2dad049291bb46855eca27523952bbf655250039a7468.json create mode 100644 services/cms-service/.sqlx/query-7b3e62330c2b8c283aff253e568b65655efd76d54a5e6ac57093a05718af9882.json create mode 100644 services/cms-service/.sqlx/query-82e4c85bf3aaa45506e3245c3d7b9e8ab224a8e10f2666e69ef212fb0c000ac0.json create mode 100644 services/cms-service/.sqlx/query-834a48554bc7989975b42afbc40d0128865826a7cc6130441c42e75b46c54dc9.json create mode 100644 services/cms-service/.sqlx/query-85fbeda23c72b58439fdedac4c6dbf23b9054354f737f60c23d0b416944b6095.json create mode 100644 services/cms-service/.sqlx/query-8ce98992129f77432d24a5a8a458c52d3a252c10f41d6be67d8696b046e4c18f.json create mode 100644 services/cms-service/.sqlx/query-914bcec73c3c1399f4e743d3e89289e783b7479592d1c7e11dd677c99d9bb2d3.json create mode 100644 services/cms-service/.sqlx/query-95ddcf80ff28b2680ebdd9d8ba92aefa34fd99209e228238d86e7d135f6b41e2.json create mode 100644 services/cms-service/.sqlx/query-b42eb00367a1991125ff01fab2a51a3582512a9265d02c2bcb05ddb80e7c6038.json create mode 100644 services/cms-service/.sqlx/query-c2a37e2b0139c053b4c4eb88a2cf4b658fbf419795901d565eaaa1e169d881e9.json create mode 100644 services/cms-service/.sqlx/query-cc7467f5734e57f581fab98e7e37a934a7852474ed2eb3ea7b26e4d14b8a4df0.json create mode 100644 services/cms-service/.sqlx/query-d06f83e2b566ac9a49c63bdfcce03912f6ae1ef00a96fd345d89f22f218aaf1c.json create mode 100644 services/cms-service/.sqlx/query-ddc1f59ea8f744d2357944e68cf49e4176f7dbee191bbdff8876fc88a0e26436.json create mode 100644 services/cms-service/.sqlx/query-e26e27402806a7fa4d85433ed18f25c84a2cc2f5eb6b9c2db7562f75755ddc13.json create mode 100644 services/cms-service/.sqlx/query-e3065bc94b895c8ced3d16c97bd6a11909ee9d29a2dc30a70123d07b28d12c11.json create mode 100644 services/cms-service/.sqlx/query-e3b659588c9e818f6c89d030ae929280aaee5c922c1f01420dc061336cb1c159.json create mode 100644 services/cms-service/.sqlx/query-e57d5797051a54d5ad707edeabfad84a6da8d8d7020f049d67c98687e6961194.json create mode 100644 services/cms-service/.sqlx/query-e5ede144c8250e31ce63979c3f1a835eb7e8377cf75c8dc0679ec5c2f9504e98.json create mode 100644 services/cms-service/.sqlx/query-ed4f770f0bd31dc8dc731e73843d8c71a2462290c837d72e1def2af3f7a5fc48.json create mode 100644 services/cms-service/.sqlx/query-f531c2478ba9634cf935aa615c9320fb99006f6e5e66501a93520af637f8f1a5.json create mode 100644 services/cms-service/.sqlx/query-f7a592c933c658314ed5228da68f5df87fbfbbb4acda6940b1fe57009517a6b7.json create mode 100644 services/lms-service/.sqlx/query-17f05eb41a9b8c4fd37f1c47495546709658ff09ac8be7ad1c611039e55394da.json create mode 100644 services/lms-service/.sqlx/query-363c5ded702de620f7d55d9a28564a6cb07ab2123f6733eb9992ef29a0347a3f.json create mode 100644 services/lms-service/.sqlx/query-48092c69f6c0c66fc843d31c65123dfbd6771c450e34d2f330a7cec4cad9e16e.json create mode 100644 services/lms-service/.sqlx/query-5e0c0dd74a0fcb24eae0b69b46550cdb1fc0520f59ab24095319c844100696a8.json create mode 100644 services/lms-service/.sqlx/query-60ddd0622ba9bdb70c661f9b8f755f4336017efa585e1f93ac848b1fed4835a1.json create mode 100644 services/lms-service/.sqlx/query-6744490d98f0f7b1d753e89dfe2cddef4e580c62954847940e5fa0d1ad6a7fcf.json create mode 100644 services/lms-service/.sqlx/query-82038007a13b07cdb912619ddefa10faa651a934e8dfb14be29226b85c614cae.json create mode 100644 services/lms-service/.sqlx/query-af5539604d4c172890ce3f62de6dbe7952f027d6caf367ed0480b5f16274bd1f.json create mode 100644 services/lms-service/.sqlx/query-bba15398004acf73d991751221eba784335db7d899c0601d216fc1703ff49d06.json create mode 100644 services/lms-service/.sqlx/query-bd51fbc3e8b3746722d88201f1937ffa3f2718790d55d4b9143bacaa6219173e.json create mode 100644 services/lms-service/.sqlx/query-bf3c31d22790fe0eeec0234f97f920a3c077a443579c6184f09b95f9d078f593.json create mode 100644 services/lms-service/.sqlx/query-c1778b5abe6d4e799993ac6145d1c00bd5d73086e80b9e068010f29202b1f5a8.json create mode 100644 services/lms-service/.sqlx/query-da54efaca96ea7a75198417d4ce1754aaad514c9877a264449807338a1f539b8.json create mode 100644 services/lms-service/.sqlx/query-e0cf43306025c312338d7f507911fccb61f18ddd6bf8f9c90f368a296f346f4b.json create mode 100644 services/lms-service/dev_keys/lti_private.pem create mode 100644 services/lms-service/dev_keys/lti_public.pem create mode 100644 services/lms-service/dev_keys/modulus_b64.txt create mode 100644 services/lms-service/dev_keys/modulus_hex.txt create mode 100644 services/lms-service/migrations/20260218000002_lesson_dependencies_mirror.sql create mode 100644 services/lms-service/migrations/20260226000000_lti_dl_requests.sql create mode 100644 services/lms-service/migrations/20260226010000_dropout_risks.sql create mode 100644 services/lms-service/migrations/20260226020000_live_learning.sql create mode 100644 services/lms-service/migrations/20260226030000_portfolios.sql create mode 100644 services/lms-service/src/jwks.rs create mode 100644 services/lms-service/src/live.rs create mode 100644 services/lms-service/src/portfolio.rs create mode 100644 services/lms-service/src/predictive.rs create mode 100644 web/experience/src/app/profile/[id]/page.tsx create mode 100644 web/studio/src/app/lti/deep-linking/page.tsx create mode 100644 web/studio/src/components/Analytics/DropoutRiskDashboard.tsx create mode 100644 web/studio/src/components/Courses/LiveSessions.tsx diff --git a/README.md b/README.md index a86825e..dd5ef25 100644 --- a/README.md +++ b/README.md @@ -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. - **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. -- **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 @@ -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. - **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. -- **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 # Registrar un nuevo administrador y empresa @@ -241,10 +247,10 @@ Agrega contenido multimedia o evaluaciones a un módulo. } ``` -#### POST /assets -Sube un archivo multimedia o documento al servidor y devuelve sus metadatos. +#### POST /assets/upload +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 ):** - `file`: Archivo binario (PDF, Video, Imagen, Docx). - **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. - **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) @@ -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. - **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. +- **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 Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio. \ No newline at end of file diff --git a/roadmap.md b/roadmap.md index 2848406..6034645 100644 --- a/roadmap.md +++ b/roadmap.md @@ -201,25 +201,25 @@ - [x] Inscripción automática tras pago exitoso. - [x] Verificación de seguridad de acceso a lecciones basada en inscripción. - [x] Dashboard de transacciones básico en base de datos. -- [ ] **Interoperabilidad**: - - [ ] Implementación de LTI 1.3 (Tool Provider). - - [ ] Conectividad con LMS externos (Moodle/Canvas). -- [ ] **Analíticas Predictivas**: - - [ ] Motor de IA para detección de riesgo de abandono. - - [ ] Notificaciones proactivas para instructores. -- [ ] **Gestión de Activos**: - - [ ] Biblioteca de medios global (Global Asset Manager). - - [ ] Reutilización de recursos multi-curso. +- [x] **Interoperabilidad**: ✅ + - [x] Implementación de LTI 1.3 (Tool Provider) con soporte para Deep Linking. + - [x] Conectividad segura con LMS externos (Moodle/Canvas) via OIDC y JWKS. +- [x] **Analíticas Predictivas**: ✅ + - [x] Motor de IA para detección de riesgo de abandono. + - [x] Notificaciones proactivas para instructores. +- [x] **Gestión de Activos**: ✅ + - [x] Biblioteca de medios global (Global Asset Manager). + - [x] Reutilización de recursos multi-curso. - [ ] **Aprendizaje en Vivo**: - - [ ] Integración con BigBlueButton/Jitsi. + - [ ] Integración con BigBlueButton. - [ ] **Portafolio del Estudiante**: - [ ] 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**: -1. **Interoperabilidad**: Implementación de LTI 1.3 para conectividad con otros LMS. -2. **Apps Móviles**: Desarrollo de versiones nativas para iOS y Android. -3. **Analíticas Predictivas**: Motor de IA para detección de riesgo de abandono. +1. **Apps Móviles**: Desarrollo de versiones nativas para iOS y Android. +2. **Aprendizaje en Vivo**: Integración con plataformas de videoconferencia (BigBlueButton/Jitsi). +3. **Portafolio del Estudiante**: Perfiles profesionales públicos y Open Badges. diff --git a/services/cms-service/.sqlx/query-00e1fd1111ece41c0c1494cf92dacaa1127f01bf97ddf71342cf129c3a3275e1.json b/services/cms-service/.sqlx/query-00e1fd1111ece41c0c1494cf92dacaa1127f01bf97ddf71342cf129c3a3275e1.json new file mode 100644 index 0000000..904544f --- /dev/null +++ b/services/cms-service/.sqlx/query-00e1fd1111ece41c0c1494cf92dacaa1127f01bf97ddf71342cf129c3a3275e1.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-13d9a1d22c6a77705cd74ed6d05916d9f84c28b87cb357178400ee7421cbe0bc.json b/services/cms-service/.sqlx/query-13d9a1d22c6a77705cd74ed6d05916d9f84c28b87cb357178400ee7421cbe0bc.json new file mode 100644 index 0000000..7471c47 --- /dev/null +++ b/services/cms-service/.sqlx/query-13d9a1d22c6a77705cd74ed6d05916d9f84c28b87cb357178400ee7421cbe0bc.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-17b6ee6e225ecf0573f41c8017f7509bf38d345a697b9b885ec276061baa1cba.json b/services/cms-service/.sqlx/query-17b6ee6e225ecf0573f41c8017f7509bf38d345a697b9b885ec276061baa1cba.json new file mode 100644 index 0000000..e91f0e8 --- /dev/null +++ b/services/cms-service/.sqlx/query-17b6ee6e225ecf0573f41c8017f7509bf38d345a697b9b885ec276061baa1cba.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-255e2331ed0f3148bd14e1cc2e791c7c91067eea455146923e463036d61e92c7.json b/services/cms-service/.sqlx/query-255e2331ed0f3148bd14e1cc2e791c7c91067eea455146923e463036d61e92c7.json new file mode 100644 index 0000000..cf199f2 --- /dev/null +++ b/services/cms-service/.sqlx/query-255e2331ed0f3148bd14e1cc2e791c7c91067eea455146923e463036d61e92c7.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-2893eec86b904d90f69b96766029f17c80aa86fd3a10d12bef9879b9f25ae23a.json b/services/cms-service/.sqlx/query-2893eec86b904d90f69b96766029f17c80aa86fd3a10d12bef9879b9f25ae23a.json new file mode 100644 index 0000000..de1d597 --- /dev/null +++ b/services/cms-service/.sqlx/query-2893eec86b904d90f69b96766029f17c80aa86fd3a10d12bef9879b9f25ae23a.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-2abb997f1ef644429883fbd0bd72408bddaa1cea968319f28d5e87011e2bf0b7.json b/services/cms-service/.sqlx/query-2abb997f1ef644429883fbd0bd72408bddaa1cea968319f28d5e87011e2bf0b7.json new file mode 100644 index 0000000..ad23417 --- /dev/null +++ b/services/cms-service/.sqlx/query-2abb997f1ef644429883fbd0bd72408bddaa1cea968319f28d5e87011e2bf0b7.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-3747c2088f23d32d110971afcdf8271678069e981fa98749ff7ec26c358a139f.json b/services/cms-service/.sqlx/query-3747c2088f23d32d110971afcdf8271678069e981fa98749ff7ec26c358a139f.json new file mode 100644 index 0000000..c49b49d --- /dev/null +++ b/services/cms-service/.sqlx/query-3747c2088f23d32d110971afcdf8271678069e981fa98749ff7ec26c358a139f.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-42dfabb9428c4d090242fc98e43eb3a8236333ca2305b6ab852dcc90d232ac89.json b/services/cms-service/.sqlx/query-42dfabb9428c4d090242fc98e43eb3a8236333ca2305b6ab852dcc90d232ac89.json new file mode 100644 index 0000000..c4c2e47 --- /dev/null +++ b/services/cms-service/.sqlx/query-42dfabb9428c4d090242fc98e43eb3a8236333ca2305b6ab852dcc90d232ac89.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-45c3ae8b43e4fe46aa4126986b7607996f77213b859e4f47e0a1d16cf4913fd0.json b/services/cms-service/.sqlx/query-45c3ae8b43e4fe46aa4126986b7607996f77213b859e4f47e0a1d16cf4913fd0.json new file mode 100644 index 0000000..fe143ac --- /dev/null +++ b/services/cms-service/.sqlx/query-45c3ae8b43e4fe46aa4126986b7607996f77213b859e4f47e0a1d16cf4913fd0.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-4687bb156a947f156e401c0d610756e7bb3a1abb0f706d3be347659f485e71de.json b/services/cms-service/.sqlx/query-4687bb156a947f156e401c0d610756e7bb3a1abb0f706d3be347659f485e71de.json new file mode 100644 index 0000000..ad080e6 --- /dev/null +++ b/services/cms-service/.sqlx/query-4687bb156a947f156e401c0d610756e7bb3a1abb0f706d3be347659f485e71de.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-4e61a89bc2207eba7452c77aea850919ec09dfabd2b8e5b5dd84aca3853669eb.json b/services/cms-service/.sqlx/query-4e61a89bc2207eba7452c77aea850919ec09dfabd2b8e5b5dd84aca3853669eb.json new file mode 100644 index 0000000..4444f81 --- /dev/null +++ b/services/cms-service/.sqlx/query-4e61a89bc2207eba7452c77aea850919ec09dfabd2b8e5b5dd84aca3853669eb.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-521e61afe4ab4bf06305447d012ee4bc236f2b3ebce7c788335cf0ca2daf8823.json b/services/cms-service/.sqlx/query-521e61afe4ab4bf06305447d012ee4bc236f2b3ebce7c788335cf0ca2daf8823.json new file mode 100644 index 0000000..c60c402 --- /dev/null +++ b/services/cms-service/.sqlx/query-521e61afe4ab4bf06305447d012ee4bc236f2b3ebce7c788335cf0ca2daf8823.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-62c035c29d3b3c5a2fff84713668f6d8a2f6e2cbabf55a6795b218338239d8ab.json b/services/cms-service/.sqlx/query-62c035c29d3b3c5a2fff84713668f6d8a2f6e2cbabf55a6795b218338239d8ab.json new file mode 100644 index 0000000..dd4dc5e --- /dev/null +++ b/services/cms-service/.sqlx/query-62c035c29d3b3c5a2fff84713668f6d8a2f6e2cbabf55a6795b218338239d8ab.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-71a9bb3c9b3ba2c851c2dad049291bb46855eca27523952bbf655250039a7468.json b/services/cms-service/.sqlx/query-71a9bb3c9b3ba2c851c2dad049291bb46855eca27523952bbf655250039a7468.json new file mode 100644 index 0000000..496da3d --- /dev/null +++ b/services/cms-service/.sqlx/query-71a9bb3c9b3ba2c851c2dad049291bb46855eca27523952bbf655250039a7468.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-7b3e62330c2b8c283aff253e568b65655efd76d54a5e6ac57093a05718af9882.json b/services/cms-service/.sqlx/query-7b3e62330c2b8c283aff253e568b65655efd76d54a5e6ac57093a05718af9882.json new file mode 100644 index 0000000..c9b97de --- /dev/null +++ b/services/cms-service/.sqlx/query-7b3e62330c2b8c283aff253e568b65655efd76d54a5e6ac57093a05718af9882.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-82e4c85bf3aaa45506e3245c3d7b9e8ab224a8e10f2666e69ef212fb0c000ac0.json b/services/cms-service/.sqlx/query-82e4c85bf3aaa45506e3245c3d7b9e8ab224a8e10f2666e69ef212fb0c000ac0.json new file mode 100644 index 0000000..81d9e5a --- /dev/null +++ b/services/cms-service/.sqlx/query-82e4c85bf3aaa45506e3245c3d7b9e8ab224a8e10f2666e69ef212fb0c000ac0.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-834a48554bc7989975b42afbc40d0128865826a7cc6130441c42e75b46c54dc9.json b/services/cms-service/.sqlx/query-834a48554bc7989975b42afbc40d0128865826a7cc6130441c42e75b46c54dc9.json new file mode 100644 index 0000000..1a90df8 --- /dev/null +++ b/services/cms-service/.sqlx/query-834a48554bc7989975b42afbc40d0128865826a7cc6130441c42e75b46c54dc9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM assets WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "834a48554bc7989975b42afbc40d0128865826a7cc6130441c42e75b46c54dc9" +} diff --git a/services/cms-service/.sqlx/query-85fbeda23c72b58439fdedac4c6dbf23b9054354f737f60c23d0b416944b6095.json b/services/cms-service/.sqlx/query-85fbeda23c72b58439fdedac4c6dbf23b9054354f737f60c23d0b416944b6095.json new file mode 100644 index 0000000..9376070 --- /dev/null +++ b/services/cms-service/.sqlx/query-85fbeda23c72b58439fdedac4c6dbf23b9054354f737f60c23d0b416944b6095.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-8ce98992129f77432d24a5a8a458c52d3a252c10f41d6be67d8696b046e4c18f.json b/services/cms-service/.sqlx/query-8ce98992129f77432d24a5a8a458c52d3a252c10f41d6be67d8696b046e4c18f.json new file mode 100644 index 0000000..dc06a6f --- /dev/null +++ b/services/cms-service/.sqlx/query-8ce98992129f77432d24a5a8a458c52d3a252c10f41d6be67d8696b046e4c18f.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-914bcec73c3c1399f4e743d3e89289e783b7479592d1c7e11dd677c99d9bb2d3.json b/services/cms-service/.sqlx/query-914bcec73c3c1399f4e743d3e89289e783b7479592d1c7e11dd677c99d9bb2d3.json new file mode 100644 index 0000000..7d8826c --- /dev/null +++ b/services/cms-service/.sqlx/query-914bcec73c3c1399f4e743d3e89289e783b7479592d1c7e11dd677c99d9bb2d3.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-95ddcf80ff28b2680ebdd9d8ba92aefa34fd99209e228238d86e7d135f6b41e2.json b/services/cms-service/.sqlx/query-95ddcf80ff28b2680ebdd9d8ba92aefa34fd99209e228238d86e7d135f6b41e2.json new file mode 100644 index 0000000..c164de3 --- /dev/null +++ b/services/cms-service/.sqlx/query-95ddcf80ff28b2680ebdd9d8ba92aefa34fd99209e228238d86e7d135f6b41e2.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-b42eb00367a1991125ff01fab2a51a3582512a9265d02c2bcb05ddb80e7c6038.json b/services/cms-service/.sqlx/query-b42eb00367a1991125ff01fab2a51a3582512a9265d02c2bcb05ddb80e7c6038.json new file mode 100644 index 0000000..395c026 --- /dev/null +++ b/services/cms-service/.sqlx/query-b42eb00367a1991125ff01fab2a51a3582512a9265d02c2bcb05ddb80e7c6038.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-c2a37e2b0139c053b4c4eb88a2cf4b658fbf419795901d565eaaa1e169d881e9.json b/services/cms-service/.sqlx/query-c2a37e2b0139c053b4c4eb88a2cf4b658fbf419795901d565eaaa1e169d881e9.json new file mode 100644 index 0000000..17e8412 --- /dev/null +++ b/services/cms-service/.sqlx/query-c2a37e2b0139c053b4c4eb88a2cf4b658fbf419795901d565eaaa1e169d881e9.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-cc7467f5734e57f581fab98e7e37a934a7852474ed2eb3ea7b26e4d14b8a4df0.json b/services/cms-service/.sqlx/query-cc7467f5734e57f581fab98e7e37a934a7852474ed2eb3ea7b26e4d14b8a4df0.json new file mode 100644 index 0000000..8a0f2c0 --- /dev/null +++ b/services/cms-service/.sqlx/query-cc7467f5734e57f581fab98e7e37a934a7852474ed2eb3ea7b26e4d14b8a4df0.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-d06f83e2b566ac9a49c63bdfcce03912f6ae1ef00a96fd345d89f22f218aaf1c.json b/services/cms-service/.sqlx/query-d06f83e2b566ac9a49c63bdfcce03912f6ae1ef00a96fd345d89f22f218aaf1c.json new file mode 100644 index 0000000..02ff6fd --- /dev/null +++ b/services/cms-service/.sqlx/query-d06f83e2b566ac9a49c63bdfcce03912f6ae1ef00a96fd345d89f22f218aaf1c.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-ddc1f59ea8f744d2357944e68cf49e4176f7dbee191bbdff8876fc88a0e26436.json b/services/cms-service/.sqlx/query-ddc1f59ea8f744d2357944e68cf49e4176f7dbee191bbdff8876fc88a0e26436.json new file mode 100644 index 0000000..198babe --- /dev/null +++ b/services/cms-service/.sqlx/query-ddc1f59ea8f744d2357944e68cf49e4176f7dbee191bbdff8876fc88a0e26436.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-e26e27402806a7fa4d85433ed18f25c84a2cc2f5eb6b9c2db7562f75755ddc13.json b/services/cms-service/.sqlx/query-e26e27402806a7fa4d85433ed18f25c84a2cc2f5eb6b9c2db7562f75755ddc13.json new file mode 100644 index 0000000..d13a817 --- /dev/null +++ b/services/cms-service/.sqlx/query-e26e27402806a7fa4d85433ed18f25c84a2cc2f5eb6b9c2db7562f75755ddc13.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-e3065bc94b895c8ced3d16c97bd6a11909ee9d29a2dc30a70123d07b28d12c11.json b/services/cms-service/.sqlx/query-e3065bc94b895c8ced3d16c97bd6a11909ee9d29a2dc30a70123d07b28d12c11.json new file mode 100644 index 0000000..0816fa7 --- /dev/null +++ b/services/cms-service/.sqlx/query-e3065bc94b895c8ced3d16c97bd6a11909ee9d29a2dc30a70123d07b28d12c11.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-e3b659588c9e818f6c89d030ae929280aaee5c922c1f01420dc061336cb1c159.json b/services/cms-service/.sqlx/query-e3b659588c9e818f6c89d030ae929280aaee5c922c1f01420dc061336cb1c159.json new file mode 100644 index 0000000..bc4559e --- /dev/null +++ b/services/cms-service/.sqlx/query-e3b659588c9e818f6c89d030ae929280aaee5c922c1f01420dc061336cb1c159.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-e57d5797051a54d5ad707edeabfad84a6da8d8d7020f049d67c98687e6961194.json b/services/cms-service/.sqlx/query-e57d5797051a54d5ad707edeabfad84a6da8d8d7020f049d67c98687e6961194.json new file mode 100644 index 0000000..b2d23e8 --- /dev/null +++ b/services/cms-service/.sqlx/query-e57d5797051a54d5ad707edeabfad84a6da8d8d7020f049d67c98687e6961194.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-e5ede144c8250e31ce63979c3f1a835eb7e8377cf75c8dc0679ec5c2f9504e98.json b/services/cms-service/.sqlx/query-e5ede144c8250e31ce63979c3f1a835eb7e8377cf75c8dc0679ec5c2f9504e98.json new file mode 100644 index 0000000..bf9263a --- /dev/null +++ b/services/cms-service/.sqlx/query-e5ede144c8250e31ce63979c3f1a835eb7e8377cf75c8dc0679ec5c2f9504e98.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-ed4f770f0bd31dc8dc731e73843d8c71a2462290c837d72e1def2af3f7a5fc48.json b/services/cms-service/.sqlx/query-ed4f770f0bd31dc8dc731e73843d8c71a2462290c837d72e1def2af3f7a5fc48.json new file mode 100644 index 0000000..2f527f7 --- /dev/null +++ b/services/cms-service/.sqlx/query-ed4f770f0bd31dc8dc731e73843d8c71a2462290c837d72e1def2af3f7a5fc48.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-f531c2478ba9634cf935aa615c9320fb99006f6e5e66501a93520af637f8f1a5.json b/services/cms-service/.sqlx/query-f531c2478ba9634cf935aa615c9320fb99006f6e5e66501a93520af637f8f1a5.json new file mode 100644 index 0000000..73bf086 --- /dev/null +++ b/services/cms-service/.sqlx/query-f531c2478ba9634cf935aa615c9320fb99006f6e5e66501a93520af637f8f1a5.json @@ -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" +} diff --git a/services/cms-service/.sqlx/query-f7a592c933c658314ed5228da68f5df87fbfbbb4acda6940b1fe57009517a6b7.json b/services/cms-service/.sqlx/query-f7a592c933c658314ed5228da68f5df87fbfbbb4acda6940b1fe57009517a6b7.json new file mode 100644 index 0000000..1b20405 --- /dev/null +++ b/services/cms-service/.sqlx/query-f7a592c933c658314ed5228da68f5df87fbfbbb4acda6940b1fe57009517a6b7.json @@ -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" +} diff --git a/services/cms-service/migrations/20260217000001_advanced_grading.sql b/services/cms-service/migrations/20260217000001_advanced_grading.sql index 79efab2..0266615 100644 --- a/services/cms-service/migrations/20260217000001_advanced_grading.sql +++ b/services/cms-service/migrations/20260217000001_advanced_grading.sql @@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS rubrics ( name VARCHAR(255) NOT NULL, description TEXT, total_points INTEGER NOT NULL DEFAULT 100, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); CREATE INDEX idx_rubrics_org ON rubrics(organization_id); @@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS rubric_criteria ( description TEXT, max_points INTEGER NOT NULL, 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); @@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS rubric_levels ( description TEXT, points INTEGER NOT NULL, 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); @@ -49,8 +49,8 @@ CREATE TABLE IF NOT EXISTS lesson_rubrics ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, rubric_id UUID NOT NULL REFERENCES rubrics(id) ON DELETE CASCADE, - is_active BOOLEAN DEFAULT TRUE, - assigned_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + assigned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), UNIQUE(lesson_id, rubric_id) ); @@ -68,9 +68,9 @@ CREATE TABLE IF NOT EXISTS rubric_assessments ( total_score DECIMAL(5,2) NOT NULL, max_score INTEGER NOT NULL, feedback TEXT, - status VARCHAR(50) DEFAULT 'draft', -- draft, submitted, published - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + status VARCHAR(50) NOT NULL DEFAULT 'draft', -- draft, submitted, published + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); 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 points DECIMAL(5,2) NOT NULL, 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); diff --git a/services/cms-service/migrations/20260218000001_lesson_dependencies.sql b/services/cms-service/migrations/20260218000001_lesson_dependencies.sql index 4c31688..c9801b6 100644 --- a/services/cms-service/migrations/20260218000001_lesson_dependencies.sql +++ b/services/cms-service/migrations/20260218000001_lesson_dependencies.sql @@ -6,7 +6,7 @@ CREATE TABLE lesson_dependencies ( lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, prerequisite_lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, min_score_percentage DOUBLE PRECISION, - created_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(lesson_id, prerequisite_lesson_id), CHECK (lesson_id != prerequisite_lesson_id) ); diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index c6369b9..3754619 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -3534,7 +3534,7 @@ pub async fn delete_course( .map_err(|_| StatusCode::NOT_FOUND)?; // 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); } diff --git a/services/cms-service/src/handlers_rubrics.rs b/services/cms-service/src/handlers_rubrics.rs index 73d9f63..2ec3891 100644 --- a/services/cms-service/src/handlers_rubrics.rs +++ b/services/cms-service/src/handlers_rubrics.rs @@ -119,7 +119,7 @@ pub async fn create_rubric( ) .fetch_one(&pool) .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)) } @@ -143,7 +143,7 @@ pub async fn list_course_rubrics( ) .fetch_all(&pool) .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)) } @@ -167,7 +167,7 @@ pub async fn get_rubric_with_details( ) .fetch_optional(&pool) .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()))?; // Get criteria @@ -183,7 +183,7 @@ pub async fn get_rubric_with_details( ) .fetch_all(&pool) .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 let mut criteria_with_levels = Vec::new(); @@ -200,7 +200,7 @@ pub async fn get_rubric_with_details( ) .fetch_all(&pool) .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 }); } @@ -235,7 +235,7 @@ pub async fn update_rubric( ) .fetch_optional(&pool) .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(Json(rubric)) @@ -254,7 +254,7 @@ pub async fn delete_rubric( ) .execute(&pool) .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 { return Err((StatusCode::NOT_FOUND, "Rubric not found".to_string())); @@ -280,7 +280,7 @@ pub async fn create_criterion( ) .fetch_optional(&pool) .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()))?; let position = payload.position.unwrap_or(0); @@ -300,7 +300,7 @@ pub async fn create_criterion( ) .fetch_one(&pool) .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 let _= sqlx::query!( @@ -314,7 +314,7 @@ pub async fn create_criterion( ) .execute(&pool) .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)) } @@ -347,7 +347,7 @@ pub async fn update_criterion( ) .fetch_optional(&pool) .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()))?; // Update rubric total_points if max_points changed @@ -363,7 +363,7 @@ pub async fn update_criterion( ) .execute(&pool) .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)) @@ -382,7 +382,7 @@ pub async fn delete_criterion( ) .fetch_optional(&pool) .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()))?; let result = sqlx::query!( @@ -396,7 +396,7 @@ pub async fn delete_criterion( ) .execute(&pool) .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 { return Err((StatusCode::NOT_FOUND, "Criterion not found".to_string())); @@ -414,7 +414,7 @@ pub async fn delete_criterion( ) .execute(&pool) .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) } @@ -436,7 +436,7 @@ pub async fn create_level( ) .fetch_optional(&pool) .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()))?; let position = payload.position.unwrap_or(0); @@ -456,7 +456,7 @@ pub async fn create_level( ) .fetch_one(&pool) .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)) } @@ -492,7 +492,7 @@ pub async fn update_level( ) .fetch_optional(&pool) .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(Json(level)) @@ -518,7 +518,7 @@ pub async fn delete_level( ) .execute(&pool) .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 { return Err((StatusCode::NOT_FOUND, "Level not found".to_string())); @@ -548,7 +548,7 @@ pub async fn assign_rubric_to_lesson( ) .fetch_one(&pool) .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)) } @@ -566,7 +566,7 @@ pub async fn unassign_rubric_from_lesson( ) .execute(&pool) .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 { return Err((StatusCode::NOT_FOUND, "Lesson rubric not found".to_string())); @@ -595,7 +595,7 @@ pub async fn get_lesson_rubrics( ) .fetch_all(&pool) .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)) } diff --git a/services/lms-service/.sqlx/query-17f05eb41a9b8c4fd37f1c47495546709658ff09ac8be7ad1c611039e55394da.json b/services/lms-service/.sqlx/query-17f05eb41a9b8c4fd37f1c47495546709658ff09ac8be7ad1c611039e55394da.json new file mode 100644 index 0000000..38596a7 --- /dev/null +++ b/services/lms-service/.sqlx/query-17f05eb41a9b8c4fd37f1c47495546709658ff09ac8be7ad1c611039e55394da.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-363c5ded702de620f7d55d9a28564a6cb07ab2123f6733eb9992ef29a0347a3f.json b/services/lms-service/.sqlx/query-363c5ded702de620f7d55d9a28564a6cb07ab2123f6733eb9992ef29a0347a3f.json new file mode 100644 index 0000000..b62e250 --- /dev/null +++ b/services/lms-service/.sqlx/query-363c5ded702de620f7d55d9a28564a6cb07ab2123f6733eb9992ef29a0347a3f.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-48092c69f6c0c66fc843d31c65123dfbd6771c450e34d2f330a7cec4cad9e16e.json b/services/lms-service/.sqlx/query-48092c69f6c0c66fc843d31c65123dfbd6771c450e34d2f330a7cec4cad9e16e.json new file mode 100644 index 0000000..2131722 --- /dev/null +++ b/services/lms-service/.sqlx/query-48092c69f6c0c66fc843d31c65123dfbd6771c450e34d2f330a7cec4cad9e16e.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-5e0c0dd74a0fcb24eae0b69b46550cdb1fc0520f59ab24095319c844100696a8.json b/services/lms-service/.sqlx/query-5e0c0dd74a0fcb24eae0b69b46550cdb1fc0520f59ab24095319c844100696a8.json new file mode 100644 index 0000000..2c96c10 --- /dev/null +++ b/services/lms-service/.sqlx/query-5e0c0dd74a0fcb24eae0b69b46550cdb1fc0520f59ab24095319c844100696a8.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-60ddd0622ba9bdb70c661f9b8f755f4336017efa585e1f93ac848b1fed4835a1.json b/services/lms-service/.sqlx/query-60ddd0622ba9bdb70c661f9b8f755f4336017efa585e1f93ac848b1fed4835a1.json new file mode 100644 index 0000000..5003ad1 --- /dev/null +++ b/services/lms-service/.sqlx/query-60ddd0622ba9bdb70c661f9b8f755f4336017efa585e1f93ac848b1fed4835a1.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-6744490d98f0f7b1d753e89dfe2cddef4e580c62954847940e5fa0d1ad6a7fcf.json b/services/lms-service/.sqlx/query-6744490d98f0f7b1d753e89dfe2cddef4e580c62954847940e5fa0d1ad6a7fcf.json new file mode 100644 index 0000000..e0bba92 --- /dev/null +++ b/services/lms-service/.sqlx/query-6744490d98f0f7b1d753e89dfe2cddef4e580c62954847940e5fa0d1ad6a7fcf.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-82038007a13b07cdb912619ddefa10faa651a934e8dfb14be29226b85c614cae.json b/services/lms-service/.sqlx/query-82038007a13b07cdb912619ddefa10faa651a934e8dfb14be29226b85c614cae.json new file mode 100644 index 0000000..dfa1e70 --- /dev/null +++ b/services/lms-service/.sqlx/query-82038007a13b07cdb912619ddefa10faa651a934e8dfb14be29226b85c614cae.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-af5539604d4c172890ce3f62de6dbe7952f027d6caf367ed0480b5f16274bd1f.json b/services/lms-service/.sqlx/query-af5539604d4c172890ce3f62de6dbe7952f027d6caf367ed0480b5f16274bd1f.json new file mode 100644 index 0000000..1a00314 --- /dev/null +++ b/services/lms-service/.sqlx/query-af5539604d4c172890ce3f62de6dbe7952f027d6caf367ed0480b5f16274bd1f.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-bba15398004acf73d991751221eba784335db7d899c0601d216fc1703ff49d06.json b/services/lms-service/.sqlx/query-bba15398004acf73d991751221eba784335db7d899c0601d216fc1703ff49d06.json new file mode 100644 index 0000000..2042077 --- /dev/null +++ b/services/lms-service/.sqlx/query-bba15398004acf73d991751221eba784335db7d899c0601d216fc1703ff49d06.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-bd51fbc3e8b3746722d88201f1937ffa3f2718790d55d4b9143bacaa6219173e.json b/services/lms-service/.sqlx/query-bd51fbc3e8b3746722d88201f1937ffa3f2718790d55d4b9143bacaa6219173e.json new file mode 100644 index 0000000..e9f365b --- /dev/null +++ b/services/lms-service/.sqlx/query-bd51fbc3e8b3746722d88201f1937ffa3f2718790d55d4b9143bacaa6219173e.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-bf3c31d22790fe0eeec0234f97f920a3c077a443579c6184f09b95f9d078f593.json b/services/lms-service/.sqlx/query-bf3c31d22790fe0eeec0234f97f920a3c077a443579c6184f09b95f9d078f593.json new file mode 100644 index 0000000..63041a5 --- /dev/null +++ b/services/lms-service/.sqlx/query-bf3c31d22790fe0eeec0234f97f920a3c077a443579c6184f09b95f9d078f593.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-c1778b5abe6d4e799993ac6145d1c00bd5d73086e80b9e068010f29202b1f5a8.json b/services/lms-service/.sqlx/query-c1778b5abe6d4e799993ac6145d1c00bd5d73086e80b9e068010f29202b1f5a8.json new file mode 100644 index 0000000..6da6eed --- /dev/null +++ b/services/lms-service/.sqlx/query-c1778b5abe6d4e799993ac6145d1c00bd5d73086e80b9e068010f29202b1f5a8.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-da54efaca96ea7a75198417d4ce1754aaad514c9877a264449807338a1f539b8.json b/services/lms-service/.sqlx/query-da54efaca96ea7a75198417d4ce1754aaad514c9877a264449807338a1f539b8.json new file mode 100644 index 0000000..aba648e --- /dev/null +++ b/services/lms-service/.sqlx/query-da54efaca96ea7a75198417d4ce1754aaad514c9877a264449807338a1f539b8.json @@ -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" +} diff --git a/services/lms-service/.sqlx/query-e0cf43306025c312338d7f507911fccb61f18ddd6bf8f9c90f368a296f346f4b.json b/services/lms-service/.sqlx/query-e0cf43306025c312338d7f507911fccb61f18ddd6bf8f9c90f368a296f346f4b.json new file mode 100644 index 0000000..489327a --- /dev/null +++ b/services/lms-service/.sqlx/query-e0cf43306025c312338d7f507911fccb61f18ddd6bf8f9c90f368a296f346f4b.json @@ -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" +} diff --git a/services/lms-service/dev_keys/lti_private.pem b/services/lms-service/dev_keys/lti_private.pem new file mode 100644 index 0000000..f47b381 --- /dev/null +++ b/services/lms-service/dev_keys/lti_private.pem @@ -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----- diff --git a/services/lms-service/dev_keys/lti_public.pem b/services/lms-service/dev_keys/lti_public.pem new file mode 100644 index 0000000..e702f76 --- /dev/null +++ b/services/lms-service/dev_keys/lti_public.pem @@ -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----- diff --git a/services/lms-service/dev_keys/modulus_b64.txt b/services/lms-service/dev_keys/modulus_b64.txt new file mode 100644 index 0000000..4b68bc5 --- /dev/null +++ b/services/lms-service/dev_keys/modulus_b64.txt @@ -0,0 +1 @@ +weIdo6QklIJW77oEAd0NvX_L1e6mFRpHbSrhWjEJTfQDzLdNV84zPfu-rP-IJdWlvrtO2F_dHHah0ilNRZCaAwPXNqS6L57OrYJjxeDXKWnnfaVw4uUT1aDGFcXQ55Bbf05-N28aj26NEXh9WQVqO6L8XRrleRUgJtb8MBAWovxKi3CBJ_lFVYe31cPeAOCaEF_xzeMVEmJt3fbSewsUIrB7jD8F3YOcu8h_QGAc9tn9uxMfBJv2XZoGHCtMQUGG07iZtoSKBYGrWf5rBc7PsCFcVuQzlO9cf13jgQ2rcfcU3LwC_gp4A9RYnv_ymaHELz0kALKBtBxj1XU7QdLrsw diff --git a/services/lms-service/dev_keys/modulus_hex.txt b/services/lms-service/dev_keys/modulus_hex.txt new file mode 100644 index 0000000..a046f05 --- /dev/null +++ b/services/lms-service/dev_keys/modulus_hex.txt @@ -0,0 +1 @@ +C1E21DA3A424948256EFBA0401DD0DBD7FCBD5EEA6151A476D2AE15A31094DF403CCB74D57CE333DFBBEACFF8825D5A5BEBB4ED85FDD1C76A1D2294D45909A0303D736A4BA2F9ECEAD8263C5E0D72969E77DA570E2E513D5A0C615C5D0E7905B7F4E7E376F1A8F6E8D11787D59056A3BA2FC5D1AE579152026D6FC301016A2FC4A8B708127F9455587B7D5C3DE00E09A105FF1CDE31512626DDDF6D27B0B1422B07B8C3F05DD839CBBC87F40601CF6D9FDBB131F049BF65D9A061C2B4C414186D3B899B6848A0581AB59FE6B05CECFB0215C56E43394EF5C7F5DE3810DAB71F714DCBC02FE0A7803D4589EFFF299A1C42F3D2400B281B41C63D5753B41D2EBB3 diff --git a/services/lms-service/migrations/20260218000002_lesson_dependencies_mirror.sql b/services/lms-service/migrations/20260218000002_lesson_dependencies_mirror.sql new file mode 100644 index 0000000..0cb178a --- /dev/null +++ b/services/lms-service/migrations/20260218000002_lesson_dependencies_mirror.sql @@ -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); diff --git a/services/lms-service/migrations/20260226000000_lti_dl_requests.sql b/services/lms-service/migrations/20260226000000_lti_dl_requests.sql new file mode 100644 index 0000000..33282a4 --- /dev/null +++ b/services/lms-service/migrations/20260226000000_lti_dl_requests.sql @@ -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); diff --git a/services/lms-service/migrations/20260226010000_dropout_risks.sql b/services/lms-service/migrations/20260226010000_dropout_risks.sql new file mode 100644 index 0000000..02102b7 --- /dev/null +++ b/services/lms-service/migrations/20260226010000_dropout_risks.sql @@ -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(); diff --git a/services/lms-service/migrations/20260226020000_live_learning.sql b/services/lms-service/migrations/20260226020000_live_learning.sql new file mode 100644 index 0000000..d0f1e24 --- /dev/null +++ b/services/lms-service/migrations/20260226020000_live_learning.sql @@ -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(); diff --git a/services/lms-service/migrations/20260226030000_portfolios.sql b/services/lms-service/migrations/20260226030000_portfolios.sql new file mode 100644 index 0000000..026e2ff --- /dev/null +++ b/services/lms-service/migrations/20260226030000_portfolios.sql @@ -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; diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index d4b99b5..b4b6491 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -10,6 +10,7 @@ use common::middleware::Org; use common::models::{ AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics, Module, Notification, Organization, RecommendationResponse, User, UserResponse, + LessonDependency, }; pub async fn get_me( @@ -20,7 +21,7 @@ pub async fn get_me( .bind(claims.sub) .fetch_one(&pool) .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 { id: user.id, @@ -156,7 +157,7 @@ pub async fn export_course_grades( ) .fetch_all(&pool) .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 let students = sqlx::query!( @@ -165,14 +166,14 @@ pub async fn export_course_grades( u.id, u.full_name, 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, AVG(g.score)::float4 as average_score FROM users u 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 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 "#, course_id, @@ -180,7 +181,7 @@ pub async fn export_course_grades( ) .fetch_all(&pool) .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 struct UserCategoryGrade { @@ -205,7 +206,7 @@ pub async fn export_course_grades( ) .fetch_all(&pool) .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 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 { 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 .average_score .map(|v| format!("{:.1}%", v * 100.0)) @@ -327,7 +328,7 @@ pub async fn enroll_user( .bind(course_id) .fetch_one(&mut *tx) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Enrollment failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -415,7 +416,7 @@ pub async fn register( let mut tx = pool .begin() .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 { sqlx::query_as::<_, Organization>( @@ -424,7 +425,7 @@ pub async fn register( .bind(&org_name) .fetch_one(&mut *tx) .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 { sqlx::query_as::<_, Organization>( "SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'", @@ -448,11 +449,11 @@ pub async fn register( .bind(organization.id) .fetch_one(&mut *tx) .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() .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(|_| { ( @@ -570,7 +571,7 @@ pub async fn get_course_catalog( .await } } - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Catalog fetch failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -612,7 +613,7 @@ pub async fn ingest_course( .bind(payload.organization.updated_at) .execute(&mut *tx) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to upsert organization during ingestion: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -650,7 +651,7 @@ pub async fn ingest_course( .bind(&payload.course.currency) .execute(&mut *tx) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to upsert course during ingestion: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -706,7 +707,7 @@ pub async fn ingest_course( .bind(instructor.created_at) .execute(&mut *tx) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to insert instructor: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -755,7 +756,7 @@ pub async fn ingest_course( .bind(lesson.is_previewable) .execute(&mut *tx) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to insert lesson: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -817,7 +818,7 @@ pub async fn get_course_outline( .bind(id) .fetch_one(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("get_course_outline: course fetch failed for {}: {}", id, e); StatusCode::NOT_FOUND })?; @@ -830,7 +831,7 @@ pub async fn get_course_outline( .bind(id) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("get_course_outline: modules fetch failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -844,7 +845,7 @@ pub async fn get_course_outline( .bind(course.organization_id) .fetch_one(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!( "get_course_outline: organization fetch failed for {}: {}", course.organization_id, @@ -865,7 +866,7 @@ pub async fn get_course_outline( .bind(id) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("get_course_outline: grading categories fetch failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -879,7 +880,7 @@ pub async fn get_course_outline( .bind(module.id) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!( "get_course_outline: lessons fetch failed for module {}: {}", module.id, @@ -905,7 +906,7 @@ pub async fn get_course_outline( ) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("get_course_outline: dependencies fetch failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -959,7 +960,7 @@ pub async fn get_lesson_content( .bind(claims.org) .fetch_optional(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("get_lesson_content: DB error (preview): {}", e); StatusCode::INTERNAL_SERVER_ERROR })? @@ -974,7 +975,7 @@ pub async fn get_lesson_content( .bind(claims.sub) .fetch_optional(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("get_lesson_content: DB error: {}", e); StatusCode::INTERNAL_SERVER_ERROR })? @@ -1020,7 +1021,7 @@ pub async fn get_lesson_content( ) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("get_lesson_content: failed to check dependencies: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -1073,7 +1074,7 @@ pub async fn submit_lesson_score( let mut tx = pool .begin() .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 .get("x-forwarded-for") @@ -1095,7 +1096,7 @@ pub async fn submit_lesson_score( Some("SYSTEM_EVENT".to_string()), ) .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 let max_attempts: Option> = @@ -1103,7 +1104,7 @@ pub async fn submit_lesson_score( .bind(payload.lesson_id) .fetch_optional(&mut *tx) .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() { return Err((StatusCode::NOT_FOUND, "Lección no encontrada".into())); @@ -1117,7 +1118,7 @@ pub async fn submit_lesson_score( .bind(org_ctx.id) .fetch_optional(&mut *tx) .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(max) = max_attempts { @@ -1142,11 +1143,11 @@ pub async fn submit_lesson_score( .bind(payload.metadata) .fetch_one(&mut *tx) .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() .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 let webhook_service = common::webhooks::WebhookService::new(pool.clone()); @@ -1254,7 +1255,7 @@ pub async fn get_leaderboard( .bind(org_ctx.id) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to fetch leaderboard: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -1310,7 +1311,7 @@ pub async fn get_course_grades( .bind(filter.cohort_id) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to fetch course grades: {}", e); (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) })?; @@ -1362,7 +1363,7 @@ pub async fn get_course_analytics( .bind(filter.cohort_id) .fetch_one(&pool) .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) let average_score: Option = sqlx::query_scalar( @@ -1381,7 +1382,7 @@ pub async fn get_course_analytics( .bind(filter.cohort_id) .fetch_one(&pool) .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 // Note: We cast AVG to float4 for PostgreSQL compatibility @@ -1407,7 +1408,7 @@ pub async fn get_course_analytics( .bind(filter.cohort_id) .fetch_all(&pool) .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 .into_iter() @@ -1529,7 +1530,7 @@ pub async fn get_advanced_analytics( .bind(org_ctx.id) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Cohort query failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -1542,7 +1543,7 @@ pub async fn get_advanced_analytics( .bind(org_ctx.id) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Retention query failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -1572,7 +1573,7 @@ pub async fn record_interaction( .bind(payload.metadata) .execute(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to record interaction: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -1596,7 +1597,7 @@ pub async fn get_lesson_heatmap( .bind(org_ctx.id) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to fetch heatmap: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -1616,7 +1617,7 @@ pub async fn get_notifications( .bind(org_ctx.id) .fetch_all(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to fetch notifications: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -1638,7 +1639,7 @@ pub async fn mark_notification_as_read( .bind(org_ctx.id) .execute(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Failed to mark notification as read: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; @@ -1702,7 +1703,7 @@ pub async fn toggle_bookmark( .bind(lesson_id) .fetch_optional(&pool) .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 { // Remove bookmark @@ -1710,7 +1711,7 @@ pub async fn toggle_bookmark( .bind(id) .execute(&pool) .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) } else { // Add bookmark @@ -1723,7 +1724,7 @@ pub async fn toggle_bookmark( .bind(lesson_id) .execute(&pool) .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) } } @@ -1745,7 +1746,7 @@ pub async fn get_user_bookmarks( // Wait, let's create a better filter for this. .fetch_all(&pool) .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)) } @@ -1777,7 +1778,7 @@ pub async fn update_user( .bind(org_ctx.id) .fetch_one(&pool) .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 { id: user.id, @@ -1809,7 +1810,7 @@ pub async fn get_recommendations( .bind(course_id) .fetch_all(&pool) .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 #[derive(sqlx::FromRow)] diff --git a/services/lms-service/src/handlers_peer_review.rs b/services/lms-service/src/handlers_peer_review.rs index deeedd9..3106acb 100644 --- a/services/lms-service/src/handlers_peer_review.rs +++ b/services/lms-service/src/handlers_peer_review.rs @@ -26,7 +26,7 @@ pub async fn submit_assignment( ) .fetch_optional(&pool) .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 { // Update existing submission @@ -44,7 +44,7 @@ pub async fn submit_assignment( ) .fetch_one(&pool) .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)); } @@ -65,7 +65,7 @@ pub async fn submit_assignment( ) .fetch_one(&pool) .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)) } @@ -106,7 +106,7 @@ pub async fn get_peer_review_assignment( ) .fetch_optional(&pool) .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)) } @@ -125,7 +125,7 @@ pub async fn submit_peer_review( ) .fetch_optional(&pool) .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 { Some(s) => s, @@ -147,7 +147,7 @@ pub async fn submit_peer_review( ) .fetch_optional(&pool) .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() { return Err(( @@ -172,7 +172,7 @@ pub async fn submit_peer_review( ) .fetch_one(&pool) .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)) } @@ -197,7 +197,7 @@ pub async fn get_my_submission_feedback( ) .fetch_all(&pool) .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)) } diff --git a/services/lms-service/src/jwks.rs b/services/lms-service/src/jwks.rs new file mode 100644 index 0000000..ad50051 --- /dev/null +++ b/services/lms-service/src/jwks.rs @@ -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 { + let jwks = get_lti_jwks(); + axum::Json(json!(jwks)) +} diff --git a/services/lms-service/src/live.rs b/services/lms-service/src/live.rs new file mode 100644 index 0000000..492b04f --- /dev/null +++ b/services/lms-service/src/live.rs @@ -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, + pub start_at: chrono::DateTime, + pub duration_minutes: i32, +} + +pub async fn get_course_meetings( + Path(course_id): Path, + State(pool): State, + claims: Claims, +) -> Result>, (StatusCode, String)> { + let meetings = sqlx::query_as::( + "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, + State(pool): State, + claims: Claims, + Json(payload): Json, +) -> Result, (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::( + 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, + claims: Claims, +) -> Result { + 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) +} diff --git a/services/lms-service/src/lti.rs b/services/lms-service/src/lti.rs index fe0833b..871a706 100644 --- a/services/lms-service/src/lti.rs +++ b/services/lms-service/src/lti.rs @@ -205,51 +205,108 @@ pub async fn lti_launch( let user = user.unwrap(); - // 6. Map resource link to course - let resource_link = sqlx::query_as::<_, LtiResourceLink>( - "SELECT * FROM lti_resource_links WHERE organization_id = $1 AND resource_link_id = $2" - ) - .bind(registration.organization_id) - .bind(<i_claims.resource_link.id) - .fetch_optional(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + // 8. Redirect based on message type + let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); + let studio_url = std::env::var("NEXT_PUBLIC_STUDIO_URL").unwrap_or_else(|_| "http://localhost:3001".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( - "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(registration.organization_id) - .bind(link.course_id) + .bind(dl_request_id) + .bind(registration.id) + .bind(<i_claims.deployment_id) + .bind(&settings.deep_link_return_url) + .bind(&settings.data) .execute(&pool) .await .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 { - "/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, +} + +pub async fn lti_deep_linking_response( + State(pool): State, + claims: Claims, + Json(payload): Json, +) -> Result, (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::("return_url"); + let dl_data: Option = 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 claims = Claims { - sub: user.id, - role: user.role, - org: user.organization_id, - exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp(), - course_id: None, - token_type: Some("access".to_string()), - }; - - 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()), + let private_key = crate::jwks::get_lti_private_key(); + let response_jwt = jsonwebtoken::encode( + &jsonwebtoken::Header { + kid: Some("openccb-lti-key-1".to_string()), + alg: jsonwebtoken::Algorithm::RS256, + ..Default::default() + }, + &response_claims, + &private_key, ) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // 8. Redirect to Experience app launch page - let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); - Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target)))) + Ok(Json(json!({ + "jwt": response_jwt, + "return_url": dl_request.get::("return_url") + }))) } + +use axum::Json; +use sqlx::Row; diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index b23a013..b1dfa8e 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -7,11 +7,16 @@ mod handlers_notes; mod handlers_payments; mod handlers_peer_review; mod lti; +mod jwks; +mod predictive; +mod live; +mod portfolio; use axum::{ Router, middleware, routing::{delete, get, post, put}, }; +use axum::Json; // Added based on instruction use dotenvy::dotenv; use sqlx::postgres::PgPoolOptions; use std::env; @@ -86,6 +91,17 @@ async fn main() { "/courses/{id}/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( "/users/{id}/gamification", get(handlers::get_user_gamification), @@ -210,6 +226,8 @@ async fn main() { ) .route("/lti/login", get(lti::lti_login_initiation)) .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) .layer(cors) .with_state(pool); diff --git a/services/lms-service/src/portfolio.rs b/services/lms-service/src/portfolio.rs new file mode 100644 index 0000000..c9aad58 --- /dev/null +++ b/services/lms-service/src/portfolio.rs @@ -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, + State(pool): State, +) -> Result, (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::( + 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, +) -> Result>, (StatusCode, String)> { + let badges = sqlx::query_as::( + 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, + claims: Claims, + Json(payload): Json, +) -> Result { + 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) +} diff --git a/services/lms-service/src/predictive.rs b/services/lms-service/src/predictive.rs new file mode 100644 index 0000000..93e5abc --- /dev/null +++ b/services/lms-service/src/predictive.rs @@ -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, + State(pool): State, + claims: Claims, +) -> Result>, (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 = 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::(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(()) +} diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index ede9df6..52bf5c2 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -193,7 +193,9 @@ pub struct LtiLaunchClaims { #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/deployment_id")] pub deployment_id: String, #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/resource_link")] - pub resource_link: LtiResourceLinkClaim, + pub resource_link: Option, + #[serde(rename = "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings")] + pub deep_linking_settings: Option, #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/context")] pub context: Option, #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/roles")] @@ -215,6 +217,65 @@ pub struct LtiContextClaim { pub title: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LtiDeepLinkingSettings { + pub deep_link_return_url: String, + pub accept_types: Vec, + pub accept_presentation_document_targets: Vec, + pub accept_media_types: Option, + pub accept_multiple: Option, + pub accept_copy_advice: Option, + pub auto_create: Option, + pub title: Option, + pub text: Option, + pub data: Option, +} + +#[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, + #[serde(rename = "https://purl.imsglobal.org/spec/lti-dl/claim/data")] + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LtiDeepLinkingContentItem { + #[serde(rename = "type")] + pub item_type: String, // "ltiResourceLink" + pub title: Option, + pub text: Option, + pub url: Option, + pub icon: Option, + pub thumbnail: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LtiImage { + pub url: String, + pub width: Option, + pub height: Option, +} + #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Asset { pub id: Uuid, @@ -642,6 +703,7 @@ pub struct SubmitAssignmentPayload { pub struct SubmitPeerReviewPayload { pub submission_id: Uuid, pub score: i32, + pub feedback: String, } // Content Libraries @@ -690,6 +752,36 @@ pub struct LibraryTemplate { pub updated_at: DateTime, } +#[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, // e.g., ["low_grades", "inactivity"] + pub last_calculated_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DropoutRiskReason { + pub metric: String, + pub value: f32, + pub description: String, +} + #[cfg(test)] mod tests { use super::*; @@ -893,3 +985,56 @@ pub struct LessonDependency { pub min_score_percentage: Option, pub created_at: DateTime, } + +// ==================== 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, + pub provider: String, // "jitsi" | "bbb" + pub meeting_id: String, // Room name or external ID + pub start_at: DateTime, + pub duration_minutes: i32, + pub join_url: Option, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +// ==================== 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, +} + +#[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, + pub evidence_url: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PublicProfile { + pub user_id: Uuid, + pub full_name: String, + pub avatar_url: Option, + pub bio: Option, + pub badges: Vec, + pub level: i32, + pub xp: i32, + pub completed_courses_count: i64, +} diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index 0769589..120adfd 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { lmsApi, Course, Module, Recommendation, UserGrade } from "@/lib/api"; -import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } from "lucide-react"; +import { lmsApi, Course, Module, Recommendation, UserGrade, Meeting } from "@/lib/api"; +import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle, Video, ExternalLink } from "lucide-react"; import Link from "next/link"; import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react"; import { useAuth } from "@/context/AuthContext"; @@ -19,6 +19,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } const [isEnrolled, setIsEnrolled] = useState(false); const [lessonDependencies, setLessonDependencies] = useState([]); const [instructors, setInstructors] = useState([]); + const [meetings, setMeetings] = useState([]); useEffect(() => { const fetchData = async () => { @@ -58,6 +59,10 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } .then(res => setRecommendations(res.recommendations)) .catch(console.error) .finally(() => setLoadingAI(false)); + + lmsApi.getMeetings(params.id) + .then(setMeetings) + .catch(console.error); }, [params.id, user]); const handleEnrollOrBuy = async () => { @@ -294,6 +299,47 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } )} + {/* Live Sessions Section */} + {meetings.length > 0 && ( +
+
+
+
+
+

Sesiones en Vivo

+

Únete a las clases sincrónicas programadas

+
+
+ +
+ {meetings.map((m) => ( +
+
+
+ +
+
+

{m.title}

+

+ {new Date(m.start_at).toLocaleString()} • {m.duration_minutes} min +

+
+
+ + Unirse + +
+ ))} +
+
+ )} + {/* Announcements Section */}
diff --git a/web/experience/src/app/profile/[id]/page.tsx b/web/experience/src/app/profile/[id]/page.tsx new file mode 100644 index 0000000..38ab878 --- /dev/null +++ b/web/experience/src/app/profile/[id]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
Loading portfolio...
; + if (error) return
{error}
; + if (!profile) return null; + + return ( +
+ {/* Hero Section / Profile Header */} +
+
+
+
+
+
+
+ {profile.avatar_url ? ( + {profile.full_name} + ) : ( +
+ +
+ )} +
+
+
+

{profile.full_name}

+

+ + Level {profile.level} Apprentice • {profile.xp} XP +

+
+
+ + + +
+
+
+
+ +
+ {/* Left Column: Bio & Stats */} +
+
+

About Me

+

+ {profile.bio || "No biography provided. This student is focused on mastering their craft."} +

+
+ +
+

Global Stats

+
+
+
+ + Courses Finished +
+ {profile.completed_courses_count} +
+
+
+ + Badges Earned +
+ {profile.badges.length} +
+
+
+ + Global Rank +
+ Top 5% +
+
+
+
+ + {/* Right Column: Badges & Showcase */} +
+
+
+

+ + Credentials & Badges +

+ VERIFIED BY OPENCCB +
+ +
+ {profile.badges.map((badge: any) => ( +
+
+ {badge.name} { + const target = e.target as any; + target.src = "https://cdn-icons-png.flaticon.com/512/10636/10636665.png"; + }} /> +
+

{badge.name}

+

+ {badge.description} +

+
+
+ ))} +
+ + {profile.badges.length === 0 && ( +
+

This student hasn't collected any badges yet.

+
+ )} +
+
+
+
+ ); +} diff --git a/web/experience/src/components/AppHeader.tsx b/web/experience/src/components/AppHeader.tsx index 9c2b953..d033d27 100644 --- a/web/experience/src/components/AppHeader.tsx +++ b/web/experience/src/components/AppHeader.tsx @@ -51,6 +51,11 @@ export default function AppHeader() { {t('nav.bookmarks')} + {user && ( + + MI PORTAFOLIO + + )}
@@ -127,6 +132,15 @@ export default function AppHeader() { > {t('nav.bookmarks')} + {user && ( + 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 + + )}
diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 90e2d6a..beb0bb3 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -232,6 +232,39 @@ export interface ProgressStats { 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 { user: User; token: string; @@ -706,5 +739,18 @@ export const lmsApi = { async getBookmarks(courseId?: string): Promise { const query = courseId ? `?cohort_id=${courseId}` : ''; return apiFetch(`/bookmarks${query}`); + }, + + // Live Learning & Portfolio + async getMeetings(courseId: string): Promise { + return apiFetch(`/courses/${courseId}/meetings`, {}, false); + }, + + async getPublicProfile(userId: string): Promise { + return apiFetch(`/profile/${userId}`, {}, false); + }, + + async getMyBadges(): Promise { + return apiFetch(`/my/badges`, {}, false); } }; diff --git a/web/studio/src/app/courses/[id]/analytics/page.tsx b/web/studio/src/app/courses/[id]/analytics/page.tsx index 539b5b8..86785d2 100644 --- a/web/studio/src/app/courses/[id]/analytics/page.tsx +++ b/web/studio/src/app/courses/[id]/analytics/page.tsx @@ -12,9 +12,13 @@ import { ArrowLeft, CheckCircle2, BookOpen, - Layers + Layers, + ShieldAlert } from "lucide-react"; 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() { const { id } = useParams() as { id: string }; @@ -26,6 +30,7 @@ export default function AnalyticsPage() { const [authError, setAuthError] = useState(null); const [cohorts, setCohorts] = useState([]); const [selectedCohortId, setSelectedCohortId] = useState(""); + const [activeAnalyticsTab, setActiveAnalyticsTab] = useState<"overview" | "risks" | "live">("overview"); useEffect(() => { const fetchData = async () => { @@ -129,114 +134,142 @@ export default function AnalyticsPage() {
-
- {/* Stats Grid */} -
-
-
-
- -
- Enrollments -
-
{analytics.total_enrollments}
-
Active Learners
-
- -
-
-
- -
- Average Score -
-
{Math.round(analytics.average_score * 100)}%
-
Across all assessments
-
- -
-
-
- -
- Attention Needed -
-
{difficultLessons.length}
-
Struggling Lessons
-
+
+ {/* Tab Selector */} +
+ + +
-
- {/* Lesson Breakdown */} -
-

- - Lesson Performance -

-
- {analytics.lessons.map((lesson) => ( -
-
-
-

{lesson.lesson_title}

-

{lesson.submission_count} submissions

-
-
- {Math.round(lesson.average_score * 100)}% -
-
-
-
+ {activeAnalyticsTab === "overview" ? ( +
+ {/* Stats Grid */} +
+
+
+
+
+ Enrollments
- ))} -
-
+
{analytics.total_enrollments}
+
Active Learners
+
- {/* Actionable Insights */} -
-
-

- - Struggling Lessons -

- {difficultLessons.length > 0 ? ( +
+
+
+ +
+ Average Score +
+
{Math.round(analytics.average_score * 100)}%
+
Across all assessments
+
+ +
+
+
+ +
+ Attention Needed +
+
{difficultLessons.length}
+
Struggling Lessons
+
+
+ +
+ {/* Lesson Breakdown */} +
+

+ + Lesson Performance +

- {difficultLessons.map(l => ( -
-
-

{l.lesson_title}

-

- Average score is below 70%. Consider reviewing the material or difficulty of questions. -

+ {analytics.lessons.map((lesson) => ( +
+
+
+

{lesson.lesson_title}

+

{lesson.submission_count} submissions

+
+
+ {Math.round(lesson.average_score * 100)}% +
+
+
+
-
{Math.round(l.average_score * 100)}%
))}
- ) : ( -
- -

All set!

-

No lessons currently fall below the difficulty threshold.

-
- )} -
+
-
-

- - Content Strategy Tip -

-

- High submission counts with low average scores often indicate that the assessment might be misleading or the prerequisites aren't clearly explained in previous lessons. -

+ {/* Actionable Insights */} +
+
+

+ + Struggling Lessons +

+ {difficultLessons.length > 0 ? ( +
+ {difficultLessons.map(l => ( +
+
+

{l.lesson_title}

+

+ Average score is below 70%. Consider reviewing the material or difficulty of questions. +

+
+
{Math.round(l.average_score * 100)}%
+
+ ))} +
+ ) : ( +
+ +

All set!

+

No lessons currently fall below the difficulty threshold.

+
+ )} +
+ +
+

+ + Content Strategy Tip +

+

+ High submission counts with low average scores often indicate that the assessment might be misleading or the prerequisites aren't clearly explained in previous lessons. +

+
+
-
-
+
+ ) : ( + + )}
diff --git a/web/studio/src/app/lti/deep-linking/page.tsx b/web/studio/src/app/lti/deep-linking/page.tsx new file mode 100644 index 0000000..0e80bcd --- /dev/null +++ b/web/studio/src/app/lti/deep-linking/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [selectedCourse, setSelectedCourse] = useState(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 ( +
+
+

PICK CONTENT TO EMBED

+

Select the course or specific lesson you want to link in your platform.

+
+ +
+ {/* Right Column: Content Hierarchy */} +
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( +
+ )) + ) : filteredCourses.length > 0 ? ( + filteredCourses.map(course => ( + + )) + ) : ( +
No courses found
+ )} +
+
+ + {/* Right Column: Outline Picker */} +
+ {selectedCourse ? ( +
+

+ + {selectedCourse.title} +

+ +
+ + + {selectedCourse.modules?.map(module => ( +
+
{module.title}
+ {module.lessons.map(lesson => ( + + ))} +
+ ))} +
+ + +
+ ) : ( +
+
+ +
+
No Course Selected
+

Select a course from the left to browse modules and lessons.

+
+ )} +
+
+
+ ); +} + +export default function DeepLinkingPicker() { + return ( +
}> + +
+ ); +} diff --git a/web/studio/src/components/Analytics/DropoutRiskDashboard.tsx b/web/studio/src/components/Analytics/DropoutRiskDashboard.tsx new file mode 100644 index 0000000..1393db7 --- /dev/null +++ b/web/studio/src/components/Analytics/DropoutRiskDashboard.tsx @@ -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([]); + 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
Calculating risk scores...
; + + 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 ( +
+
+
+

+ + Dropout Risk Analysis +

+

AI-powered detection based on grades, activity, and engagement.

+
+
+ + 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" + /> +
+
+ + {filteredRisks.length > 0 ? ( +
+ {filteredRisks.map((risk) => ( +
+
+
+
+ {risk.user_full_name?.[0] || } +
+
+

{risk.user_full_name || "Unknown Student"}

+
+ {risk.user_email || "N/A"} + Last active: {new Date(risk.last_calculated_at).toLocaleDateString()} +
+
+
+ +
+
+ {risk.risk_level} Risk +
+ +
+
Score: {Math.round(risk.score * 100)}%
+
+
0.8 ? 'bg-red-500' : risk.score > 0.5 ? 'bg-orange-500' : 'bg-green-500'}`} + style={{ width: `${risk.score * 100}%` }} + /> +
+
+ + +
+
+ + {risk.reasons && risk.reasons.length > 0 && ( +
+ {risk.reasons.map((reason, _idx) => ( +
+ + {reason.description} +
+ ))} +
+ )} +
+ ))} +
+ ) : ( +
+ +

No students at risk

+

Everyone seems to be doing great in this course!

+
+ )} +
+ ); +} diff --git a/web/studio/src/components/Courses/LiveSessions.tsx b/web/studio/src/components/Courses/LiveSessions.tsx new file mode 100644 index 0000000..8669956 --- /dev/null +++ b/web/studio/src/components/Courses/LiveSessions.tsx @@ -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([]); + 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
Loading sessions...
; + + return ( +
+
+
+

+

+

Schedule and manage your live Jitsi sessions.

+
+ +
+ + {showForm && ( +
+
+
+
+ + setFormData({ ...formData, title: e.target.value })} + /> +
+
+ + setFormData({ ...formData, start_at: e.target.value })} + /> +
+
+
+ +