From 7f7ea3d70c11dc70260ac5360a2aa849a357bf95 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 23 Feb 2026 15:43:45 -0300 Subject: [PATCH] feat: Add LTI launch, lesson preview, course progress, bookmarks, and asset management features. --- Cargo.lock | 8 + README.md | 8 +- install.sh | 84 +- logs.txt | 76 - roadmap.md | 15 +- services/cms-service/cms_check.txt | 1388 +++++++++++++++++ .../20260223000000_add_lesson_preview.sql | 87 ++ .../20260223150000_update_assets_table.sql | 5 + services/cms-service/src/handlers.rs | 249 +-- services/cms-service/src/handlers_assets.rs | 206 +++ services/cms-service/src/main.rs | 7 +- services/lms-service/Cargo.toml | 2 + services/lms-service/build_errors.txt | 620 ++++++++ services/lms-service/lti_errors.txt | 518 ++++++ .../20260223000000_add_preview_and_teams.sql | 11 + .../20260224000000_add_bookmarks.sql | 15 + .../migrations/20260225000000_lti_tables.sql | 35 + services/lms-service/src/handlers.rs | 234 ++- .../lms-service/src/handlers_discussions.rs | 6 +- services/lms-service/src/lti.rs | 255 +++ services/lms-service/src/main.rs | 7 + shared/common/src/models.rs | 140 +- validate_auth.sh | 44 +- web/experience/package-lock.json | 410 ++++- web/experience/package.json | 7 +- web/experience/src/app/bookmarks/page.tsx | 127 ++ .../courses/[id]/lessons/[lessonId]/page.tsx | 25 +- web/experience/src/app/courses/[id]/page.tsx | 35 +- .../src/app/courses/[id]/progress/page.tsx | 301 +--- web/experience/src/app/lti/launch/page.tsx | 85 + web/experience/src/components/AppHeader.tsx | 10 + .../src/components/ProgressDashboard.tsx | 181 +++ .../components/blocks/PeerReviewPlayer.tsx | 2 +- web/experience/src/lib/api.ts | 46 +- web/experience/src/lib/locales/en.json | 1 + web/experience/src/lib/locales/es.json | 1 + web/experience/src/lib/locales/pt.json | 1 + .../courses/[id]/lessons/[lessonId]/page.tsx | 26 +- web/studio/src/app/courses/[id]/team/page.tsx | 257 +++ web/studio/src/app/library/assets/page.tsx | 256 +++ web/studio/src/app/page.tsx | 8 +- .../src/components/AssetPickerModal.tsx | 107 +- .../src/components/CourseEditorLayout.tsx | 3 +- web/studio/src/components/Navbar.tsx | 10 +- web/studio/src/lib/api.ts | 28 +- 45 files changed, 5250 insertions(+), 697 deletions(-) delete mode 100644 logs.txt create mode 100644 services/cms-service/cms_check.txt create mode 100644 services/cms-service/migrations/20260223000000_add_lesson_preview.sql create mode 100644 services/cms-service/migrations/20260223150000_update_assets_table.sql create mode 100644 services/cms-service/src/handlers_assets.rs create mode 100644 services/lms-service/build_errors.txt create mode 100644 services/lms-service/lti_errors.txt create mode 100644 services/lms-service/migrations/20260223000000_add_preview_and_teams.sql create mode 100644 services/lms-service/migrations/20260224000000_add_bookmarks.sql create mode 100644 services/lms-service/migrations/20260225000000_lti_tables.sql create mode 100644 services/lms-service/src/lti.rs create mode 100644 web/experience/src/app/bookmarks/page.tsx create mode 100644 web/experience/src/app/lti/launch/page.tsx create mode 100644 web/experience/src/components/ProgressDashboard.tsx create mode 100644 web/studio/src/app/courses/[id]/team/page.tsx create mode 100644 web/studio/src/app/library/assets/page.tsx diff --git a/Cargo.lock b/Cargo.lock index 64f849e..2216f94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1474,6 +1474,7 @@ name = "lms-service" version = "0.1.0" dependencies = [ "axum", + "base64 0.22.1", "bcrypt", "chrono", "common", @@ -1487,6 +1488,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "urlencoding", "uuid", ] @@ -3320,6 +3322,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/README.md b/README.md index 32ecb32..a86825e 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,14 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Student Notes**: Sistema de anotaciones personales por lección con auto-guardado inteligente (debounced). - **Interactive Gradebook**: Libro de calificaciones avanzado con filtrado por cohortes, exportación masiva a CSV con desgloses por categoría y pertenencia a cohortes. - **Bulk Operations**: Herramientas administrativas para inscripción masiva de usuarios vía email y comunicación segmentada. -- **Course Teams (Multi-instructor support)** +- **Course Teams**: Soporte para múltiples instructores por curso con roles granulares (Instructor Principal, Instructor, Asistente). +- **Course Preview**: Capacidad de marcar lecciones específicas como previsualizables para usuarios no inscritos (freemium). +- **Student Progress Dashboard**: Visualización avanzada del avance del estudiante con gráficos de actividad diaria y predicción de fecha de finalización basada en el ritmo de aprendizaje. - **Segmented Announcements**: Sistema de anuncios con capacidad de dirigirse a cohortes específicas y notificaciones filtradas. - **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. ## Requisitos del Sistema @@ -621,6 +624,9 @@ Obtiene una lista de todas las organizaciones registradas. - **Cohort Management**: Sistema de gestión de grupos con seguimiento de progreso por cohorte. - **Advanced Gradebook**: Seguimiento del desempeño estudiantil con analíticas y filtrado avanzado. - **Learning Sequences UI**: Interfaz visual para gestionar dependencias y visualización de bloqueos con iconos de candado. +- **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. ## 📄 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/install.sh b/install.sh index 1947cdd..5c4e1fc 100755 --- a/install.sh +++ b/install.sh @@ -11,22 +11,22 @@ set -e echo "====================================================" -echo " 🚀 Welcome to the OpenCCB Installer" +echo " 🚀 Bienvenido al Instalador de OpenCCB" echo "====================================================" echo "" -# 1. Detection & Cloning +# 1. Detección y Clonación if [ -f "Cargo.toml" ] && [ -d "services" ] && [ -d "web" ]; then - echo "✅ Project detected in current directory." + echo "✅ Proyecto detectado en el directorio actual." PROJECT_ROOT=$(pwd) else - # Simplification: assume we are in the project root if the script is running + # Simplificación: assume we are in the project root if the script is running # but let's keep a basic check if [ -d "openccb" ]; then cd openccb PROJECT_ROOT=$(pwd) else - echo "⚠️ Please run this script from the root of the OpenCCB repository." + echo "⚠️ Por favor, ejecuta este script desde la raíz del repositorio de OpenCCB." exit 1 fi fi @@ -34,10 +34,10 @@ fi # 2. Prerequisite Installation install_pkg() { if ! command -v "$1" &> /dev/null; then - echo "🔧 Installing $1..." + echo "🔧 Instalando $1..." sudo apt-get update && sudo apt-get install -y "$1" else - echo "✅ $1 is already installed." + echo "✅ $1 ya está instalado." fi } @@ -55,13 +55,13 @@ if [[ "$OSTYPE" == "linux-gnu"* ]]; then fi if ! command -v cargo &> /dev/null; then - echo "🔧 Installing Rust..." + echo "🔧 Instalando Rust..." curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source $HOME/.cargo/env fi if ! command -v node &> /dev/null; then - echo "🔧 Installing Node.js via NVM..." + echo "🔧 Instalando Node.js vía NVM..." curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" @@ -69,7 +69,7 @@ if ! command -v node &> /dev/null; then fi if ! command -v sqlx &> /dev/null; then - echo "🔧 Installing sqlx-cli..." + echo "🔧 Instalando sqlx-cli..." cargo install sqlx-cli --no-default-features --features postgres fi @@ -93,14 +93,14 @@ update_env() { fi } -# 5. Remote AI Configuration +# 5. Configuración de IA Remota echo "" -echo "🔍 Configuring Remote AI Services..." -read -p "Enter Remote Ollama URL [http://t-800:11434]: " REMOTE_OLLAMA_URL +echo "🔍 Configurando Servicios de IA Remota..." +read -p "Ingrese la URL de Ollama Remoto [http://t-800:11434]: " REMOTE_OLLAMA_URL REMOTE_OLLAMA_URL=${REMOTE_OLLAMA_URL:-http://t-800:11434} -read -p "Enter Remote Whisper URL [http://t-800:9000]: " REMOTE_WHISPER_URL +read -p "Ingrese la URL de Whisper Remoto [http://t-800:9000]: " REMOTE_WHISPER_URL REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-http://t-800:9000} -read -p "Enter Model name (on remote server) [llama3.2:3b]: " LLM_MODEL +read -p "Ingrese el nombre del Modelo (en el servidor remoto) [llama3.2:3b]: " LLM_MODEL LLM_MODEL=${LLM_MODEL:-llama3.2:3b} update_env "AI_PROVIDER" "local" @@ -110,9 +110,9 @@ update_env "LOCAL_LLM_MODEL" "$LLM_MODEL" # AI setup is now purely remote. Skipping local container configuration. -# Ask for DB credentials if not set +# Solicitar credenciales de DB si no están configuradas if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' -f2) == "" ]]; then - read -p "Enter Database Password [password]: " DB_PASS + read -p "Ingrese la Contraseña de la Base de Datos [password]: " DB_PASS DB_PASS=${DB_PASS:-password} update_env "DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb?sslmode=disable" update_env "CMS_DATABASE_URL" "postgresql://user:${DB_PASS}@localhost:5432/openccb_cms?sslmode=disable" @@ -122,21 +122,21 @@ if ! grep -q "DATABASE_URL=" .env || [[ $(grep "DATABASE_URL=" .env | cut -d'=' update_env "NEXT_PUBLIC_LMS_API_URL" "http://localhost:3002" fi -# 5. AI Stack Setup (Skipped - using remote) -echo "🌐 Using remote AI services at $REMOTE_OLLAMA_URL and $REMOTE_WHISPER_URL" +# 5. Configuración de Pila de IA (Omitido - usando remoto) +echo "🌐 Usando servicios de IA remotos en $REMOTE_OLLAMA_URL y $REMOTE_WHISPER_URL" -# 6. Database Initialization (Integrated db-mgmt.sh) +# 6. Inicialización de la Base de Datos echo "" -read -p "Do you want a CLEAN installation? (This will DELETE all existing data) [y/N]: " CLEAN_INSTALL +read -p "¿Desea una instalación LIMPIA? (Esto ELIMINARÁ todos los datos existentes) [y/N]: " CLEAN_INSTALL if [[ "$CLEAN_INSTALL" =~ ^[Yy]$ ]]; then - echo "🐘 Resetting database for a clean installation..." + echo "🐘 Reseteando la base de datos para una instalación limpia..." sudo docker compose down -v || true fi -echo "🐘 Starting database with Docker..." +echo "🐘 Iniciando base de datos con Docker..." sudo docker compose up -d db -echo "⏳ Waiting for database to be ready (container)..." +echo "⏳ Esperando a que la base de datos esté lista (contenedor)..." RETRIES=30 until sudo docker exec openccb-db-1 pg_isready -U user &> /dev/null || [ $RETRIES -eq 0 ]; do echo -n "." @@ -145,7 +145,7 @@ until sudo docker exec openccb-db-1 pg_isready -U user &> /dev/null || [ $RETRIE done echo "" -echo "⏳ Waiting for database port (host)..." +echo "⏳ Esperando al puerto de la base de datos (host)..." RETRIES=10 until curl -s localhost:5432 &> /dev/null || [ $RETRIES -eq 0 ]; do echo -n "+" @@ -155,7 +155,7 @@ done echo "" if [ $RETRIES -eq 0 ]; then - echo "⚠️ Wait for host port timed out, but continuing..." + echo "⚠️ Tiempo de espera agotado para el puerto del host, pero continuando..." fi # Extra buffer for PostgreSQL initialization @@ -164,7 +164,7 @@ sleep 2 CMS_URL=$(grep "CMS_DATABASE_URL=" .env | cut -d'=' -f2-) LMS_URL=$(grep "LMS_DATABASE_URL=" .env | cut -d'=' -f2-) -echo "🏗️ Creating databases and running migrations..." +echo "🏗️ Creando bases de datos y ejecutando migraciones..." DATABASE_URL=$CMS_URL sqlx database create || true DATABASE_URL=$LMS_URL sqlx database create || true DATABASE_URL=$CMS_URL sqlx migrate run --source services/cms-service/migrations @@ -172,27 +172,27 @@ DATABASE_URL=$LMS_URL sqlx migrate run --source services/lms-service/migrations # 7. System Initialization (Integrated init-system.sh) echo "" -echo "🔍 Checking for existing administrator..." +echo "🔍 Buscando administrador existente..." ADMIN_EXISTS=$(sudo docker exec openccb-db-1 psql -U user -d openccb_cms -t -c "SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin');" | xargs 2>/dev/null || echo "f") if [ "$ADMIN_EXISTS" != "t" ]; then - echo "👤 Configure Initial Administrator" - read -p "Full Name [System Admin]: " ADMIN_NAME - ADMIN_NAME=${ADMIN_NAME:-System Admin} - read -p "Admin Email [admin@example.com]: " ADMIN_EMAIL + echo "👤 Configurar Administrador Inicial" + read -p "Nombre Completo [Administrador del Sistema]: " ADMIN_NAME + ADMIN_NAME=${ADMIN_NAME:-Administrador del Sistema} + read -p "Email del Administrador [admin@example.com]: " ADMIN_EMAIL ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} - read -s -p "Admin Password [password123]: " ADMIN_PASS + read -s -p "Contraseña del Administrador [password123]: " ADMIN_PASS ADMIN_PASS=${ADMIN_PASS:-password123} echo "" - ORG_NAME="Default Organization" + ORG_NAME="Organización por Defecto" fi echo "" -echo "🚀 Starting all services..." +echo "🚀 Iniciando todos los servicios..." sudo docker compose up -d --build if [ "$ADMIN_EXISTS" != "t" ]; then - echo "⏳ Waiting for CMS API to be ready..." + echo "⏳ Esperando a que el API CMS esté listo..." API_URL="http://localhost:3001" START_WAIT=$SECONDS PAYLOAD=$(cat < services/cms-service/src/handlers.rs:3147:1 + | + 15 | PublishedModule, User, UserResponse, CourseInstructor, + | ---------------- previous import of the type `CourseInstructor` here +... +3147 | pub struct CourseInstructor { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ `CourseInstructor` redefined here + | + = note: `CourseInstructor` must be defined only once in the type namespace of this module +help: you can use `as` to change the binding name of the import + | + 15 | PublishedModule, User, UserResponse, CourseInstructor as OtherCourseInstructor, + | ++++++++++++++++++++++++ + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_assets.rs:98:5 + | + 98 | / sqlx::query!( + 99 | | r#" +100 | | INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes) +101 | | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +... | +110 | | size_bytes +111 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_assets.rs:185:17 + | +185 | let asset = sqlx::query_as!( + | _________________^ +186 | | Asset, +187 | | "SELECT * FROM assets WHERE id = $1 AND organization_id = $2", +188 | | id, +189 | | org_ctx.id +190 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_assets.rs:197:5 + | +197 | sqlx::query!("DELETE FROM assets WHERE id = $1", id) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_dependencies.rs:40:22 + | +40 | let dependency = sqlx::query_as!( + | ______________________^ +41 | | LessonDependency, +42 | | r#" +43 | | INSERT INTO lesson_dependencies (organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage) +... | +52 | | payload.min_score_percentage +53 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_dependencies.rs:69:18 + | +69 | let result = sqlx::query!( + | __________________^ +70 | | "DELETE FROM lesson_dependencies WHERE lesson_id = $1 AND prerequisite_lesson_id = $2 AND organization_id = $3", +71 | | lesson_id, +72 | | prerequisite_id, +73 | | org_ctx.id +74 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_dependencies.rs:91:24 + | +91 | let dependencies = sqlx::query_as!( + | ________________________^ +92 | | LessonDependency, +93 | | "SELECT * FROM lesson_dependencies WHERE lesson_id = $1 AND organization_id = $2", +94 | | lesson_id, +95 | | org_ctx.id +96 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_library.rs:27:17 + | +27 | let block = sqlx::query_as!( + | _________________^ +28 | | LibraryBlock, +29 | | r#" +30 | | INSERT INTO library_blocks (organization_id, created_by, name, description, block_type, block_data, tags) +... | +40 | | payload.tags.as_deref() +41 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_library.rs:113:17 + | +113 | let block = sqlx::query_as!( + | _________________^ +114 | | LibraryBlock, +115 | | r#"SELECT id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", create... +116 | | block_id, +117 | | org_ctx.id +118 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_library.rs:137:20 + | +137 | let existing = sqlx::query!( + | ____________________^ +138 | | "SELECT id FROM library_blocks WHERE id = $1 AND organization_id = $2", +139 | | block_id, +140 | | org_ctx.id +141 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_library.rs:152:9 + | +152 | / sqlx::query_as!( +153 | | LibraryBlock, +154 | | r#" +155 | | UPDATE library_blocks +... | +167 | | org_ctx.id +168 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_library.rs:173:9 + | +173 | / sqlx::query_as!( +174 | | LibraryBlock, +175 | | r#" +176 | | UPDATE library_blocks +... | +186 | | org_ctx.id +187 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_library.rs:202:18 + | +202 | let result = sqlx::query!( + | __________________^ +203 | | "DELETE FROM library_blocks WHERE id = $1 AND organization_id = $2", +204 | | block_id, +205 | | org_ctx.id +206 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_library.rs:224:18 + | +224 | let result = sqlx::query!( + | __________________^ +225 | | "UPDATE library_blocks SET usage_count = usage_count + 1 WHERE id = $1 AND organization_id = $2", +226 | | block_id, +227 | | org_ctx.id +228 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:107:18 + | +107 | let rubric = sqlx::query_as!( + | __________________^ +108 | | Rubric, +109 | | r#" +110 | | INSERT INTO rubrics (organization_id, course_id, created_by, name, description) +... | +118 | | payload.description +119 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:133:19 + | +133 | let rubrics = sqlx::query_as!( + | ___________________^ +134 | | Rubric, +135 | | r#" +136 | | SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at +... | +142 | | course_id +143 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:158:18 + | +158 | let rubric = sqlx::query_as!( + | __________________^ +159 | | Rubric, +160 | | r#" +161 | | SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at +... | +166 | | org_ctx.id +167 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:174:20 + | +174 | let criteria = sqlx::query_as!( + | ____________________^ +175 | | RubricCriterion, +176 | | r#" +177 | | SELECT id, rubric_id, name, description, max_points, position, created_at +... | +182 | | rubric_id +183 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:191:22 + | +191 | let levels = sqlx::query_as!( + | ______________________^ +192 | | RubricLevel, +193 | | r#" +194 | | SELECT id, criterion_id, name, description, points, position, created_at +... | +199 | | criterion.id +200 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:221:18 + | +221 | let rubric = sqlx::query_as!( + | __________________^ +222 | | Rubric, +223 | | r#" +224 | | UPDATE rubrics +... | +234 | | org_ctx.id +235 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:250:18 + | +250 | let result = sqlx::query!( + | __________________^ +251 | | "DELETE FROM rubrics WHERE id = $1 AND organization_id = $2", +252 | | rubric_id, +253 | | org_ctx.id +254 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:276:19 + | +276 | let _rubric = sqlx::query!( + | ___________________^ +277 | | "SELECT id FROM rubrics WHERE id = $1 AND organization_id = $2", +278 | | rubric_id, +279 | | org_ctx.id +280 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:288:21 + | +288 | let criterion = sqlx::query_as!( + | _____________________^ +289 | | RubricCriterion, +290 | | r#" +291 | | INSERT INTO rubric_criteria (rubric_id, name, description, max_points, position) +... | +299 | | position +300 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:306:12 + | +306 | let _= sqlx::query!( + | ____________^ +307 | | r#" +308 | | UPDATE rubrics +309 | | SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), +... | +313 | | rubric_id +314 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:329:21 + | +329 | let criterion = sqlx::query_as!( + | _____________________^ +330 | | RubricCriterion, +331 | | r#" +332 | | UPDATE rubric_criteria +... | +346 | | org_ctx.id +347 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:355:17 + | +355 | let _ = sqlx::query!( + | _________________^ +356 | | r#" +357 | | UPDATE rubrics +358 | | SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), +... | +362 | | criterion.rubric_id +363 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:379:21 + | +379 | let criterion = sqlx::query!( + | _____________________^ +380 | | "SELECT rubric_id FROM rubric_criteria WHERE id = $1", +381 | | criterion_id +382 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:388:18 + | +388 | let result = sqlx::query!( + | __________________^ +389 | | r#" +390 | | DELETE FROM rubric_criteria +391 | | WHERE id = $1 +... | +395 | | org_ctx.id +396 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:406:13 + | +406 | let _ = sqlx::query!( + | _____________^ +407 | | r#" +408 | | UPDATE rubrics +409 | | SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), +... | +413 | | criterion.rubric_id +414 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:432:22 + | +432 | let _criterion = sqlx::query!( + | ______________________^ +433 | | "SELECT id FROM rubric_criteria WHERE id = $1 AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)", +434 | | criterion_id, +435 | | org_ctx.id +436 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:444:17 + | +444 | let level = sqlx::query_as!( + | _________________^ +445 | | RubricLevel, +446 | | r#" +447 | | INSERT INTO rubric_levels (criterion_id, name, description, points, position) +... | +455 | | position +456 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:471:17 + | +471 | let level = sqlx::query_as!( + | _________________^ +472 | | RubricLevel, +473 | | r#" +474 | | UPDATE rubric_levels +... | +491 | | org_ctx.id +492 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:507:18 + | +507 | let result = sqlx::query!( + | __________________^ +508 | | r#" +509 | | DELETE FROM rubric_levels +510 | | WHERE id = $1 +... | +517 | | org_ctx.id +518 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:538:25 + | +538 | let lesson_rubric = sqlx::query_as!( + | _________________________^ +539 | | LessonRubric, +540 | | r#" +541 | | INSERT INTO lesson_rubrics (lesson_id, rubric_id, is_active) +... | +547 | | rubric_id +548 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:562:18 + | +562 | let result = sqlx::query!( + | __________________^ +563 | | "DELETE FROM lesson_rubrics WHERE lesson_id = $1 AND rubric_id = $2", +564 | | lesson_id, +565 | | rubric_id +566 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/cms-service/src/handlers_rubrics.rs:584:19 + | +584 | let rubrics = sqlx::query_as!( + | ___________________^ +585 | | Rubric, +586 | | r#" +587 | | 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 +... | +594 | | org_ctx.id +595 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate + --> services/cms-service/src/handlers.rs:3146:10 + | +3146 | #[derive(Debug, Serialize, sqlx::FromRow)] + | ^^^^^ `common::models::CourseInstructor` is not defined in the current crate + | + = note: impl doesn't have any local type before any uncovered type parameters + = note: for more information see https://doc.rust-lang.org/reference/items/implementations.html#orphan-rules + = note: define and implement a trait or new type instead + +error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate + --> services/cms-service/src/handlers.rs:3146:17 + | +3146 | #[derive(Debug, Serialize, sqlx::FromRow)] + | ^^^^^^^^^ +3147 | pub struct CourseInstructor { + | ---------------- `common::models::CourseInstructor` is not defined in the current crate + | + = note: impl doesn't have any local type before any uncovered type parameters + = note: for more information see https://doc.rust-lang.org/reference/items/implementations.html#orphan-rules + = note: define and implement a trait or new type instead + = note: this error originates in the derive macro `Serialize` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0210]: type parameter `R` must be used as the type parameter for some local type (e.g., `MyStruct`) + --> services/cms-service/src/handlers.rs:3146:28 + | +3146 | #[derive(Debug, Serialize, sqlx::FromRow)] + | ^^^^^^^^^^^^^ type parameter `R` must be used as the type parameter for some local type + | + = note: implementing a foreign trait is only possible if at least one of the types for which it is implemented is local + = note: only traits defined in the current crate can be implemented for a type parameter + = note: this error originates in the derive macro `sqlx::FromRow` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: `?` couldn't convert the error to `reqwest::StatusCode` + --> services/cms-service/src/handlers.rs:3534:97 + | +3534 | if !is_super_admin && !check_course_access(&pool, course.id, claims.sub, &claims.role).await? { + | ---------------------------------------------------------------------^ the trait `From<(reqwest::StatusCode, std::string::String)>` is not implemented for `reqwest::StatusCode` + | | + | this can't be annotated with `?` because it has type `Result<_, (reqwest::StatusCode, std::string::String)>` + | + = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait + = help: the trait `From<(reqwest::StatusCode, std::string::String)>` is not implemented for `reqwest::StatusCode` + but trait `From<&reqwest::StatusCode>` is implemented for it + = help: for that trait implementation, expected `&reqwest::StatusCode`, found `(reqwest::StatusCode, std::string::String)` + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_assets.rs:98:5 + | + 98 | / sqlx::query!( + 99 | | r#" +100 | | INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes) +101 | | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +... | +112 | | .execute(&pool) +113 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_assets.rs:114:15 + | +114 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +114 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_assets.rs:185:17 + | +185 | let asset = sqlx::query_as!( + | _________________^ +186 | | Asset, +187 | | "SELECT * FROM assets WHERE id = $1 AND organization_id = $2", +188 | | id, +... | +191 | | .fetch_optional(&pool) +192 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_assets.rs:193:15 + | +193 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +193 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_assets.rs:197:5 + | +197 | / sqlx::query!("DELETE FROM assets WHERE id = $1", id) +198 | | .execute(&pool) +199 | | .await + | |______________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_assets.rs:200:19 + | +200 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +200 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_dependencies.rs:40:22 + | +40 | let dependency = sqlx::query_as!( + | ______________________^ +41 | | LessonDependency, +42 | | r#" +43 | | INSERT INTO lesson_dependencies (organization_id, lesson_id, prerequisite_lesson_id, min_score_percentage) +... | +54 | | .fetch_one(&pool) +55 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_dependencies.rs:69:18 + | +69 | let result = sqlx::query!( + | __________________^ +70 | | "DELETE FROM lesson_dependencies WHERE lesson_id = $1 AND prerequisite_lesson_id = $2 AND organization_id = $3", +71 | | lesson_id, +72 | | prerequisite_id, +... | +75 | | .execute(&pool) +76 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_dependencies.rs:91:24 + | +91 | let dependencies = sqlx::query_as!( + | ________________________^ +92 | | LessonDependency, +93 | | "SELECT * FROM lesson_dependencies WHERE lesson_id = $1 AND organization_id = $2", +94 | | lesson_id, +... | +97 | | .fetch_all(&pool) +98 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:27:17 + | +27 | let block = sqlx::query_as!( + | _________________^ +28 | | LibraryBlock, +29 | | r#" +30 | | INSERT INTO library_blocks (organization_id, created_by, name, description, block_type, block_data, tags) +... | +42 | | .fetch_one(&pool) +43 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:44:15 + | +44 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +44 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:113:17 + | +113 | let block = sqlx::query_as!( + | _________________^ +114 | | LibraryBlock, +115 | | r#"SELECT id, organization_id, created_by, name, description, block_type, block_data, tags, usage_count as "usage_count!", create... +116 | | block_id, +... | +119 | | .fetch_optional(&pool) +120 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:121:15 + | +121 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +121 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:137:20 + | +137 | let existing = sqlx::query!( + | ____________________^ +138 | | "SELECT id FROM library_blocks WHERE id = $1 AND organization_id = $2", +139 | | block_id, +140 | | org_ctx.id +141 | | ) +142 | | .fetch_optional(&pool) +143 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:144:15 + | +144 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +144 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:152:9 + | +152 | / sqlx::query_as!( +153 | | LibraryBlock, +154 | | r#" +155 | | UPDATE library_blocks +... | +169 | | .fetch_one(&pool) +170 | | .await + | |______________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:171:19 + | +171 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +171 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:173:9 + | +173 | / sqlx::query_as!( +174 | | LibraryBlock, +175 | | r#" +176 | | UPDATE library_blocks +... | +188 | | .fetch_one(&pool) +189 | | .await + | |______________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:190:19 + | +190 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +190 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:202:18 + | +202 | let result = sqlx::query!( + | __________________^ +203 | | "DELETE FROM library_blocks WHERE id = $1 AND organization_id = $2", +204 | | block_id, +205 | | org_ctx.id +206 | | ) +207 | | .execute(&pool) +208 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:209:15 + | +209 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +209 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:224:18 + | +224 | let result = sqlx::query!( + | __________________^ +225 | | "UPDATE library_blocks SET usage_count = usage_count + 1 WHERE id = $1 AND organization_id = $2", +226 | | block_id, +227 | | org_ctx.id +228 | | ) +229 | | .execute(&pool) +230 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_library.rs:231:15 + | +231 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +231 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:107:18 + | +107 | let rubric = sqlx::query_as!( + | __________________^ +108 | | Rubric, +109 | | r#" +110 | | INSERT INTO rubrics (organization_id, course_id, created_by, name, description) +... | +120 | | .fetch_one(&pool) +121 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:122:15 + | +122 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +122 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:133:19 + | +133 | let rubrics = sqlx::query_as!( + | ___________________^ +134 | | Rubric, +135 | | r#" +136 | | SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at +... | +144 | | .fetch_all(&pool) +145 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:146:15 + | +146 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +146 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:158:18 + | +158 | let rubric = sqlx::query_as!( + | __________________^ +159 | | Rubric, +160 | | r#" +161 | | SELECT id, organization_id, course_id, created_by, name, description, total_points, created_at, updated_at +... | +168 | | .fetch_optional(&pool) +169 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:170:15 + | +170 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +170 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:174:20 + | +174 | let criteria = sqlx::query_as!( + | ____________________^ +175 | | RubricCriterion, +176 | | r#" +177 | | SELECT id, rubric_id, name, description, max_points, position, created_at +... | +184 | | .fetch_all(&pool) +185 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:186:15 + | +186 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +186 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:191:22 + | +191 | let levels = sqlx::query_as!( + | ______________________^ +192 | | RubricLevel, +193 | | r#" +194 | | SELECT id, criterion_id, name, description, points, position, created_at +... | +201 | | .fetch_all(&pool) +202 | | .await + | |______________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:203:19 + | +203 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +203 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:221:18 + | +221 | let rubric = sqlx::query_as!( + | __________________^ +222 | | Rubric, +223 | | r#" +224 | | UPDATE rubrics +... | +236 | | .fetch_optional(&pool) +237 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:238:15 + | +238 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +238 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:250:18 + | +250 | let result = sqlx::query!( + | __________________^ +251 | | "DELETE FROM rubrics WHERE id = $1 AND organization_id = $2", +252 | | rubric_id, +253 | | org_ctx.id +254 | | ) +255 | | .execute(&pool) +256 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:257:15 + | +257 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +257 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:276:19 + | +276 | let _rubric = sqlx::query!( + | ___________________^ +277 | | "SELECT id FROM rubrics WHERE id = $1 AND organization_id = $2", +278 | | rubric_id, +279 | | org_ctx.id +280 | | ) +281 | | .fetch_optional(&pool) +282 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:283:15 + | +283 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +283 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:288:21 + | +288 | let criterion = sqlx::query_as!( + | _____________________^ +289 | | RubricCriterion, +290 | | r#" +291 | | INSERT INTO rubric_criteria (rubric_id, name, description, max_points, position) +... | +301 | | .fetch_one(&pool) +302 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:303:15 + | +303 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +303 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:306:12 + | +306 | let _= sqlx::query!( + | ____________^ +307 | | r#" +308 | | UPDATE rubrics +309 | | SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), +... | +315 | | .execute(&pool) +316 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:317:15 + | +317 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +317 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:329:21 + | +329 | let criterion = sqlx::query_as!( + | _____________________^ +330 | | RubricCriterion, +331 | | r#" +332 | | UPDATE rubric_criteria +... | +348 | | .fetch_optional(&pool) +349 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:350:15 + | +350 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +350 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:355:17 + | +355 | let _ = sqlx::query!( + | _________________^ +356 | | r#" +357 | | UPDATE rubrics +358 | | SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), +... | +364 | | .execute(&pool) +365 | | .await + | |______________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:366:19 + | +366 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +366 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:379:21 + | +379 | let criterion = sqlx::query!( + | _____________________^ +380 | | "SELECT rubric_id FROM rubric_criteria WHERE id = $1", +381 | | criterion_id +... | +384 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:385:15 + | +385 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +385 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:388:18 + | +388 | let result = sqlx::query!( + | __________________^ +389 | | r#" +390 | | DELETE FROM rubric_criteria +391 | | WHERE id = $1 +... | +397 | | .execute(&pool) +398 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:399:15 + | +399 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +399 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:406:13 + | +406 | let _ = sqlx::query!( + | _____________^ +407 | | r#" +408 | | UPDATE rubrics +409 | | SET total_points = (SELECT COALESCE(SUM(max_points), 0) FROM rubric_criteria WHERE rubric_id = $1), +... | +415 | | .execute(&pool) +416 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:417:15 + | +417 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +417 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:432:22 + | +432 | let _criterion = sqlx::query!( + | ______________________^ +433 | | "SELECT id FROM rubric_criteria WHERE id = $1 AND rubric_id IN (SELECT id FROM rubrics WHERE organization_id = $2)", +434 | | criterion_id, +435 | | org_ctx.id +436 | | ) +437 | | .fetch_optional(&pool) +438 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:439:15 + | +439 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +439 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:444:17 + | +444 | let level = sqlx::query_as!( + | _________________^ +445 | | RubricLevel, +446 | | r#" +447 | | INSERT INTO rubric_levels (criterion_id, name, description, points, position) +... | +457 | | .fetch_one(&pool) +458 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:459:15 + | +459 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +459 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:471:17 + | +471 | let level = sqlx::query_as!( + | _________________^ +472 | | RubricLevel, +473 | | r#" +474 | | UPDATE rubric_levels +... | +493 | | .fetch_optional(&pool) +494 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:495:15 + | +495 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +495 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:507:18 + | +507 | let result = sqlx::query!( + | __________________^ +508 | | r#" +509 | | DELETE FROM rubric_levels +510 | | WHERE id = $1 +... | +519 | | .execute(&pool) +520 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:521:15 + | +521 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +521 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:538:25 + | +538 | let lesson_rubric = sqlx::query_as!( + | _________________________^ +539 | | LessonRubric, +540 | | r#" +541 | | INSERT INTO lesson_rubrics (lesson_id, rubric_id, is_active) +... | +549 | | .fetch_one(&pool) +550 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:551:15 + | +551 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +551 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:562:18 + | +562 | let result = sqlx::query!( + | __________________^ +563 | | "DELETE FROM lesson_rubrics WHERE lesson_id = $1 AND rubric_id = $2", +564 | | lesson_id, +565 | | rubric_id +566 | | ) +567 | | .execute(&pool) +568 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:569:15 + | +569 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +569 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:584:19 + | +584 | let rubrics = sqlx::query_as!( + | ___________________^ +585 | | Rubric, +586 | | r#" +587 | | 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 +... | +596 | | .fetch_all(&pool) +597 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/cms-service/src/handlers_rubrics.rs:598:15 + | +598 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +598 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0609]: no field `email` on type `&common::models::CourseInstructor` + --> services/cms-service/src/handlers.rs:3153:9 + | +3153 | pub email: String, + | ^^^^^ unknown field + | + = note: available fields are: `id`, `organization_id`, `course_id`, `user_id`, `role`, `created_at` + +error[E0609]: no field `full_name` on type `&common::models::CourseInstructor` + --> services/cms-service/src/handlers.rs:3154:9 + | +3154 | pub full_name: String, + | ^^^^^^^^^ unknown field + | + = note: available fields are: `id`, `organization_id`, `course_id`, `user_id`, `role`, `created_at` + +error[E0560]: struct `common::models::CourseInstructor` has no field named `email` + --> services/cms-service/src/handlers.rs:3153:9 + | +3153 | pub email: String, + | ^^^^^ `common::models::CourseInstructor` does not have this field + | + = note: all struct fields are already assigned + +error[E0560]: struct `common::models::CourseInstructor` has no field named `full_name` + --> services/cms-service/src/handlers.rs:3154:9 + | +3154 | pub full_name: String, + | ^^^^^^^^^ `common::models::CourseInstructor` does not have this field + | + = note: all struct fields are already assigned + +Some errors have detailed explanations: E0117, E0210, E0255, E0277, E0282, E0560, E0609. +For more information about an error, try `rustc --explain E0117`. +error: could not compile `cms-service` (bin "cms-service") due to 113 previous errors diff --git a/services/cms-service/migrations/20260223000000_add_lesson_preview.sql b/services/cms-service/migrations/20260223000000_add_lesson_preview.sql new file mode 100644 index 0000000..9b110b2 --- /dev/null +++ b/services/cms-service/migrations/20260223000000_add_lesson_preview.sql @@ -0,0 +1,87 @@ +-- Migration to support course previews +ALTER TABLE lessons ADD COLUMN is_previewable BOOLEAN NOT NULL DEFAULT FALSE; + +-- Update Lesson Management Functions +CREATE OR REPLACE FUNCTION fn_create_lesson( + p_organization_id UUID, + p_module_id UUID, + p_title VARCHAR(255), + p_content_type VARCHAR(50), + p_content_url VARCHAR(500) DEFAULT NULL, + p_position INTEGER DEFAULT 0, + p_transcription JSONB DEFAULT NULL, + p_metadata JSONB DEFAULT NULL, + p_is_graded BOOLEAN DEFAULT FALSE, + p_grading_category_id UUID DEFAULT NULL, + p_max_attempts INTEGER DEFAULT NULL, + p_allow_retry BOOLEAN DEFAULT TRUE, + p_due_date TIMESTAMPTZ DEFAULT NULL, + p_important_date_type VARCHAR(50) DEFAULT NULL, + p_is_previewable BOOLEAN DEFAULT FALSE +) RETURNS SETOF lessons AS $$ +BEGIN + RETURN QUERY + INSERT INTO lessons ( + organization_id, module_id, title, content_type, content_url, + position, transcription, metadata, is_graded, grading_category_id, + max_attempts, allow_retry, due_date, important_date_type, is_previewable + ) + VALUES ( + p_organization_id, p_module_id, p_title, p_content_type, p_content_url, + p_position, p_transcription, p_metadata, p_is_graded, p_grading_category_id, + p_max_attempts, p_allow_retry, p_due_date, p_important_date_type, p_is_previewable + ) + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION fn_update_lesson( + p_id UUID, + p_organization_id UUID, + p_title VARCHAR(255) DEFAULT NULL, + p_content_type VARCHAR(50) DEFAULT NULL, + p_content_url VARCHAR(500) DEFAULT NULL, + p_content_blocks JSONB DEFAULT NULL, + p_transcription JSONB DEFAULT NULL, + p_metadata JSONB DEFAULT NULL, + p_is_graded BOOLEAN DEFAULT NULL, + p_grading_category_id UUID DEFAULT NULL, + p_max_attempts INTEGER DEFAULT NULL, + p_allow_retry BOOLEAN DEFAULT NULL, + p_position INTEGER DEFAULT NULL, + p_due_date TIMESTAMPTZ DEFAULT NULL, + p_important_date_type VARCHAR(50) DEFAULT NULL, + p_summary TEXT DEFAULT NULL, + p_is_previewable BOOLEAN DEFAULT NULL, + p_clear_due_date BOOLEAN DEFAULT FALSE, + p_clear_grading_category BOOLEAN DEFAULT FALSE +) RETURNS SETOF lessons AS $$ +BEGIN + RETURN QUERY + UPDATE lessons + SET title = COALESCE(p_title, title), + content_type = COALESCE(p_content_type, content_type), + content_url = COALESCE(p_content_url, content_url), + content_blocks = COALESCE(p_content_blocks, content_blocks), + transcription = COALESCE(p_transcription, transcription), + metadata = COALESCE(p_metadata, metadata), + is_graded = COALESCE(p_is_graded, is_graded), + grading_category_id = CASE + WHEN p_clear_grading_category THEN NULL + ELSE COALESCE(p_grading_category_id, grading_category_id) + END, + max_attempts = COALESCE(p_max_attempts, max_attempts), + allow_retry = COALESCE(p_allow_retry, allow_retry), + position = COALESCE(p_position, position), + due_date = CASE + WHEN p_clear_due_date THEN NULL + ELSE COALESCE(p_due_date, due_date) + END, + important_date_type = COALESCE(p_important_date_type, important_date_type), + summary = COALESCE(p_summary, summary), + is_previewable = COALESCE(p_is_previewable, is_previewable), + updated_at = NOW() + WHERE id = p_id AND organization_id = p_organization_id + RETURNING *; +END; +$$ LANGUAGE plpgsql; diff --git a/services/cms-service/migrations/20260223150000_update_assets_table.sql b/services/cms-service/migrations/20260223150000_update_assets_table.sql new file mode 100644 index 0000000..c9cfa67 --- /dev/null +++ b/services/cms-service/migrations/20260223150000_update_assets_table.sql @@ -0,0 +1,5 @@ +-- Migration: Add uploaded_by to assets table +ALTER TABLE assets ADD COLUMN uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL; + +-- Index for performance when filtering by uploader +CREATE INDEX idx_assets_uploaded_by ON assets(uploaded_by); diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index d9c2ea6..c6369b9 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -12,7 +12,7 @@ use common::auth::{Claims, create_jwt, create_preview_token}; use common::middleware::Org; use common::models::{ AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse, - PublishedModule, User, UserResponse, + PublishedModule, User, UserResponse, CourseInstructor, }; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -115,11 +115,23 @@ pub async fn publish_course( let mut course_for_pub = course.clone(); course_for_pub.organization_id = target_org_id; + // 5. Fetch Course Team + let instructors = sqlx::query_as::<_, CourseInstructor>( + "SELECT ci.*, u.email, u.full_name FROM course_instructors ci + JOIN users u ON ci.user_id = u.id + WHERE ci.course_id = $1" + ) + .bind(id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let payload = PublishedCourse { course: course_for_pub, organization, grading_categories, modules: pub_modules, + instructors: Some(instructors), dependencies: None, }; @@ -329,10 +341,10 @@ pub async fn update_course( .bind(org_ctx.id) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::NOT_FOUND, "Course not found".into()))?; + .map_err(|_| (StatusCode::NOT_FOUND, "Curso no encontrado".into()))?; if claims.role != "admin" && existing.instructor_id != claims.sub { - return Err((StatusCode::FORBIDDEN, "Not authorized".into())); + return Err((StatusCode::FORBIDDEN, "No autorizado".into())); } let title = payload @@ -547,6 +559,11 @@ pub async fn create_lesson( let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str()); + let is_previewable = payload + .get("is_previewable") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mut tx = pool .begin() .await @@ -575,7 +592,7 @@ pub async fn create_lesson( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let lesson = sqlx::query_as::<_, Lesson>( - "SELECT * FROM fn_create_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)" + "SELECT * FROM fn_create_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)" ) .bind(org_ctx.id) .bind(module_id) @@ -591,6 +608,7 @@ pub async fn create_lesson( .bind(allow_retry) .bind(due_date) .bind(important_date_type) + .bind(is_previewable) .fetch_one(&mut *tx) .await .map_err(|e| { @@ -1293,6 +1311,7 @@ pub async fn update_lesson( let metadata = payload.get("metadata").cloned(); let important_date_type = payload.get("important_date_type").and_then(|v| v.as_str()); let summary = payload.get("summary").and_then(|v| v.as_str()); + let is_previewable = payload.get("is_previewable").and_then(|v| v.as_bool()); let content_blocks = payload.get("content_blocks").cloned(); let transcription = payload.get("transcription").cloned(); @@ -1342,7 +1361,7 @@ pub async fn update_lesson( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let lesson = sqlx::query_as::<_, Lesson>( - "SELECT * FROM fn_update_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)" + "SELECT * FROM fn_update_lesson($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)" ) .bind(id) .bind(org_ctx.id) @@ -1360,6 +1379,7 @@ pub async fn update_lesson( .bind(due_date) .bind(important_date_type) .bind(summary) + .bind(is_previewable) .bind(clear_due_date) .bind(clear_grading_category) .fetch_one(&mut *tx) @@ -1642,195 +1662,6 @@ pub async fn reorder_lessons( Ok(StatusCode::OK) } -#[derive(Debug, Serialize)] -pub struct UploadResponse { - pub id: Uuid, - pub filename: String, - pub url: String, -} - -pub async fn upload_asset( - Org(org_ctx): Org, - State(pool): State, - mut multipart: axum::extract::Multipart, -) -> Result, (StatusCode, String)> { - tracing::info!("Starting upload_asset for org: {}", org_ctx.id); - let mut filename = String::new(); - let mut data = Vec::new(); - let mut mimetype = String::new(); - let mut course_id: Option = None; - - while let Some(field) = - multipart - .next_field() - .await - .map_err(|e: axum::extract::multipart::MultipartError| { - (StatusCode::BAD_REQUEST, e.to_string()) - })? - { - let name = field.name().unwrap_or_default().to_string(); - if name == "file" { - filename = field.file_name().unwrap_or("unnamed").to_string(); - mimetype = field - .content_type() - .unwrap_or("application/octet-stream") - .to_string(); - data = field - .bytes() - .await - .map_err(|e: axum::extract::multipart::MultipartError| { - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) - })? - .to_vec(); - } else if name == "course_id" { - if let Ok(txt) = field.text().await { - if let Ok(id) = Uuid::parse_str(&txt) { - course_id = Some(id); - } - } - } - } - - if data.is_empty() { - return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string())); - } - - let asset_id = Uuid::new_v4(); - let extension = std::path::Path::new(&filename) - .extension() - .and_then(|s| s.to_str()) - .unwrap_or(""); - - let storage_filename = format!("{}.{}", asset_id, extension); - let storage_path = format!("uploads/{}", storage_filename); - - // Ensure uploads directory exists - tokio::fs::create_dir_all("uploads") - .await - .map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - // Write file - tokio::fs::write(&storage_path, data) - .await - .map_err(|e: std::io::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - // Record in DB - let size_bytes = tokio::fs::metadata(&storage_path) - .await - .map(|m| m.len() as i64) - .unwrap_or(0); - - sqlx::query( - "INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id, course_id) VALUES ($1, $2, $3, $4, $5, $6, $7)" - ) - .bind(asset_id) - .bind(&filename) - .bind(storage_path) - .bind(mimetype) - .bind(size_bytes) - .bind(org_ctx.id) - .bind(course_id) - .execute(&pool) - .await - .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let url = format!("/assets/{}", storage_filename); - - tracing::info!("Upload successful: {} -> {}", filename, url); - Ok(Json(UploadResponse { - id: asset_id, - filename, - url, - })) -} - -pub async fn get_course_assets( - Org(org_ctx): Org, - State(pool): State, - Path(course_id): Path, -) -> Result>, StatusCode> { - let assets = sqlx::query_as::<_, common::models::Asset>( - "SELECT * FROM assets WHERE organization_id = $1 AND course_id = $2 ORDER BY created_at DESC" - ) - .bind(org_ctx.id) - .bind(course_id) - .fetch_all(&pool) - .await - .map_err(|e| { - tracing::error!("Failed to fetch course assets: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(assets)) -} - -pub async fn delete_asset( - Org(org_ctx): Org, - claims: Claims, - State(pool): State, - Path(asset_id): Path, -) -> Result { - // 1. Fetch asset to verify ownership/org - let asset = sqlx::query_as::<_, common::models::Asset>( - "SELECT * FROM assets WHERE id = $1 AND organization_id = $2", - ) - .bind(asset_id) - .bind(org_ctx.id) - .fetch_optional(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let asset = match asset { - Some(a) => a, - None => return Err((StatusCode::NOT_FOUND, "Asset not found".to_string())), - }; - - // 2. Check permissions (only instructor of the course or admin) - if claims.role != "admin" { - // If linked to a course, check if user owns that course - if let Some(cid) = asset.course_id { - let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1") - .bind(cid) - .fetch_optional(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - if let Some(c) = course { - if c.instructor_id != claims.sub { - return Err(( - StatusCode::FORBIDDEN, - "Not authorized to delete this asset".to_string(), - )); - } - } - } - // If not linked to a course, only admins might delete? Or maybe uploader? - // For now, let's assume if it's orphaned, only admin deletes. - if asset.course_id.is_none() { - return Err(( - StatusCode::FORBIDDEN, - "Only admins can delete global assets".to_string(), - )); - } - } - - // 3. Delete file - // Note: storage_path is relative to working dir usually "uploads/..." - if let Err(e) = tokio::fs::remove_file(&asset.storage_path).await { - tracing::warn!("Failed to delete file {}: {}", asset.storage_path, e); - // We continue to delete from DB even if file specific deletion failed (maybe already gone) - } - - // 4. Delete from DB - sqlx::query("DELETE FROM assets WHERE id = $1") - .bind(asset_id) - .execute(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - Ok(StatusCode::NO_CONTENT) -} - #[derive(Deserialize)] pub struct AuthPayload { pub email: String, @@ -1988,7 +1819,7 @@ pub async fn login( "Verification failed".into(), ) })? { - return Err((StatusCode::UNAUTHORIZED, "Invalid credentials".into())); + return Err((StatusCode::UNAUTHORIZED, "Credenciales inválidas".into())); } let token = create_jwt(user.id, user.organization_id, &user.role).map_err(|_| { @@ -3059,7 +2890,7 @@ pub async fn export_course( })?; if !exists { - return Err((StatusCode::NOT_FOUND, "Course not found".to_string())); + return Err((StatusCode::NOT_FOUND, "Rúbrica no encontrada".to_string())); } // 2. Generate ZIP @@ -3143,8 +2974,8 @@ pub async fn import_course( let mimetype = mime_guess::from_path(&old_filename).first_or_octet_stream().to_string(); sqlx::query( - "INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id) - VALUES ($1, $2, $3, $4, $5, $6)" + "INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id, uploaded_by) + VALUES ($1, $2, $3, $4, $5, $6, $7)" ) .bind(new_id) .bind(&old_filename) @@ -3152,6 +2983,7 @@ pub async fn import_course( .bind(&mimetype) .bind(content.len() as i64) .bind(org_ctx.id) + .bind(claims.sub) .execute(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -3312,7 +3144,7 @@ pub async fn check_course_access( } #[derive(Debug, Serialize, sqlx::FromRow)] -pub struct CourseInstructor { +pub struct CourseInstructorDetail { pub id: Uuid, pub course_id: Uuid, pub user_id: Uuid, @@ -3326,18 +3158,21 @@ pub async fn get_course_team( Org(_org_ctx): Org, claims: Claims, State(pool): State, - Path(id): Path, -) -> Result>, (StatusCode, String)> { - if !check_course_access(&pool, id, claims.sub, &claims.role).await? { + Path(course_id): Path, +) -> Result>, (StatusCode, String)> { + if !check_course_access(&pool, course_id, claims.sub, &claims.role).await? { return Err((StatusCode::FORBIDDEN, "No access to this course team".into())); } - let team = sqlx::query_as::<_, CourseInstructor>( - "SELECT ci.*, u.email, u.full_name FROM course_instructors ci - JOIN users u ON ci.user_id = u.id - WHERE ci.course_id = $1" + let team = sqlx::query_as::<_, CourseInstructorDetail>( + r#" + SELECT ci.id, ci.course_id, ci.user_id, ci.role, ci.created_at, u.email, u.full_name + FROM course_instructors ci + JOIN users u ON ci.user_id = u.id + WHERE ci.course_id = $1 + "# ) - .bind(id) + .bind(course_id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/services/cms-service/src/handlers_assets.rs b/services/cms-service/src/handlers_assets.rs new file mode 100644 index 0000000..2e74b6c --- /dev/null +++ b/services/cms-service/src/handlers_assets.rs @@ -0,0 +1,206 @@ +use axum::{ + Json, + extract::{Path, Query, State, Multipart}, + http::StatusCode, +}; +use common::models::{Asset}; +use common::{auth::Claims, middleware::Org}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; +use std::path::Path as StdPath; + +#[derive(Debug, Serialize)] +pub struct AssetUploadResponse { + pub id: Uuid, + pub filename: String, + pub url: String, + pub mimetype: String, + pub size_bytes: i64, +} + +#[derive(Debug, Deserialize)] +pub struct AssetFilters { + pub mimetype: Option, + pub course_id: Option, + pub search: Option, + pub page: Option, + pub limit: Option, +} + +/// POST /api/assets/upload - Subir un archivo a la biblioteca global +pub async fn upload_asset( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + let mut filename = String::new(); + let mut data = Vec::new(); + let mut mimetype = String::new(); + let mut course_id: Option = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? + { + let name = field.name().unwrap_or_default().to_string(); + if name == "file" { + filename = field.file_name().unwrap_or("unnamed").to_string(); + mimetype = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + data = field + .bytes() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .to_vec(); + } else if name == "course_id" { + if let Ok(txt) = field.text().await { + if let Ok(id) = Uuid::parse_str(&txt) { + course_id = Some(id); + } + } + } + } + + if data.is_empty() { + return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string())); + } + + let asset_id = Uuid::new_v4(); + let extension = StdPath::new(&filename) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + let storage_filename = format!("{}.{}", asset_id, extension); + let storage_path = format!("uploads/{}", storage_filename); + + // Ensure uploads directory exists + tokio::fs::create_dir_all("uploads") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Write file + tokio::fs::write(&storage_path, data) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let size_bytes = tokio::fs::metadata(&storage_path) + .await + .map(|m| m.len() as i64) + .unwrap_or(0); + + // Record in DB + sqlx::query!( + r#" + INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + asset_id, + org_ctx.id, + claims.sub, + course_id, + filename, + storage_path, + mimetype, + size_bytes + ) + .execute(&pool) + .await + .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(AssetUploadResponse { + id: asset_id, + filename, + url: format!("/assets/{}", storage_filename), + mimetype, + size_bytes, + })) +} + +/// GET /api/assets - Listar activos de la organización +pub async fn list_assets( + Org(org_ctx): Org, + State(pool): State, + Query(filters): Query, +) -> Result>, (StatusCode, String)> { + let limit = filters.limit.unwrap_or(50) as i64; + let offset = ((filters.page.unwrap_or(1).max(1) - 1) * filters.limit.unwrap_or(50)) as i64; + + let mut query = String::from("SELECT * FROM assets WHERE organization_id = $1"); + let mut param_index = 2; + + if filters.mimetype.is_some() { + query.push_str(&format!(" AND mimetype ILIKE ${}", param_index)); + param_index += 1; + } + + if filters.course_id.is_some() { + query.push_str(&format!(" AND course_id = ${}", param_index)); + param_index += 1; + } + + if filters.search.is_some() { + query.push_str(&format!(" AND filename ILIKE ${}", param_index)); + param_index += 1; + } + + query.push_str(&format!(" ORDER BY created_at DESC LIMIT ${} OFFSET ${}", param_index, param_index + 1)); + + let mut sql_query = sqlx::query_as::<_, Asset>(&query).bind(org_ctx.id); + + if let Some(mt) = &filters.mimetype { + sql_query = sql_query.bind(format!("%{}%", mt)); + } + + if let Some(cid) = filters.course_id { + sql_query = sql_query.bind(cid); + } + + if let Some(search) = &filters.search { + sql_query = sql_query.bind(format!("%{}%", search)); + } + + let assets = sql_query + .bind(limit) + .bind(offset) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(assets)) +} + +/// DELETE /api/assets/:id - Eliminar un activo y su archivo físico +pub async fn delete_asset( + Org(org_ctx): Org, + State(pool): State, + Path(id): Path, +) -> Result { + // 1. Get asset metadata to find file path + let asset = sqlx::query_as!( + Asset, + "SELECT * FROM assets WHERE id = $1 AND organization_id = $2", + id, + org_ctx.id + ) + .fetch_optional(&pool) + .await + .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?; + + // 2. Delete from DB + sqlx::query!("DELETE FROM assets WHERE id = $1", id) + .execute(&pool) + .await + .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 3. Delete physical file (async) + let _ = tokio::fs::remove_file(&asset.storage_path).await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 86ee9c1..b66e5ee 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -3,6 +3,7 @@ pub mod exporter; mod external_handlers; mod handlers; mod handlers_branding; +mod handlers_assets; mod handlers_dependencies; mod handlers_library; mod handlers_rubrics; @@ -161,9 +162,9 @@ async fn main() { .route("/users/{id}", axum::routing::put(handlers::update_user)) .route("/audit-logs", get(handlers::get_audit_logs)) .route("/api/ai/review-text", post(handlers::review_text)) - .route("/api/assets/upload", post(handlers::upload_asset)) - .route("/api/assets/{id}", delete(handlers::delete_asset)) - .route("/courses/{id}/assets", get(handlers::get_course_assets)) + .route("/api/assets", get(handlers_assets::list_assets)) + .route("/api/assets/upload", post(handlers_assets::upload_asset)) + .route("/api/assets/{id}", delete(handlers_assets::delete_asset)) .layer(DefaultBodyLimit::disable()) .route( "/organizations", diff --git a/services/lms-service/Cargo.toml b/services/lms-service/Cargo.toml index 2441352..46ea795 100644 --- a/services/lms-service/Cargo.toml +++ b/services/lms-service/Cargo.toml @@ -20,3 +20,5 @@ tower-http.workspace = true bcrypt.workspace = true jsonwebtoken.workspace = true reqwest = { version = "0.12", features = ["json"] } +urlencoding = "2.1" +base64 = "0.22" diff --git a/services/lms-service/build_errors.txt b/services/lms-service/build_errors.txt new file mode 100644 index 0000000..8a31e56 --- /dev/null +++ b/services/lms-service/build_errors.txt @@ -0,0 +1,620 @@ + Checking lms-service v0.1.0 (/home/juan/dev/openccb/services/lms-service) +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:154:22 + | +154 | let categories = sqlx::query!( + | ______________________^ +155 | | "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name", +156 | | course_id +157 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:163:20 + | +163 | let students = sqlx::query!( + | ____________________^ +164 | | r#" +165 | | SELECT +166 | | u.id, +... | +180 | | org_ctx.id +181 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:193:27 + | +193 | let detailed_grades = sqlx::query_as!( + | ___________________________^ +194 | | UserCategoryGrade, +195 | | r#" +196 | | SELECT +... | +205 | | course_id +206 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:896:24 + | +896 | let dependencies = sqlx::query_as!( + | ________________________^ +897 | | LessonDependency, +898 | | r#" +899 | | SELECT ld.* +... | +905 | | id +906 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:1004:30 + | +1004 | let unmet_dependencies = sqlx::query!( + | ______________________________^ +1005 | | r#" +1006 | | SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage +1007 | | FROM lesson_dependencies ld +... | +1020 | | claims.sub +1021 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_announcements.rs:55:23 + | +55 | let cohorts = sqlx::query!( + | _______________________^ +56 | | "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1", +57 | | a.id +58 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:21:46 + | +21 | let existing: Option = sqlx::query_as!( + | ______________________________________________^ +22 | | CourseSubmission, +23 | | "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2", +24 | | claims.sub, +25 | | lesson_id +26 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:33:23 + | +33 | let updated = sqlx::query_as!( + | _______________________^ +34 | | CourseSubmission, +35 | | r#" +36 | | UPDATE course_submissions +... | +43 | | lesson_id +44 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:53:22 + | +53 | let submission = sqlx::query_as!( + | ______________________^ +54 | | CourseSubmission, +55 | | r#" +56 | | INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content) +... | +64 | | payload.content +65 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:83:22 + | + 83 | let submission = sqlx::query_as!( + | ______________________^ + 84 | | CourseSubmission, + 85 | | r#" + 86 | | SELECT s.* +... | +105 | | org_ctx.id +106 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:122:22 + | +122 | let submission = sqlx::query!( + | ______________________^ +123 | | "SELECT user_id FROM course_submissions WHERE id = $1", +124 | | payload.submission_id +125 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:143:20 + | +143 | let existing = sqlx::query!( + | ____________________^ +144 | | "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2", +145 | | payload.submission_id, +146 | | claims.sub +147 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:160:18 + | +160 | let review = sqlx::query_as!( + | __________________^ +161 | | PeerReview, +162 | | r#" +163 | | INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id) +... | +171 | | org_ctx.id +172 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:187:19 + | +187 | let reviews = sqlx::query_as!( + | ___________________^ +188 | | PeerReview, +189 | | r#" +190 | | SELECT pr.* +... | +196 | | lesson_id +197 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0412]: cannot find type `AnalyticsFilter` in module `common::models` + --> services/lms-service/src/handlers.rs:1736:42 + | +1736 | Query(filter): Query, + | ^^^^^^^^^^^^^^^ not found in `common::models` + +error[E0412]: cannot find type `RecommendationResponse` in this scope + --> services/lms-service/src/handlers.rs:1802:18 + | +1802 | ) -> Result, (StatusCode, String)> { + | ^^^^^^^^^^^^^^^^^^^^^^ not found in this scope + | +help: consider importing this struct + | + 1 + use common::models::RecommendationResponse; + | + +error[E0412]: cannot find type `RecommendationResponse` in this scope + --> services/lms-service/src/handlers.rs:1945:22 + | +1945 | let ai_response: RecommendationResponse = response + | ^^^^^^^^^^^^^^^^^^^^^^ not found in this scope + | +help: consider importing this struct + | + 1 + use common::models::RecommendationResponse; + | + +error[E0425]: cannot find function `dangerous_insecure_decode` in crate `jsonwebtoken` + --> services/lms-service/src/lti.rs:107:51 + | +107 | let claims: serde_json::Value = jsonwebtoken::dangerous_insecure_decode(&payload.id_token) + | ^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `jsonwebtoken` + +warning: unused imports: `SubmitAssignmentPayload` and `SubmitPeerReviewPayload` + --> services/lms-service/src/handlers.rs:12:44 + | +12 | Module, Notification, Organization, SubmitAssignmentPayload, SubmitPeerReviewPayload, User, UserResponse, + | ^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +warning: unused import: `crate::lti` + --> services/lms-service/src/handlers.rs:14:5 + | +14 | use crate::lti; + | ^^^^^^^^^^ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:154:22 + | +154 | let categories = sqlx::query!( + | ______________________^ +155 | | "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name", +156 | | course_id +... | +159 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:160:15 + | +160 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +160 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:163:20 + | +163 | let students = sqlx::query!( + | ____________________^ +164 | | r#" +165 | | SELECT +166 | | u.id, +... | +182 | | .fetch_all(&pool) +183 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:184:15 + | +184 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +184 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:193:27 + | +193 | let detailed_grades = sqlx::query_as!( + | ___________________________^ +194 | | UserCategoryGrade, +195 | | r#" +196 | | SELECT +... | +207 | | .fetch_all(&pool) +208 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:209:15 + | +209 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +209 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:896:24 + | +896 | let dependencies = sqlx::query_as!( + | ________________________^ +897 | | LessonDependency, +898 | | r#" +899 | | SELECT ld.* +... | +907 | | .fetch_all(&pool) +908 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:1004:30 + | +1004 | let unmet_dependencies = sqlx::query!( + | ______________________________^ +1005 | | r#" +1006 | | SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage +1007 | | FROM lesson_dependencies ld +... | +1022 | | .fetch_all(&pool) +1023 | | .await + | |__________^ cannot infer type + +error[E0277]: the trait bound `for<'r> DailyProgress: FromRow<'r, _>` is not satisfied + --> services/lms-service/src/handlers.rs:1461:49 + | +1461 | let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>( + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `for<'r> FromRow<'r, _>` is not implemented for `DailyProgress` + | + = help: the following other types implement trait `FromRow<'r, R>`: + `()` implements `FromRow<'r, R>` + `(T1, T2)` implements `FromRow<'r, R>` + `(T1, T2, T3)` implements `FromRow<'r, R>` + `(T1, T2, T3, T4)` implements `FromRow<'r, R>` + `(T1, T2, T3, T4, T5)` implements `FromRow<'r, R>` + `(T1, T2, T3, T4, T5, T6)` implements `FromRow<'r, R>` + `(T1, T2, T3, T4, T5, T6, T7)` implements `FromRow<'r, R>` + `(T1, T2, T3, T4, T5, T6, T7, T8)` implements `FromRow<'r, R>` + and 58 others +note: required by a bound in `sqlx::query_as` + --> /home/juan/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sqlx-core-0.8.6/src/query_as.rs:345:8 + | + 342 | pub fn query_as<'q, DB, O>(sql: &'q str) -> QueryAs<'q, DB, O, ::Arguments<'q>> + | -------- required by a bound in this function +... + 345 | O: for<'r> FromRow<'r, DB::Row>, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `query_as` + +error[E0599]: the method `fetch_all` exists for struct `QueryAs<'_, Postgres, DailyProgress, PgArguments>`, but its trait bounds were not satisfied + --> services/lms-service/src/handlers.rs:1476:6 + | +1461 | let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>( + | _____________________________- +1462 | | r#" +1463 | | SELECT +1464 | | TO_CHAR(created_at, 'YYYY-MM-DD') as date, +... | +1475 | | .bind(org_ctx.id) +1476 | | .fetch_all(&pool) + | | -^^^^^^^^^ method cannot be called on `QueryAs<'_, Postgres, DailyProgress, PgArguments>` due to unsatisfied trait bounds + | |_____| + | + | + ::: /home/juan/dev/openccb/shared/common/src/models.rs:349:1 + | + 349 | pub struct DailyProgress { + | ------------------------ doesn't satisfy `DailyProgress: FromRow<'r, PgRow>` + | + = note: the following trait bounds were not satisfied: + `DailyProgress: FromRow<'r, PgRow>` + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:1461:29 + | +1461 | let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>( + | _____________________________^ +1462 | | r#" +1463 | | SELECT +1464 | | TO_CHAR(created_at, 'YYYY-MM-DD') as date, +... | +1476 | | .fetch_all(&pool) +1477 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_announcements.rs:55:23 + | +55 | let cohorts = sqlx::query!( + | _______________________^ +56 | | "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1", +57 | | a.id +... | +60 | | .await + | |______________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_announcements.rs:61:19 + | +61 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +61 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:21:46 + | +21 | let existing: Option = sqlx::query_as!( + | ______________________________________________^ +22 | | CourseSubmission, +23 | | "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2", +24 | | claims.sub, +... | +27 | | .fetch_optional(&pool) +28 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:29:15 + | +29 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +29 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:33:23 + | +33 | let updated = sqlx::query_as!( + | _______________________^ +34 | | CourseSubmission, +35 | | r#" +36 | | UPDATE course_submissions +... | +45 | | .fetch_one(&pool) +46 | | .await + | |______________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:47:19 + | +47 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +47 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:53:22 + | +53 | let submission = sqlx::query_as!( + | ______________________^ +54 | | CourseSubmission, +55 | | r#" +56 | | INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content) +... | +66 | | .fetch_one(&pool) +67 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:68:15 + | +68 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +68 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:83:22 + | + 83 | let submission = sqlx::query_as!( + | ______________________^ + 84 | | CourseSubmission, + 85 | | r#" + 86 | | SELECT s.* +... | +107 | | .fetch_optional(&pool) +108 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:109:15 + | +109 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +109 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:122:22 + | +122 | let submission = sqlx::query!( + | ______________________^ +123 | | "SELECT user_id FROM course_submissions WHERE id = $1", +124 | | payload.submission_id +... | +127 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:128:15 + | +128 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +128 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:143:20 + | +143 | let existing = sqlx::query!( + | ____________________^ +144 | | "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2", +145 | | payload.submission_id, +146 | | claims.sub +147 | | ) +148 | | .fetch_optional(&pool) +149 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:150:15 + | +150 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +150 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:160:18 + | +160 | let review = sqlx::query_as!( + | __________________^ +161 | | PeerReview, +162 | | r#" +163 | | INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id) +... | +173 | | .fetch_one(&pool) +174 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:175:15 + | +175 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +175 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:187:19 + | +187 | let reviews = sqlx::query_as!( + | ___________________^ +188 | | PeerReview, +189 | | r#" +190 | | SELECT pr.* +... | +198 | | .fetch_all(&pool) +199 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:200:15 + | +200 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +200 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +Some errors have detailed explanations: E0277, E0282, E0412, E0425, E0599. +For more information about an error, try `rustc --explain E0277`. +warning: `lms-service` (bin "lms-service") generated 2 warnings +error: could not compile `lms-service` (bin "lms-service") due to 47 previous errors; 2 warnings emitted diff --git a/services/lms-service/lti_errors.txt b/services/lms-service/lti_errors.txt new file mode 100644 index 0000000..3807ee7 --- /dev/null +++ b/services/lms-service/lti_errors.txt @@ -0,0 +1,518 @@ + Checking lms-service v0.1.0 (/home/juan/dev/openccb/services/lms-service) +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:154:22 + | +154 | let categories = sqlx::query!( + | ______________________^ +155 | | "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name", +156 | | course_id +157 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:163:20 + | +163 | let students = sqlx::query!( + | ____________________^ +164 | | r#" +165 | | SELECT +166 | | u.id, +... | +180 | | org_ctx.id +181 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:193:27 + | +193 | let detailed_grades = sqlx::query_as!( + | ___________________________^ +194 | | UserCategoryGrade, +195 | | r#" +196 | | SELECT +... | +205 | | course_id +206 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:896:24 + | +896 | let dependencies = sqlx::query_as!( + | ________________________^ +897 | | LessonDependency, +898 | | r#" +899 | | SELECT ld.* +... | +905 | | id +906 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers.rs:1004:30 + | +1004 | let unmet_dependencies = sqlx::query!( + | ______________________________^ +1005 | | r#" +1006 | | SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage +1007 | | FROM lesson_dependencies ld +... | +1020 | | claims.sub +1021 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_announcements.rs:55:23 + | +55 | let cohorts = sqlx::query!( + | _______________________^ +56 | | "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1", +57 | | a.id +58 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:21:46 + | +21 | let existing: Option = sqlx::query_as!( + | ______________________________________________^ +22 | | CourseSubmission, +23 | | "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2", +24 | | claims.sub, +25 | | lesson_id +26 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:33:23 + | +33 | let updated = sqlx::query_as!( + | _______________________^ +34 | | CourseSubmission, +35 | | r#" +36 | | UPDATE course_submissions +... | +43 | | lesson_id +44 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:53:22 + | +53 | let submission = sqlx::query_as!( + | ______________________^ +54 | | CourseSubmission, +55 | | r#" +56 | | INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content) +... | +64 | | payload.content +65 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:83:22 + | + 83 | let submission = sqlx::query_as!( + | ______________________^ + 84 | | CourseSubmission, + 85 | | r#" + 86 | | SELECT s.* +... | +105 | | org_ctx.id +106 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:122:22 + | +122 | let submission = sqlx::query!( + | ______________________^ +123 | | "SELECT user_id FROM course_submissions WHERE id = $1", +124 | | payload.submission_id +125 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:143:20 + | +143 | let existing = sqlx::query!( + | ____________________^ +144 | | "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2", +145 | | payload.submission_id, +146 | | claims.sub +147 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:160:18 + | +160 | let review = sqlx::query_as!( + | __________________^ +161 | | PeerReview, +162 | | r#" +163 | | INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id) +... | +171 | | org_ctx.id +172 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: error communicating with database: Connection refused (os error 111) + --> services/lms-service/src/handlers_peer_review.rs:187:19 + | +187 | let reviews = sqlx::query_as!( + | ___________________^ +188 | | PeerReview, +189 | | r#" +190 | | SELECT pr.* +... | +196 | | lesson_id +197 | | ) + | |_____^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused import: `crate::lti` + --> services/lms-service/src/handlers.rs:14:5 + | +14 | use crate::lti; + | ^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:154:22 + | +154 | let categories = sqlx::query!( + | ______________________^ +155 | | "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name", +156 | | course_id +... | +159 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:160:15 + | +160 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +160 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:163:20 + | +163 | let students = sqlx::query!( + | ____________________^ +164 | | r#" +165 | | SELECT +166 | | u.id, +... | +182 | | .fetch_all(&pool) +183 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:184:15 + | +184 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +184 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:193:27 + | +193 | let detailed_grades = sqlx::query_as!( + | ___________________________^ +194 | | UserCategoryGrade, +195 | | r#" +196 | | SELECT +... | +207 | | .fetch_all(&pool) +208 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:209:15 + | +209 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +209 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:896:24 + | +896 | let dependencies = sqlx::query_as!( + | ________________________^ +897 | | LessonDependency, +898 | | r#" +899 | | SELECT ld.* +... | +907 | | .fetch_all(&pool) +908 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers.rs:1004:30 + | +1004 | let unmet_dependencies = sqlx::query!( + | ______________________________^ +1005 | | r#" +1006 | | SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage +1007 | | FROM lesson_dependencies ld +... | +1022 | | .fetch_all(&pool) +1023 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_announcements.rs:55:23 + | +55 | let cohorts = sqlx::query!( + | _______________________^ +56 | | "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1", +57 | | a.id +... | +60 | | .await + | |______________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_announcements.rs:61:19 + | +61 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +61 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:21:46 + | +21 | let existing: Option = sqlx::query_as!( + | ______________________________________________^ +22 | | CourseSubmission, +23 | | "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2", +24 | | claims.sub, +... | +27 | | .fetch_optional(&pool) +28 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:29:15 + | +29 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +29 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:33:23 + | +33 | let updated = sqlx::query_as!( + | _______________________^ +34 | | CourseSubmission, +35 | | r#" +36 | | UPDATE course_submissions +... | +45 | | .fetch_one(&pool) +46 | | .await + | |______________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:47:19 + | +47 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +47 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:53:22 + | +53 | let submission = sqlx::query_as!( + | ______________________^ +54 | | CourseSubmission, +55 | | r#" +56 | | INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content) +... | +66 | | .fetch_one(&pool) +67 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:68:15 + | +68 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +68 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:83:22 + | + 83 | let submission = sqlx::query_as!( + | ______________________^ + 84 | | CourseSubmission, + 85 | | r#" + 86 | | SELECT s.* +... | +107 | | .fetch_optional(&pool) +108 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:109:15 + | +109 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +109 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:122:22 + | +122 | let submission = sqlx::query!( + | ______________________^ +123 | | "SELECT user_id FROM course_submissions WHERE id = $1", +124 | | payload.submission_id +... | +127 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:128:15 + | +128 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +128 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:143:20 + | +143 | let existing = sqlx::query!( + | ____________________^ +144 | | "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2", +145 | | payload.submission_id, +146 | | claims.sub +147 | | ) +148 | | .fetch_optional(&pool) +149 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:150:15 + | +150 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +150 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:160:18 + | +160 | let review = sqlx::query_as!( + | __________________^ +161 | | PeerReview, +162 | | r#" +163 | | INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id) +... | +173 | | .fetch_one(&pool) +174 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:175:15 + | +175 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +175 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:187:19 + | +187 | let reviews = sqlx::query_as!( + | ___________________^ +188 | | PeerReview, +189 | | r#" +190 | | SELECT pr.* +... | +198 | | .fetch_all(&pool) +199 | | .await + | |__________^ cannot infer type + +error[E0282]: type annotations needed + --> services/lms-service/src/handlers_peer_review.rs:200:15 + | +200 | .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ^ - type must be known at this point + | +help: consider giving this closure parameter an explicit type + | +200 | .map_err(|e: /* Type */| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + | ++++++++++++ + +For more information about this error, try `rustc --explain E0282`. +warning: `lms-service` (bin "lms-service") generated 1 warning +error: could not compile `lms-service` (bin "lms-service") due to 40 previous errors; 1 warning emitted diff --git a/services/lms-service/migrations/20260223000000_add_preview_and_teams.sql b/services/lms-service/migrations/20260223000000_add_preview_and_teams.sql new file mode 100644 index 0000000..5cff1b5 --- /dev/null +++ b/services/lms-service/migrations/20260223000000_add_preview_and_teams.sql @@ -0,0 +1,11 @@ +-- Migration to support course previews and multi-instructor sync +ALTER TABLE lessons ADD COLUMN is_previewable BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE course_instructors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'instructor', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(course_id, user_id) +); diff --git a/services/lms-service/migrations/20260224000000_add_bookmarks.sql b/services/lms-service/migrations/20260224000000_add_bookmarks.sql new file mode 100644 index 0000000..a691046 --- /dev/null +++ b/services/lms-service/migrations/20260224000000_add_bookmarks.sql @@ -0,0 +1,15 @@ +-- Create user_bookmarks table for students to save lessons +CREATE TABLE IF NOT EXISTS user_bookmarks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + user_id UUID NOT NULL, + course_id UUID NOT NULL, + lesson_id UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, lesson_id) +); + +-- Index for efficient querying +CREATE INDEX IF NOT EXISTS idx_user_bookmarks_user_id ON user_bookmarks(user_id); +CREATE INDEX IF NOT EXISTS idx_user_bookmarks_course_id ON user_bookmarks(course_id); +CREATE INDEX IF NOT EXISTS idx_user_bookmarks_org_id ON user_bookmarks(organization_id); diff --git a/services/lms-service/migrations/20260225000000_lti_tables.sql b/services/lms-service/migrations/20260225000000_lti_tables.sql new file mode 100644 index 0000000..7124c21 --- /dev/null +++ b/services/lms-service/migrations/20260225000000_lti_tables.sql @@ -0,0 +1,35 @@ +-- Migration: Add LTI 1.3 tables + +CREATE TABLE IF NOT EXISTS lti_registrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + issuer TEXT NOT NULL, + client_id TEXT NOT NULL, + deployment_id TEXT NOT NULL, + auth_token_url TEXT NOT NULL, + auth_login_url TEXT NOT NULL, + jwks_url TEXT NOT NULL, + platform_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(issuer, client_id, deployment_id) +); + +CREATE TABLE IF NOT EXISTS lti_nonces ( + nonce TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Delete nonces older than 1 hour (can be run via cron or during launch) +-- DELETE FROM lti_nonces WHERE created_at < NOW() - INTERVAL '1 hour'; + +CREATE TABLE IF NOT EXISTS lti_resource_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + resource_link_id TEXT NOT NULL, + course_id UUID NOT NULL REFERENCES courses(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(organization_id, resource_link_id) +); + +CREATE INDEX idx_lti_registrations_issuer_client ON lti_registrations(issuer, client_id); diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 7a624c4..d4b99b5 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -11,6 +11,30 @@ use common::models::{ AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics, Module, Notification, Organization, RecommendationResponse, User, UserResponse, }; + +pub async fn get_me( + claims: common::auth::Claims, + State(pool): State, +) -> Result, (StatusCode, String)> { + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(claims.sub) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(UserResponse { + id: user.id, + email: user.email, + full_name: user.full_name, + role: user.role, + organization_id: user.organization_id, + xp: user.xp, + level: user.level, + avatar_url: user.avatar_url, + bio: user.bio, + language: user.language, + })) +} use serde::{Deserialize, Serialize}; use sqlx::{PgPool, Row}; use std::env; @@ -644,6 +668,12 @@ pub async fn ingest_course( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + sqlx::query("DELETE FROM course_instructors WHERE course_id = $1") + .bind(payload.course.id) + .execute(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // 3. Insert Grading Categories for cat in payload.grading_categories { sqlx::query( @@ -662,6 +692,27 @@ pub async fn ingest_course( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } + // 4. Insert Instructors + if let Some(instructors) = payload.instructors { + for instructor in instructors { + sqlx::query( + "INSERT INTO course_instructors (id, course_id, user_id, role, created_at) + VALUES ($1, $2, $3, $4, $5)" + ) + .bind(instructor.id) + .bind(payload.course.id) + .bind(instructor.user_id) + .bind(&instructor.role) + .bind(instructor.created_at) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to insert instructor: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + } + } + // 4. Insert Modules and Lessons for pub_module in &payload.modules { sqlx::query( @@ -680,8 +731,8 @@ pub async fn ingest_course( for lesson in &pub_module.lessons { sqlx::query( - "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)" + "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry, organization_id, summary, due_date, important_date_type, transcription_status, is_previewable) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)" ) .bind(lesson.id) .bind(pub_module.module.id) @@ -701,6 +752,7 @@ pub async fn ingest_course( .bind(lesson.due_date) .bind(&lesson.important_date_type) .bind(&lesson.transcription_status) + .bind(lesson.is_previewable) .execute(&mut *tx) .await .map_err(|e| { @@ -858,11 +910,23 @@ pub async fn get_course_outline( StatusCode::INTERNAL_SERVER_ERROR })?; + // 7. Fetch Course Team + let instructors = sqlx::query_as::<_, common::models::CourseInstructor>( + "SELECT ci.*, u.email, u.full_name FROM course_instructors ci + JOIN users u ON ci.user_id = u.id + WHERE ci.course_id = $1" + ) + .bind(id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(common::models::PublishedCourse { course, organization, grading_categories, modules: pub_modules, + instructors: Some(instructors), dependencies: Some(dependencies), })) } @@ -903,8 +967,8 @@ pub async fn get_lesson_content( sqlx::query_as::<_, Lesson>( "SELECT l.* FROM lessons l JOIN modules m ON l.module_id = m.id - JOIN enrollments e ON m.course_id = e.course_id - WHERE l.id = $1 AND e.user_id = $2", + LEFT JOIN enrollments e ON m.course_id = e.course_id AND e.user_id = $2 + WHERE l.id = $1 AND (e.id IS NOT NULL OR l.is_previewable = true)", ) .bind(id) .bind(claims.sub) @@ -1363,6 +1427,95 @@ pub async fn get_course_analytics( })) } +pub async fn get_student_progress_stats( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, +) -> Result, (StatusCode, String)> { + let user_id = claims.sub; + + // 1. Total Lessons + let total_lessons: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)" + ) + .bind(org_ctx.id) + .bind(course_id) + .fetch_one(&pool) + .await + .unwrap_or(0); + + // 2. Completed Lessons + let completed_lessons: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3", + ) + .bind(user_id) + .bind(course_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .unwrap_or(0); + + // 3. Daily Progress (Last 30 days) + let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>( + r#" + SELECT + TO_CHAR(created_at, 'YYYY-MM-DD') as date, + COUNT(*)::bigint as count + FROM user_grades + WHERE user_id = $1 AND course_id = $2 AND organization_id = $3 + AND created_at >= NOW() - INTERVAL '30 days' + GROUP BY date + ORDER BY date ASC + "# + ) + .bind(user_id) + .bind(course_id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + // 4. Prediction Logic + let first_entry: Option> = sqlx::query_scalar( + "SELECT MIN(created_at) FROM user_grades WHERE user_id = $1 AND course_id = $2" + ) + .bind(user_id) + .bind(course_id) + .fetch_one(&pool) + .await + .unwrap_or(None); + + let estimated_completion_date = if let Some(start) = first_entry { + let days_passed = (chrono::Utc::now() - start).num_days().max(1) as f64; + let pace = completed_lessons as f64 / days_passed; + + if pace > 0.0 && total_lessons > completed_lessons { + let remaining = (total_lessons - completed_lessons) as f64; + let days_to_finish = (remaining / pace).ceil() as i64; + Some(chrono::Utc::now() + chrono::Duration::days(days_to_finish)) + } else { + None + } + } else { + None + }; + + let progress_percentage = if total_lessons > 0 { + (completed_lessons as f32 / total_lessons as f32) * 100.0 + } else { + 0.0 + }; + + Ok(Json(common::models::ProgressStats { + total_lessons, + completed_lessons, + progress_percentage, + daily_completions, + estimated_completion_date, + })) +} + pub async fn get_advanced_analytics( Org(org_ctx): Org, State(pool): State, @@ -1524,6 +1677,79 @@ pub async fn check_deadlines_and_notify(pool: PgPool) { } } +pub async fn toggle_bookmark( + Org(org_ctx): Org, + claims: Claims, + Path(lesson_id): Path, + State(pool): State, +) -> Result { + let user_id = claims.sub; + + // 1. Get course_id from lesson + let course_id: Uuid = sqlx::query_scalar( + "SELECT m.course_id FROM lessons l JOIN modules m ON l.module_id = m.id WHERE l.id = $1" + ) + .bind(lesson_id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".to_string()))?; + + // 2. Check if already bookmarked + let existing_id: Option = sqlx::query_scalar( + "SELECT id FROM user_bookmarks WHERE user_id = $1 AND lesson_id = $2" + ) + .bind(user_id) + .bind(lesson_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some(id) = existing_id { + // Remove bookmark + sqlx::query("DELETE FROM user_bookmarks WHERE id = $1") + .bind(id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(StatusCode::NO_CONTENT) + } else { + // Add bookmark + sqlx::query( + "INSERT INTO user_bookmarks (organization_id, user_id, course_id, lesson_id) VALUES ($1, $2, $3, $4)" + ) + .bind(org_ctx.id) + .bind(user_id) + .bind(course_id) + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(StatusCode::CREATED) + } +} + +pub async fn get_user_bookmarks( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Query(filter): Query, +) -> Result>, (StatusCode, String)> { + let user_id = claims.sub; + + let bookmarks = sqlx::query_as::<_, common::models::UserBookmark>( + "SELECT * FROM user_bookmarks WHERE user_id = $1 AND organization_id = $2 AND ($3::uuid IS NULL OR course_id = $3) ORDER BY created_at DESC" + ) + .bind(user_id) + .bind(org_ctx.id) + .bind(filter.cohort_id) // Reusing AnalyticsFilter which has cohort_id, but here we can use it for course_id or just ignore it. + // Wait, let's create a better filter for this. + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(bookmarks)) +} + pub async fn update_user( Org(org_ctx): Org, claims: common::auth::Claims, diff --git a/services/lms-service/src/handlers_discussions.rs b/services/lms-service/src/handlers_discussions.rs index 686fe88..7e06263 100644 --- a/services/lms-service/src/handlers_discussions.rs +++ b/services/lms-service/src/handlers_discussions.rs @@ -329,10 +329,10 @@ pub async fn create_post( .bind(thread_id) .fetch_one(&pool) .await - .map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?; + .map_err(|_| (StatusCode::NOT_FOUND, "Cohorte no encontrada".to_string()))?; if thread.0 { - return Err((StatusCode::FORBIDDEN, "Thread is locked".to_string())); + return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string())); } let post = sqlx::query_as::<_, DiscussionPost>( @@ -392,7 +392,7 @@ pub async fn vote_post( Json(payload): Json, ) -> Result { if payload.vote_type != "upvote" && payload.vote_type != "downvote" { - return Err((StatusCode::BAD_REQUEST, "Invalid vote type".to_string())); + return Err((StatusCode::BAD_REQUEST, "Tipo de voto inválido".to_string())); } // Upsert vote diff --git a/services/lms-service/src/lti.rs b/services/lms-service/src/lti.rs new file mode 100644 index 0000000..fe0833b --- /dev/null +++ b/services/lms-service/src/lti.rs @@ -0,0 +1,255 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{Redirect}, + Form, +}; +use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation}; +use serde::{Deserialize}; +use sqlx::{PgPool}; +use uuid::Uuid; +use common::models::{LtiLaunchClaims, LtiRegistration, LtiResourceLink, User}; +use common::auth::Claims; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + +#[derive(Deserialize)] +pub struct LtiLoginParams { + pub iss: String, + pub login_hint: String, + pub target_link_uri: String, + pub lti_message_hint: Option, + pub client_id: Option, + pub lti_deployment_id: Option, +} + +pub async fn lti_login_initiation( + State(pool): State, + Query(params): Query, +) -> Result { + // 1. Find registration + let registration = sqlx::query_as::<_, LtiRegistration>( + "SELECT * FROM lti_registrations WHERE issuer = $1 AND ($2::text IS NULL OR client_id = $2)" + ) + .bind(¶ms.iss) + .bind(¶ms.client_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::BAD_REQUEST, "LTI Registration not found".to_string()))?; + + // 2. Generate state and nonce + let state = Uuid::new_v4().to_string(); + let nonce = Uuid::new_v4().to_string(); + + // 3. Store nonce + sqlx::query("INSERT INTO lti_nonces (nonce) VALUES ($1)") + .bind(&nonce) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 4. Construct redirect URL + let mut url = format!( + "{}?scope=openid&response_type=id_token&client_id={}&redirect_uri={}&login_hint={}&state={}&nonce={}&response_mode=form_post", + registration.auth_login_url, + registration.client_id, + urlencoding::encode(¶ms.target_link_uri), + urlencoding::encode(¶ms.login_hint), + state, + nonce + ); + + if let Some(hint) = params.lti_message_hint { + url.push_str(&format!("<i_message_hint={}", urlencoding::encode(&hint))); + } + + Ok(Redirect::to(&url)) +} + +#[derive(Deserialize)] +pub struct LtiLaunchParams { + pub id_token: String, + pub state: String, +} + +pub async fn validate_lti_jwt( + id_token: &str, + jwks_url: &str, + client_id: &str, +) -> Result { + let header = decode_header(id_token).map_err(|e| e.to_string())?; + let kid = header.kid.ok_or("Missing kid in JWT header")?; + + // Fetch JWKS + let jwks: JwkSet = reqwest::get(jwks_url) + .await + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + let jwk = jwks.find(&kid).ok_or("JWK not found for kid")?; + let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| e.to_string())?; + + let mut validation = Validation::new(jsonwebtoken::Algorithm::RS256); + validation.set_audience(&[client_id]); + + let token_data = decode::(id_token, &decoding_key, &validation) + .map_err(|e| e.to_string())?; + + Ok(token_data.claims) +} + +pub async fn lti_launch( + State(pool): State, + Form(payload): Form, +) -> Result { + // 1. Decode claims manually to find registration (since we don't have the key yet) + let parts: Vec<&str> = payload.id_token.split('.').collect(); + if parts.len() != 3 { + return Err((StatusCode::BAD_REQUEST, "Invalid JWT format".to_string())); + } + + let decoded_claims = URL_SAFE_NO_PAD.decode(parts[1]) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64 in JWT payload: {}", e)))?; + + let claims: serde_json::Value = serde_json::from_slice(&decoded_claims) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON in JWT payload: {}", e)))?; + + let iss = claims["iss"].as_str().ok_or((StatusCode::BAD_REQUEST, "Missing iss claim".to_string()))?; + let aud_val = &claims["aud"]; + let aud = match aud_val { + serde_json::Value::String(s) => s.as_str(), + serde_json::Value::Array(arr) => arr[0].as_str().ok_or((StatusCode::BAD_REQUEST, "Invalid aud in array".to_string()))?, + _ => return Err((StatusCode::BAD_REQUEST, "Invalid aud claim".to_string())), + }; + + // 2. Find registration + let registration = sqlx::query_as::<_, LtiRegistration>( + "SELECT * FROM lti_registrations WHERE issuer = $1 AND client_id = $2" + ) + .bind(iss) + .bind(aud) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "LTI Registration not found for issuer/aud".to_string()))?; + + // 3. Validate JWT + let lti_claims = validate_lti_jwt(&payload.id_token, ®istration.jwks_url, ®istration.client_id) + .await + .map_err(|e| (StatusCode::UNAUTHORIZED, format!("JWT validation failed: {}", e)))?; + + // 4. Verify nonce + let nonce_exists = sqlx::query("DELETE FROM lti_nonces WHERE nonce = $1") + .bind(<i_claims.nonce) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .rows_affected() > 0; + + if !nonce_exists { + return Err((StatusCode::BAD_REQUEST, "Invalid or expired nonce".to_string())); + } + + // 5. Find or create user + let email = lti_claims.email.clone().unwrap_or_else(|| format!("lti_{}@{}", lti_claims.subject, iss.replace("http://", "").replace("https://", ""))); + let full_name = lti_claims.name.clone().unwrap_or_else(|| "LTI User".to_string()); + + let mut user = sqlx::query_as::<_, User>( + "SELECT * FROM users WHERE email = $1 AND organization_id = $2" + ) + .bind(&email) + .bind(registration.organization_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if user.is_none() { + let new_user_id = Uuid::new_v4(); + let role = if lti_claims.roles.iter().any(|r| r.contains("Instructor") || r.contains("Administrator")) { + "instructor" + } else { + "student" + }; + + sqlx::query( + "INSERT INTO users (id, organization_id, email, password_hash, full_name, role) VALUES ($1, $2, $3, $4, $5, $6)" + ) + .bind(new_user_id) + .bind(registration.organization_id) + .bind(&email) + .bind("") + .bind(&full_name) + .bind(role) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + user = Some(User { + id: new_user_id, + organization_id: registration.organization_id, + email: email.clone(), + password_hash: "".to_string(), + full_name: full_name.clone(), + role: role.to_string(), + xp: 0, + level: 1, + avatar_url: None, + bio: None, + language: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }); + } + + 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()))?; + + let redirect_target = if let Some(link) = resource_link { + sqlx::query( + "INSERT INTO enrollments (user_id, organization_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING" + ) + .bind(user.id) + .bind(registration.organization_id) + .bind(link.course_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + format!("/courses/{}", link.course_id) + } else { + "/dashboard".to_string() + }; + + // 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()), + ) + .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)))) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 3258053..b23a013 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -6,6 +6,7 @@ mod handlers_discussions; mod handlers_notes; mod handlers_payments; mod handlers_peer_review; +mod lti; use axum::{ Router, middleware, @@ -50,6 +51,7 @@ async fn main() { .allow_headers(Any); let protected_routes = Router::new() + .route("/auth/me", get(handlers::get_me)) .route("/enroll", post(handlers::enroll_user)) .route("/bulk-enroll", post(handlers::bulk_enroll_users)) .route("/enrollments/{id}", get(handlers::get_user_enrollments)) @@ -58,7 +60,10 @@ async fn main() { post(handlers_payments::create_payment_preference), ) .route("/courses/{id}/outline", get(handlers::get_course_outline)) + .route("/courses/{id}/progress-stats", get(handlers::get_student_progress_stats)) .route("/lessons/{id}", get(handlers::get_lesson_content)) + .route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark)) + .route("/bookmarks", get(handlers::get_user_bookmarks)) .route("/grades", post(handlers::submit_lesson_score)) .route( "/users/{user_id}/courses/{course_id}/grades", @@ -203,6 +208,8 @@ async fn main() { "/payments/mercadopago/webhook", post(handlers_payments::mercadopago_webhook), ) + .route("/lti/login", get(lti::lti_login_initiation)) + .route("/lti/launch", post(lti::lti_launch)) .merge(protected_routes) .layer(cors) .with_state(pool); diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 6934801..ede9df6 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -50,6 +50,7 @@ pub struct Lesson { pub due_date: Option>, pub important_date_type: Option, // "exam", "assignment", "milestone", etc. pub transcription_status: Option, + pub is_previewable: bool, pub created_at: DateTime, } @@ -128,10 +129,97 @@ pub struct Enrollment { pub enrolled_at: DateTime, } -#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct UserBookmark { + pub id: Uuid, + pub organization_id: Uuid, + pub user_id: Uuid, + pub course_id: Uuid, + pub lesson_id: Uuid, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct CourseInstructor { + pub id: Uuid, + pub organization_id: Uuid, + pub course_id: Uuid, + pub user_id: Uuid, + pub role: String, // "primary", "instructor", "assistant" + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct LtiRegistration { + pub id: Uuid, + pub organization_id: Uuid, + pub issuer: String, + pub client_id: String, + pub deployment_id: String, + pub auth_token_url: String, + pub auth_login_url: String, + pub jwks_url: String, + pub platform_name: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct LtiResourceLink { + pub id: Uuid, + pub organization_id: Uuid, + pub resource_link_id: String, + pub course_id: Uuid, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LtiLaunchClaims { + #[serde(rename = "iss")] + pub issuer: String, + #[serde(rename = "sub")] + pub subject: String, + #[serde(rename = "aud")] + pub audience: serde_json::Value, // Can be string or array + #[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, + #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/version")] + pub version: String, + #[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, + #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/context")] + pub context: Option, + #[serde(rename = "https://purl.imsglobal.org/spec/lti/claim/roles")] + pub roles: Vec, + pub name: Option, + pub email: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LtiResourceLinkClaim { + pub id: String, + pub title: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LtiContextClaim { + pub id: String, + pub label: Option, + pub title: Option, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Asset { pub id: Uuid, pub organization_id: Uuid, + pub uploaded_by: Option, pub course_id: Option, pub filename: String, pub storage_path: String, @@ -212,6 +300,8 @@ pub struct PublishedCourse { pub grading_categories: Vec, pub modules: Vec, #[serde(skip_serializing_if = "Option::is_none")] + pub instructors: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub dependencies: Option>, } @@ -255,6 +345,40 @@ pub struct AdvancedAnalytics { pub cohorts: Vec, pub retention: Vec, } + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DailyProgress { + pub date: String, + pub count: i64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AnalyticsFilter { + pub cohort_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Recommendation { + pub title: String, + pub description: String, + pub lesson_id: Option, + pub priority: String, // "high", "medium", "low" + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RecommendationResponse { + pub recommendations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgressStats { + pub total_lessons: i64, + pub completed_lessons: i64, + pub progress_percentage: f32, + pub daily_completions: Vec, + pub estimated_completion_date: Option>, +} #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] pub struct Webhook { pub id: Uuid, @@ -295,19 +419,6 @@ pub struct AuditLog { pub created_at: DateTime, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Recommendation { - pub title: String, - pub description: String, - pub lesson_id: Option, - pub priority: String, // "high", "medium", "low" - pub reason: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct RecommendationResponse { - pub recommendations: Vec, -} // Discussion Forums Models #[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] @@ -668,6 +779,7 @@ mod tests { }, grading_categories: vec![], modules: vec![pub_module], + instructors: None, }; let course_with_price = Course { diff --git a/validate_auth.sh b/validate_auth.sh index 2fb601c..4ddda2a 100755 --- a/validate_auth.sh +++ b/validate_auth.sh @@ -1,24 +1,24 @@ #!/bin/bash -# 1. Verify Juan Login -echo "Testing Login for juan.allende@gmail.com..." +# 1. Verificar Login de Juan +echo "Probando Login para juan.allende@gmail.com..." HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:3001/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"juan.allende@gmail.com","password":"password123"}') if [ "$HTTP_CODE" -eq 200 ]; then - echo "SUCCESS: Login worked for juan.allende@gmail.com with password123" + echo "ÉXITO: El login funcionó para juan.allende@gmail.com con password123" else - echo "FAIL: Login failed with status $HTTP_CODE" - # Print body for debugging + echo "FALLO: El login falló con estado $HTTP_CODE" + # Imprimir cuerpo para depuración curl -s -X POST http://localhost:3001/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"juan.allende@gmail.com","password":"password123"}' echo "" fi -# 3. Verify Organization Context (Course Scoping) -echo "Testing Course Scoping by Organization..." -# Login to get token +# 3. Verificar Contexto de Organización (Scoping de Cursos) +echo "Probando Scoping de Cursos por Organización..." +# Login para obtener token USER_DATA=$(curl -s -X POST http://localhost:3001/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"juan.allende@gmail.com","password":"password123"}') @@ -26,40 +26,40 @@ TOKEN=$(echo "$USER_DATA" | jq -r '.token') ORG_ID=$(echo "$USER_DATA" | jq -r '.user.organization_id') if [ "$TOKEN" != "null" ]; then - echo "SUCCESS: Got token for juan.allende@gmail.com" - # Try to list courses + echo "ÉXITO: Se obtuvo el token para juan.allende@gmail.com" + # Intentar listar cursos HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET http://localhost:3001/courses \ -H "Authorization: Bearer $TOKEN") if [ "$HTTP_CODE" -eq 200 ]; then - echo "SUCCESS: Courses retrieved successfully with organization scope" + echo "ÉXITO: Cursos recuperados correctamente con scope de organización" else - echo "FAIL: Failed to retrieve courses (Status: $HTTP_CODE)" + echo "FALLO: Error al recuperar cursos (Estado: $HTTP_CODE)" fi - # 4. Verify Admin Context Switching (X-Organization-Id) - # Create a dummy organization to test switching - echo "Testing Admin Context Switching (X-Organization-Id)..." + # 4. Verificar Cambio de Contexto de Admin (X-Organization-Id) + # Crear una organización ficticia para probar el cambio + echo "Probando Cambio de Contexto de Admin (X-Organization-Id)..." NEW_ORG_ID=$(curl -s -X POST http://localhost:3001/organizations \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ - -d '{"name": "Context Switching Test"}' | jq -r '.id') + -d '{"name": "Prueba de Cambio de Contexto"}' | jq -r '.id') if [ "$NEW_ORG_ID" != "null" ]; then - echo "SUCCESS: New organization created ($NEW_ORG_ID)" - # Try to list courses using the new org context + echo "ÉXITO: Nueva organización creada ($NEW_ORG_ID)" + # Intentar listar cursos usando el nuevo contexto de org HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET http://localhost:3001/courses \ -H "Authorization: Bearer $TOKEN" \ -H "X-Organization-Id: $NEW_ORG_ID") if [ "$HTTP_CODE" -eq 200 ]; then - echo "SUCCESS: Context switching worked via X-Organization-Id" + echo "ÉXITO: El cambio de contexto funcionó vía X-Organization-Id" else - echo "FAIL: Context switching failed (Status: $HTTP_CODE)" + echo "FALLO: El cambio de contexto falló (Estado: $HTTP_CODE)" fi else - echo "FAIL: Could not create test organization" + echo "FALLO: No se pudo crear la organización de prueba" fi else - echo "FAIL: Could not get token for testing organization context" + echo "FALLO: No se pudo obtener el token para probar el contexto de organización" fi diff --git a/web/experience/package-lock.json b/web/experience/package-lock.json index f1892eb..a0cefd3 100644 --- a/web/experience/package-lock.json +++ b/web/experience/package-lock.json @@ -11,14 +11,17 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^11.2.10", + "lodash": "^4.17.21", "lucide-react": "^0.395.0", "next": "14.2.21", "react": "^18", "react-dom": "^18", "react-markdown": "^10.1.0", + "recharts": "^3.7.0", "tailwind-merge": "^2.3.0" }, "devDependencies": { + "@types/lodash": "^4.17.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -461,6 +464,42 @@ "node": ">=14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -473,6 +512,18 @@ "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -497,6 +548,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -536,6 +650,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -590,6 +711,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", @@ -1816,6 +1943,127 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -1899,6 +2147,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -2199,6 +2453,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2656,6 +2920,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3238,6 +3508,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3300,6 +3580,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -3954,6 +4243,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5382,7 +5677,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -5411,6 +5706,30 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5432,6 +5751,52 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5507,6 +5872,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6274,6 +6645,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6683,6 +7060,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6717,6 +7103,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/experience/package.json b/web/experience/package.json index ab4d800..a33b6c3 100644 --- a/web/experience/package.json +++ b/web/experience/package.json @@ -12,17 +12,18 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^11.2.10", + "lodash": "^4.17.21", "lucide-react": "^0.395.0", "next": "14.2.21", - "lodash": "^4.17.21", "react": "^18", "react-dom": "^18", "react-markdown": "^10.1.0", + "recharts": "^3.7.0", "tailwind-merge": "^2.3.0" }, "devDependencies": { - "@types/node": "^20", "@types/lodash": "^4.17.0", + "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.4.19", @@ -32,4 +33,4 @@ "tailwindcss": "^3.4.1", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/web/experience/src/app/bookmarks/page.tsx b/web/experience/src/app/bookmarks/page.tsx new file mode 100644 index 0000000..ee431ff --- /dev/null +++ b/web/experience/src/app/bookmarks/page.tsx @@ -0,0 +1,127 @@ +"use client"; + +import React, { useEffect, useState } from 'react'; +import { lmsApi, UserBookmark, Course, Module } from '@/lib/api'; +import { Bookmark, ChevronRight, BookOpen, Clock, Trash2 } from 'lucide-react'; +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; +import { es } from 'date-fns/locale'; + +export default function BookmarksPage() { + const [bookmarks, setBookmarks] = useState([]); + const [courses, setCourses] = useState>({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchBookmarks = async () => { + try { + const data = await lmsApi.getBookmarks(); + setBookmarks(data); + + // Fetch course details for each unique course_id + const courseIds = [...new Set(data.map(b => b.course_id))]; + const courseData: Record = {}; + + await Promise.all(courseIds.map(async (id) => { + try { + const outline = await lmsApi.getCourseOutline(id); + courseData[id] = { ...outline.course, modules: outline.modules }; + } catch (e) { + console.error(`Error fetching course ${id}`, e); + } + })); + setCourses(courseData); + } catch (err) { + console.error("Error fetching bookmarks:", err); + } finally { + setLoading(false); + } + }; + + fetchBookmarks(); + }, []); + + const handleRemoveBookmark = async (lessonId: string) => { + try { + await lmsApi.toggleBookmark(lessonId); + setBookmarks(prev => prev.filter(b => b.lesson_id !== lessonId)); + } catch (err) { + console.error("Error removing bookmark:", err); + } + }; + + if (loading) return
Cargando Marcadores...
; + + return ( +
+
+
+
+ +
+

Mis Lecciones Guardadas

+
+

Acceso rápido a los contenidos que marcaste como importantes

+
+ + {bookmarks.length === 0 ? ( +
+
+ +
+

Aún no tienes marcadores

+

Cuando encuentres una lección interesante, haz clic en el icono de marcador para guardarla aquí.

+
+ ) : ( +
+ {bookmarks.map((bookmark) => { + const course = courses[bookmark.course_id]; + return ( +
+
+
+ +
+
+
+ {course?.title || 'Curso'} + + + + Guardado {formatDistanceToNow(new Date(bookmark.created_at), { addSuffix: true, locale: es })} + +
+ +

+ {(() => { + const lesson = course?.modules?.flatMap(m => m.lessons).find(l => l.id === bookmark.lesson_id); + return lesson?.title || `Lección ${bookmark.lesson_id.substring(0, 8)}`; + })()} +

+ +
+
+ +
+ + + Continuar + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 6808771..1a9c471 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { lmsApi, Lesson, Course, Module, UserGrade } from "@/lib/api"; import Link from "next/link"; -import { ChevronLeft, ChevronRight, Menu, CheckCircle2 } from "lucide-react"; +import { ChevronLeft, ChevronRight, Menu, CheckCircle2, Bookmark } from "lucide-react"; import { useAuth } from "@/context/AuthContext"; import DescriptionPlayer from "@/components/blocks/DescriptionPlayer"; @@ -35,6 +35,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les const [currentTime, setCurrentTime] = useState(0); const [userGrade, setUserGrade] = useState(null); const [allGrades, setAllGrades] = useState([]); + const [isBookmarked, setIsBookmarked] = useState(false); const { user } = useAuth(); useEffect(() => { @@ -48,10 +49,14 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les setCourse({ ...outlineData.course, modules: outlineData.modules }); if (user) { - const grades = await lmsApi.getUserGrades(user.id, params.id); + const [grades, bookmarks] = await Promise.all([ + lmsApi.getUserGrades(user.id, params.id), + lmsApi.getBookmarks(params.id) + ]); setAllGrades(grades); const currentGrade = grades.find((g: UserGrade) => g.lesson_id === params.lessonId); setUserGrade(currentGrade || null); + setIsBookmarked(bookmarks.some(b => b.lesson_id === params.lessonId)); } } catch (err) { console.error("Error al cargar los datos de la lección", err); @@ -129,6 +134,15 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les } }; + const handleToggleBookmark = async () => { + try { + await lmsApi.toggleBookmark(params.lessonId); + setIsBookmarked(!isBookmarked); + } catch (err) { + console.error("Error toggling bookmark", err); + } + }; + const getLessonStatus = (l: Lesson) => { const grade = allGrades.find(g => g.lesson_id === l.id); const isCurrent = l.id === params.lessonId; @@ -215,6 +229,13 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les > + {hasTranscription && ( -

{course.title}

+
+
+
+ + + Volver al curso + +
+
+ +
+

Tu Progreso de Aprendizaje

+

Análisis detallado de tu avance y predicción de finalización

-
- {/* Left: Overall Progress */} -
-
-
-

Estado General

+ -
- - - - -
- {Math.round(totalWeightedGrade)}% - Calificación Actual -
-
- - {/* Performance Bar */} -
- -
+
+

¿Cómo se calcula mi progreso?

+
+
+ Completado +

Consideramos una lección como completada cuando has visualizado el contenido o aprobado la evaluación correspondiente.

- -
-

- Resumen de Evaluaciones -

-
- {categoryStats.map(stat => ( -
-
-
- {stat.name} -
- {Math.round(stat.weightedScore)} / {stat.weight}% -
- ))} -
+
+ Predicción +

Calculamos tu fecha estimada analizando cuántas lecciones completas por día desde que iniciaste el curso.

+
+
+ Recomendaciones +

Si tu ritmo es bajo, verás sugerencias personalizadas en la página principal para ayudarte a retomar el camino.

-
- - {/* Right: Detailed Breakdown */} -
-
-

- - Desglose Detallado -

- -
- {categoryStats.map(cat => ( -
-
-
-

{cat.name}

-

- Peso: {cat.weight}% de la calificación total del curso -

-
-
-
{Math.round(cat.avgScore)}%
-
Puntuación Promedio
-
-
- -
-
-
-
- -
-
-
- - {cat.completedCount} / {cat.count} evaluaciones completadas -
-
- {cat.completedCount === cat.count && ( -
- - Categoría Finalizada -
- )} -
-
-
- ))} -
-
- - {/* Certificate Section */} - {totalWeightedGrade >= (course.passing_percentage || 70) ? ( -
-
-
- -
-
-

¡Curso Completado!

-

- ¡Felicidades! Has aprobado {course.title}. -

-
-
- -
- ) : ( -
-
-
- -
-
-

Ruta de Certificación

-

- Mantén {course.passing_percentage || 70}% o más para obtener tu certificado verificado. -

-
-
-
- Falta {Math.max(0, (course.passing_percentage || 70) - Math.round(totalWeightedGrade))}% -
-
- )}
diff --git a/web/experience/src/app/lti/launch/page.tsx b/web/experience/src/app/lti/launch/page.tsx new file mode 100644 index 0000000..5203037 --- /dev/null +++ b/web/experience/src/app/lti/launch/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { lmsApi } from "@/lib/api"; +import { useAuth } from "@/context/AuthContext"; + +function LtiLaunchContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { login } = useAuth(); + const [error, setError] = useState(null); + + useEffect(() => { + const handleLaunch = async () => { + const token = searchParams.get("token"); + const target = searchParams.get("target") || "/dashboard"; + + if (!token) { + setError("No launch token provided"); + return; + } + + try { + // 1. Temporarily save token so api client can use it for getMe + localStorage.setItem("experience_token", token); + + // 2. Fetch user details + const user = await lmsApi.getMe(); + + // 3. Initialize session in AuthContext + login(user, token); + + // 4. Redirect to final destination + router.replace(target); + } catch (err: any) { + console.error("LTI Launch Error:", err); + setError("Failed to initialize session. Please try again."); + } + }; + + handleLaunch(); + }, [searchParams, login, router]); + + if (error) { + return ( +
+
+
⚠️
+

Error de Inicio

+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+
+
+
+
+

Iniciando Sesión

+

OpenCCB LTI Gateway

+
+
+
+ ); +} + +export default function LtiLaunchPage() { + return ( + + + + ); +} diff --git a/web/experience/src/components/AppHeader.tsx b/web/experience/src/components/AppHeader.tsx index 19ddf75..9c2b953 100644 --- a/web/experience/src/components/AppHeader.tsx +++ b/web/experience/src/components/AppHeader.tsx @@ -48,6 +48,9 @@ export default function AppHeader() { {t('nav.myLearning')} + + {t('nav.bookmarks')} +
@@ -117,6 +120,13 @@ export default function AppHeader() { > {t('nav.myLearning')} + setIsMenuOpen(false)} + className="text-sm font-black uppercase tracking-widest text-gray-300 hover:text-white border-l-2 border-transparent hover:border-blue-500 pl-4 transition-all" + > + {t('nav.bookmarks')} +
diff --git a/web/experience/src/components/ProgressDashboard.tsx b/web/experience/src/components/ProgressDashboard.tsx new file mode 100644 index 0000000..31008ad --- /dev/null +++ b/web/experience/src/components/ProgressDashboard.tsx @@ -0,0 +1,181 @@ +"use client"; + +import React, { useEffect, useState } from 'react'; +import { lmsApi, ProgressStats } from '@/lib/api'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + AreaChart, + Area +} from 'recharts'; +import { Calendar, CheckCircle2, TrendingUp, Clock, AlertTriangle } from 'lucide-react'; +import { format, parseISO } from 'date-fns'; +import { es } from 'date-fns/locale'; + +interface ProgressDashboardProps { + courseId: string; +} + +const ProgressDashboard: React.FC = ({ courseId }) => { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + try { + const data = await lmsApi.getProgressStats(courseId); + setStats(data); + } catch (err) { + console.error("Error fetching progress stats:", err); + setError("No se pudieron cargar las estadísticas de progreso."); + } finally { + setLoading(false); + } + }; + + fetchStats(); + }, [courseId]); + + if (loading) return
+
+
+
; + + if (error || !stats) return
+ + {error || "Error al cargar datos."} +
; + + const chartData = stats.daily_completions.map(d => ({ + date: format(parseISO(d.date), 'dd MMM', { locale: es }), + count: d.count + })); + + return ( +
+ {/* Summary Cards */} +
+
+
+ Progreso Total + +
+
+ {Math.round(stats.progress_percentage)}% + Completado +
+
+
+
+
+ +
+
+ Lecciones + +
+
+ {stats.completed_lessons} + de {stats.total_lessons} +
+

Lecciones finalizadas con éxito

+
+ +
+
+ Predicción + +
+
+ + {stats.estimated_completion_date + ? format(parseISO(stats.estimated_completion_date), "d 'de' MMMM", { locale: es }) + : "N/A" + } + + Fecha estimada de cierre +
+
+ +
+
+ Estado + +
+
+ + {stats.progress_percentage >= 80 ? 'Excelente' : stats.progress_percentage >= 50 ? 'Buen Ritmo' : 'En Progreso'} + + Según tu ritmo actual +
+
+
+ + {/* Activity Chart */} +
+
+
+

Actividad de Aprendizaje

+

Lecciones completadas por día (Últimos 30 días)

+
+
+ +
+ + + + + + + + + + + + + + + +
+
+
+ ); +}; + +export default ProgressDashboard; diff --git a/web/experience/src/components/blocks/PeerReviewPlayer.tsx b/web/experience/src/components/blocks/PeerReviewPlayer.tsx index 1067817..ea2fdba 100644 --- a/web/experience/src/components/blocks/PeerReviewPlayer.tsx +++ b/web/experience/src/components/blocks/PeerReviewPlayer.tsx @@ -183,7 +183,7 @@ export default function PeerReviewPlayer({ courseId, lessonId, block }: PeerRevi > 👀
Review a Peer
-
Earn credit by reviewing other students' work.
+
Earn credit by reviewing other students' work.
+
+
+

+ 🔓 Course Preview +

+

Allow students to view this lesson without being enrolled

+
+ +
+ {isGraded && ( <>
diff --git a/web/studio/src/app/courses/[id]/team/page.tsx b/web/studio/src/app/courses/[id]/team/page.tsx new file mode 100644 index 0000000..e97490b --- /dev/null +++ b/web/studio/src/app/courses/[id]/team/page.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { cmsApi, CourseInstructor, User } from "@/lib/api"; +import { useParams } from "next/navigation"; +import { Plus, Trash2, UserPlus, Shield, ShieldCheck, ShieldAlert, X, Search, Check, Mail, GraduationCap } from "lucide-react"; +import CourseEditorLayout from "@/components/CourseEditorLayout"; + +export default function CourseTeamPage() { + const { id } = useParams() as { id: string }; + const [instructors, setInstructors] = useState([]); + const [loading, setLoading] = useState(true); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [users, setUsers] = useState([]); + const [searching, setSearching] = useState(false); + const [selectedRole, setSelectedRole] = useState<'instructor' | 'assistant'>('instructor'); + + useEffect(() => { + loadTeam(); + }, [id]); + + const loadTeam = async () => { + try { + setLoading(true); + const team = await cmsApi.getCourseTeam(id); + setInstructors(team); + } catch (err) { + console.error("Failed to load team:", err); + } finally { + setLoading(false); + } + }; + + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); + if (!searchQuery.includes('@')) return; // Basic validation for email search + + try { + setSearching(true); + // This is a bit of a hack since we don't have a specific "search users" endpoint + // for instructors. Let's assume listOrganizationsUsers or similar could work, + // or just try to find by email if we had that endpoint. + // For now, let's try to list all users and filter locally as a fallback. + const allUsers = await cmsApi.getUsers(); + const filtered = allUsers.filter((u: User) => + u.email.toLowerCase().includes(searchQuery.toLowerCase()) || + u.full_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + setUsers(filtered); + } catch (err) { + console.error("Search failed:", err); + } finally { + setSearching(false); + } + }; + + const handleAddMember = async (user: User) => { + try { + await cmsApi.addTeamMember(id, user.email, selectedRole); + setIsAddModalOpen(false); + setSearchQuery(""); + setUsers([]); + loadTeam(); + } catch (err) { + console.error("Failed to add member:", err); + alert("Failed to add team member."); + } + }; + + const handleRemoveMember = async (userId: string) => { + if (!confirm("Are you sure you want to remove this instructor?")) return; + try { + await cmsApi.removeTeamMember(id, userId); + setInstructors(instructors.filter(i => i.user_id !== userId)); + } catch (err) { + console.error("Failed to remove member:", err); + alert("Failed to remove team member."); + } + }; + + const getRoleIcon = (role: string) => { + switch (role) { + case 'primary': return ; + case 'instructor': return ; + default: return ; + } + }; + + const getRoleLabel = (role: string) => { + switch (role) { + case 'primary': return 'Primary Instructor'; + case 'instructor': return 'Instructor'; + default: return 'Assistant'; + } + }; + + return ( +
+
+
+

Course Team

+

Manage multiple instructors and assistants for this course

+
+ +
+ + +
+ {loading ? ( +
Loading team members...
+ ) : instructors.length === 0 ? ( +
+ +

No instructors assigned yet.

+
+ ) : ( + instructors.map((inst) => ( +
+
+
+ {inst.full_name?.charAt(0) || inst.email?.charAt(0)} +
+
+

+ {inst.full_name} + {inst.role === 'primary' && ( + + Owner + + )} +

+
+ + {inst.email} + + + + {getRoleIcon(inst.role)} {getRoleLabel(inst.role)} + +
+
+
+
+ {inst.role !== 'primary' && ( + + )} +
+
+ )) + )} +
+
+ + {/* Add Member Modal */} + {isAddModalOpen && ( +
+
+
+
+

Add Team Member

+

Search for a user by name or email

+
+ +
+ +
+
+ + setSearchQuery(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-2xl pl-12 pr-4 py-4 text-sm focus:outline-none focus:border-blue-500 transition-all font-medium" + /> + {searching && ( +
+
+
+ )} + + +
+ +
+ +
+ {users.length > 0 ? ( + users.map(u => ( + + )) + ) : searchQuery && !searching ? ( +
No users found matching your search.
+ ) : null} +
+
+
+
+ )} +
+ ); +} diff --git a/web/studio/src/app/library/assets/page.tsx b/web/studio/src/app/library/assets/page.tsx new file mode 100644 index 0000000..bea9c1d --- /dev/null +++ b/web/studio/src/app/library/assets/page.tsx @@ -0,0 +1,256 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { + Search, Image as ImageIcon, FileText, Film, File as FileIcon, + Loader2, Upload, Trash2, ExternalLink, Filter, Plus, ChevronLeft +} from "lucide-react"; +import { cmsApi, Asset, AssetFilters, getImageUrl } from "@/lib/api"; +import { useAuth } from "@/context/AuthContext"; +import { useTranslation } from "@/context/I18nContext"; +import Link from "next/link"; + +export default function AssetLibraryPage() { + const { t } = useTranslation(); + const { user } = useAuth(); + const [assets, setAssets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [filterType, setFilterType] = useState("all"); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + + const loadAssets = useCallback(async () => { + setIsLoading(true); + try { + const filters: AssetFilters = {}; + if (searchTerm) filters.search = searchTerm; + if (filterType !== "all") filters.mimetype = filterType; + + const data = await cmsApi.getAssets(filters); + setAssets(data); + } catch (error) { + console.error("Failed to load assets:", error); + } finally { + setIsLoading(false); + } + }, [searchTerm, filterType]); + + useEffect(() => { + const timer = setTimeout(() => { + loadAssets(); + }, 300); + return () => clearTimeout(timer); + }, [loadAssets]); + + const handleFileUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + setIsUploading(true); + setUploadProgress(0); + + try { + for (let i = 0; i < files.length; i++) { + await cmsApi.uploadAsset(files[i], (pct) => { + setUploadProgress(pct); + }); + } + loadAssets(); + } catch (error) { + console.error("Upload failed:", error); + alert("Failed to upload assets."); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("Are you sure you want to delete this asset? This will break content that uses it.")) return; + + try { + await cmsApi.deleteAsset(id); + setAssets(prev => prev.filter(a => a.id !== id)); + } catch (error) { + console.error("Delete failed:", error); + alert("Failed to delete asset."); + } + }; + + const getIcon = (mimetype: string) => { + if (mimetype.startsWith('image/')) return ; + if (mimetype.startsWith('video/')) return ; + if (mimetype.includes('pdf')) return ; + return ; + }; + + return ( +
+
+ {/* Header */} +
+
+ + + Back to Dashboard + +

+ Global Asset Library +

+

Manage and reuse your organization's media files across all courses.

+
+ +
+ +
+
+ + {/* Upload Progress Bar */} + {isUploading && ( +
+
+ Uploading Assets... + {uploadProgress}% +
+
+
+
+
+ )} + + {/* Controls */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-2xl pl-12 pr-4 py-4 text-sm font-medium focus:outline-none focus:border-blue-500/50 focus:bg-white/10 transition-all" + /> +
+ +
+ + +
+ +
+ {assets.length} Total Assets +
+
+ + {/* Asset Grid */} + {isLoading && assets.length === 0 ? ( +
+ + Retrieving Cloud Assets... +
+ ) : assets.length === 0 ? ( +
+
+ +
+
+

Your library is empty

+

Start contributing to your organization's shared assets.

+
+
+ ) : ( +
+ {assets.map((asset) => ( +
+ {/* Preview Area */} +
+ {asset.mimetype.startsWith('image/') ? ( +
+ ) : ( +
+ {getIcon(asset.mimetype)} +
+ )} +
+ + + + +
+ +
+
+ {asset.mimetype.split('/')[1]} +
+
+
+ + {/* Content Info */} +
+

+ {asset.filename} +

+
+
+ + {(asset.size_bytes / 1024 / 1024).toFixed(2)} MB + + + {new Date(asset.created_at).toLocaleDateString()} + +
+ {asset.course_id && ( +
+ Course Linked +
+ )} +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/web/studio/src/app/page.tsx b/web/studio/src/app/page.tsx index 8692c55..2dd763a 100644 --- a/web/studio/src/app/page.tsx +++ b/web/studio/src/app/page.tsx @@ -171,7 +171,7 @@ export default function StudioDashboard() {

{course.title}

-

{course.description || "No description provided."}

+

{course.description || "Sin descripción disponible."}

- Last updated: {new Date(course.updated_at).toLocaleDateString()} + Última actualización: {new Date(course.updated_at).toLocaleDateString()} ID: {course.id.slice(0, 4)}...
diff --git a/web/studio/src/components/AssetPickerModal.tsx b/web/studio/src/components/AssetPickerModal.tsx index 39582f2..b5448c1 100644 --- a/web/studio/src/components/AssetPickerModal.tsx +++ b/web/studio/src/components/AssetPickerModal.tsx @@ -8,9 +8,9 @@ import { cmsApi, Asset } from "@/lib/api"; interface AssetPickerModalProps { isOpen: boolean; onClose: () => void; - courseId: string; + courseId?: string; onSelect: (asset: Asset) => void; - filterType?: "image" | "file" | "all"; + filterType?: "image" | "file" | "video" | "all"; } export default function AssetPickerModal({ @@ -23,34 +23,46 @@ export default function AssetPickerModal({ const [assets, setAssets] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(""); + const [viewMode, setViewMode] = useState<"course" | "global">(courseId ? "course" : "global"); const loadAssets = useCallback(async () => { if (!isOpen) return; setIsLoading(true); try { - const data = await cmsApi.getCourseAssets(courseId); + const filters: any = {}; + if (viewMode === "course" && courseId) { + filters.course_id = courseId; + } + if (searchTerm) { + filters.search = searchTerm; + } + + const data = await cmsApi.getAssets(filters); let filtered = data; + if (filterType === "image") { filtered = data.filter(a => a.mimetype.startsWith("image/")); } else if (filterType === "file") { - filtered = data.filter(a => !a.mimetype.startsWith("image/")); + filtered = data.filter(a => !a.mimetype.startsWith("image/") && !a.mimetype.startsWith("video/")); + } else if (filterType === "video") { + filtered = data.filter(a => a.mimetype.startsWith("video/")); } + setAssets(filtered); } catch (error) { console.error("Failed to load assets for picker:", error); } finally { setIsLoading(false); } - }, [courseId, isOpen, filterType]); + }, [courseId, isOpen, filterType, viewMode, searchTerm]); useEffect(() => { - loadAssets(); + const timer = setTimeout(() => { + loadAssets(); + }, 300); + return () => clearTimeout(timer); }, [loadAssets]); - const filteredAssets = assets.filter(asset => - asset.filename.toLowerCase().includes(searchTerm.toLowerCase()) - ); - const getIcon = (mimetype: string) => { if (mimetype.startsWith('image/')) return ; if (mimetype.startsWith('video/')) return ; @@ -62,47 +74,78 @@ export default function AssetPickerModal({ -
+
+
+ {courseId && ( + + )} + +
+
- + setSearchTerm(e.target.value)} - className="w-full bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm focus:outline-none focus:border-blue-500/50" + className="w-full bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2.5 text-sm font-medium text-gray-200 placeholder:text-gray-500 focus:outline-none focus:border-blue-500/50 transition-colors" />
-
+
{isLoading ? ( -
- - Loading assets... +
+ + Synchronizing...
- ) : filteredAssets.length === 0 ? ( -
- {searchTerm ? "No files match your search." : "No assets found for this course."} + ) : assets.length === 0 ? ( +
+
+ +
+
+
Empty Collection
+
+ {searchTerm ? "No results found" : "No assets uploaded yet"} +
+
) : ( - filteredAssets.map((asset) => ( + assets.map((asset) => ( @@ -110,10 +153,10 @@ export default function AssetPickerModal({ )}
-
-

- Only assets uploaded to this course are shown. -

+
+
+ {assets.length} Assets Found +
diff --git a/web/studio/src/components/CourseEditorLayout.tsx b/web/studio/src/components/CourseEditorLayout.tsx index 18dc3c8..f0756f7 100644 --- a/web/studio/src/components/CourseEditorLayout.tsx +++ b/web/studio/src/components/CourseEditorLayout.tsx @@ -7,7 +7,7 @@ import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder, Graduation interface CourseEditorLayoutProps { children: React.ReactNode; - activeTab: "outline" | "grading" | "rubrics" | "calendar" | "analytics" | "settings" | "files" | "grades" | "announcements"; + activeTab: "outline" | "grading" | "rubrics" | "calendar" | "analytics" | "settings" | "files" | "grades" | "announcements" | "team"; } export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) { @@ -17,6 +17,7 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor { key: "outline", label: "Outline", icon: Layout, href: `/courses/${id}` }, { key: "grading", label: "Grading Policy", icon: CheckCircle2, href: `/courses/${id}/grading` }, { key: "rubrics", label: "Rubrics", icon: Layout, href: `/courses/${id}/rubrics` }, + { key: "team", label: "Team", icon: GraduationCap, href: `/courses/${id}/team` }, { key: "grades", label: "Gradebook", icon: GraduationCap, href: `/courses/${id}/grades` }, { key: "announcements", label: "Announcements", icon: Megaphone, href: `/courses/${id}/announcements` }, { key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` }, diff --git a/web/studio/src/components/Navbar.tsx b/web/studio/src/components/Navbar.tsx index e48d084..6bf3701 100644 --- a/web/studio/src/components/Navbar.tsx +++ b/web/studio/src/components/Navbar.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { useAuth } from '@/context/AuthContext'; import { useTranslation } from '@/context/I18nContext'; -import { LayoutDashboard, ShieldCheck, LogOut, Webhook, Settings, Globe } from 'lucide-react'; +import { LayoutDashboard, ShieldCheck, LogOut, Webhook, Settings, Globe, Library } from 'lucide-react'; export function Navbar() { const { t, language, setLanguage } = useTranslation(); @@ -28,6 +28,14 @@ export function Navbar() { {t('nav.courses')} + + + {t('nav.library') || 'Library'} + + {user?.role === 'admin' && ( <> {user.organization_id === '00000000-0000-0000-0000-000000000001' && ( diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index d1b5eb2..c779f93 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -112,6 +112,7 @@ export interface Lesson { cues?: { start: number; end: number; text: string }[]; } | null; transcription_status?: 'idle' | 'queued' | 'processing' | 'completed' | 'failed'; + is_previewable: boolean; created_at: string; } @@ -188,6 +189,8 @@ export interface UploadResponse { id: string; filename: string; url: string; + mimetype?: string; + size_bytes?: number; logo_url?: string; favicon_url?: string; } @@ -406,6 +409,8 @@ export interface CreateWebhookPayload { export interface Asset { id: string; + organization_id: string; + uploaded_by: string | null; course_id: string | null; filename: string; storage_path: string; @@ -414,6 +419,14 @@ export interface Asset { created_at: string; } +export interface AssetFilters { + mimetype?: string; + course_id?: string; + search?: string; + page?: number; + limit?: number; +} + export interface Cohort { id: string; organization_id: string; @@ -568,6 +581,7 @@ export const cmsApi = { getCourseTeam: (courseId: string): Promise => apiFetch(`/courses/${courseId}/team`), addTeamMember: (courseId: string, email: string, role: string): Promise => apiFetch(`/courses/${courseId}/team`, { method: 'POST', body: JSON.stringify({ email, role }) }), removeTeamMember: (courseId: string, userId: string): Promise => apiFetch(`/courses/${courseId}/team/${userId}`, { method: 'DELETE' }), + getUsers: (): Promise => apiFetch('/users'), // Modules & Lessons createModule: (course_id: string, title: string, position: number): Promise => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }), @@ -625,7 +639,19 @@ export const cmsApi = { deleteWebhook: (id: string): Promise => apiFetch(`/webhooks/${id}`, { method: 'DELETE' }), // Assets - getCourseAssets: (courseId: string): Promise => apiFetch(`/courses/${courseId}/assets`), + getAssets: (filters?: AssetFilters): Promise => { + const params = new URLSearchParams(); + if (filters) { + if (filters.mimetype) params.append('mimetype', filters.mimetype); + if (filters.course_id) params.append('course_id', filters.course_id); + if (filters.search) params.append('search', filters.search); + if (filters.page) params.append('page', filters.page.toString()); + if (filters.limit) params.append('limit', filters.limit.toString()); + } + const query = params.toString(); + return apiFetch(`/api/assets${query ? `?${query}` : ''}`); + }, + getCourseAssets: (courseId: string): Promise => apiFetch(`/api/assets?course_id=${courseId}`), deleteAsset: (id: string): Promise => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }), uploadAsset: (file: File, onProgress?: (pct: number) => void, courseId?: string): Promise => { return new Promise((resolve, reject) => {