diff --git a/README.md b/README.md index 4942235..2a5281f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura 3. **Database**: PostgreSQL compartido. 4. **AI Services**: stack local con Faster-Whisper (Transcripción) y Ollama (Traducción y Resúmenes). - **AI Course Wizard**: Generación automática de cursos a partir de prompts estructurados. - - **Global Admin Console**: Panel estilo Django para gestión supervisada de tenants y auditoría. - **Course Portability**: Importación/Exportación de cursos completos mediante JSON. - **User Profiles**: Gestión completa de identidad (avatar, bio, preferencias). - **Engagement Heatmaps**: Visualización de retención segundo a segundo en videos. @@ -36,8 +35,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Color-Coded Progress Navigation**: Sistema visual de seguimiento de progreso mediante colores (Verde: Completado, Amarillo: En Proceso, Rojo: Repetible) tanto a nivel de lección como de módulo. - **Adaptive Skill Analysis**: Motor de análisis de etiquetas que calcula la maestría de habilidades (Gramática, Vocabulario, etc.) para personalizar las recomendaciones de IA. - **Efficient Docker Builds**: Imágenes de contenedor optimizadas para desarrollo rápido y despliegue ligero. -- **Discussion Forums**: Sistema completo de foros por curso con hilos de discusión, respuestas anidadas, votación, moderación por instructores y suscripciones. -- **Split Authentication Flow**: Flujos de autenticación diferenciados para usuarios personales (email/password) y empresas (dominio corporativo). +- **Unified Authentication Flow**: Flujo de inicio de sesión simplificado para estudiantes e instructores. - **Course Monetization**: Integración con Mercado Pago para venta de cursos, con inscripciones automáticas y paneles de precios para instructores. - **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. @@ -54,6 +52,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Predictive Analytics (Dropout Risk)**: Motor de IA que analiza el desempeño, actividad y compromiso social del estudiante para detectar riesgos de abandono de forma proactiva, con alertas accionables para instructores. - **Live Learning (Videoconference)**: Integración nativa con Jitsi para clases virtuales síncronas, con programación desde Studio y acceso integrado en Experience. - **Student Portfolio & Badges**: Sistema de reconocimiento con Open Badges y perfiles públicos profesionales para mostrar logros y progreso verificado. +- **Dynamic Mermaid Diagrams**: Generación automática de diagramas (flowcharts, mapas mentales, secuencias) a partir del contenido de cada lección usando IA, con editor visual integrado para instructores. ## Requisitos del Sistema @@ -147,16 +146,14 @@ Para resetear completamente el entorno de desarrollo y empezar desde cero: Gestión de registro, login y perfiles organizacionales. #### POST /auth/register -Crea una nueva organización y el usuario administrador inicial. +Crea un nuevo usuario vinculado a la organización por defecto. -- **Lógica Inteligente**: Si `organization_name` está vacío, se utiliza el dominio del email. El primer usuario es marcado como `role: admin`. - **Cuerpo de la Petición ( AuthPayload ):** ```json { "email": "string", "password": "string", "full_name": "string", - "organization_name": "string", "role": "string (admin | instructor | student)" } ``` @@ -172,10 +169,10 @@ OpenCCB actúa como un Tool Provider LTI 1.3 moderno, utilizando OIDC y JWKS par - **Autoprovisionamiento**: Los usuarios lanzados vía LTI se crean automáticamente con los roles correspondientes. ```bash -# Registrar un nuevo administrador y empresa +# Registrar un nuevo administrador curl -X POST "http://localhost:3001/auth/register" \ -H "Content-Type: application/json" \ - -d '{"email": "admin@empresa.com", "password": "pass", "organization_name": "OpenCCB Corp"}' + -d '{"email": "admin@empresa.com", "password": "pass", "full_name": "Admin Name"}' ``` --- @@ -575,39 +572,15 @@ curl -X POST http://localhost:3002/courses/{course_id}/announcements \ --- -### 6. Multi-tenencia y Gestión Global (Super Admin) -OpenCCB está diseñado para multi-tenencia. Las organizaciones están aisladas, pero un **Super Admin** puede gestionarlo todo. +### 6. Estructura Single-Tenant +OpenCCB está diseñado como un módulo premium single-tenant. Todas las operaciones se realizan bajo una única organización preconfigurada. -#### Definición de Super Admin -- **ID de Organización por Defecto**: `00000000-0000-0000-0000-000000000001` -- Cualquier usuario con `role: admin` en esta organización es un **Super Admin**. -#### Global Control Panel (`/admin`) -- **Dashboard**: Resumen de organizaciones, usuarios y salud del sistema. -- **Audit Logs**: Seguimiento detallado de todas las acciones administrativas. -- **Service Monitor**: Estado en tiempo real del API Cluster, AI Services y Background Workers. +#### Organización por Defecto +- **ID de Organización**: `00000000-0000-0000-0000-000000000001` +- El sistema utiliza este ID de forma transparente para todas las consultas y recursos. -#### Cursos Globales -Los cursos creados por los Super Admins en la **Organización por Defecto** son automáticamente marcados como **Globales**. -- Aparecen en el catálogo de **todas las organizaciones**. -- Los usuarios de cualquier organización pueden inscribirse en cursos globales. - Riverside - -#### Publicación Entre Organizaciones -Los Super Admins pueden publicar cursos en **cualquier organización**. Al publicar a través de Studio, un **Selector de Organizaciones** premium permite elegir el destino. - -#### X-Organization-Id Header -Super Admins can simulate the context of any organization by sending this header in their requests: -```bash -curl -H "Authorization: Bearer $SUPER_ADMIN_TOKEN" \ - -H "X-Organization-Id: $TARGET_ORG_ID" \ - http://localhost:3001/courses -``` - -#### GET /organizations -Obtiene una lista de todas las organizaciones registradas. - -- **Control Global**: Accesible únicamente para usuarios con rol `admin` dentro de la organización `Default`. Permite supervisar el crecimiento del ecosistema. -- **Respuesta**: Array de `Organization`. +#### Branding Unificado +- La personalización de marca se aplica globalmente a través del panel de Ajustes en Studio, afectando tanto a la interfaz administrativa como al portal del estudiante. --- diff --git a/install.sh b/install.sh index a2928c1..25592ea 100755 --- a/install.sh +++ b/install.sh @@ -238,7 +238,8 @@ if [ "$ADMIN_EXISTS" != "t" ]; then read -s -p "Contraseña del Administrador [password123]: " ADMIN_PASS ADMIN_PASS=${ADMIN_PASS:-password123} echo "" - ORG_NAME="Default Organization" + read -p "Nombre de la Organización [OpenCCB]: " ORG_NAME + ORG_NAME=${ORG_NAME:-OpenCCB} fi # Selective Build/Rebuild diff --git a/roadmap.md b/roadmap.md index 076ec8e..7e1e578 100644 --- a/roadmap.md +++ b/roadmap.md @@ -69,23 +69,22 @@ - [x] Visualización de feedback basada en niveles - [x] Actualización de notas en tiempo real -## Fase 6: Funcionalidades Avanzadas ✅ -- [x] **Multi-tenancy**: Soporte para múltiples organizaciones (Completado) - - [x] Migración del esquema DB (añadir `organization_id`) - - [x] Actualización de modelos Rust y Claims de JWT - - [x] Middleware en Axum para contexto de organización - - [x] Registro en frontend con soporte para organizaciones - - [x] **Super Admin y Org por Defecto**: Gestión global de todos los inquilinos - - [x] **Visibilidad Global de Cursos**: Cursos de sistema disponibles para todas las organizaciones -- [x] **Personalización de Marca (Branding)**: Identidad propia por organización (Completado) +## Fase 6: Refactorización a Single-Tenant ✅ +- [x] **Single-tenancy**: Reposicionamiento como módulo premium (Completado) + - [x] Hardcoding del `organization_id` por defecto en middleware común + - [x] Remoción de selectores de organización en Studio y Experience + - [x] Simplificación de registros y logins para un solo inquilino + - [x] Eliminación de rutas y controladores de gestión multi-empresa + - [x] Limpieza de componentes frontend redundantes (Selector, Gestión de Orgs) +- [x] **Personalización de Marca (Branding Premium)**: Identidad unificada (Completado) + - [x] Endpoints singulares para gestión de marca sin parámetros de ID - [x] Carga y optimización de logotipos y favicons customizados - [x] Nombre de plataforma personalizado (White-label) - - [x] Esquemas de colores personalizados (Primario/Secundario) - - [x] Adaptación dinámica del portal de Experience - - [x] Previsualización en vivo del branding en Studio -- [x] **Interfaz de Usuario Avanzada**: - - [x] **Selector de Organizaciones Premium**: Gestión multi-tenant con búsqueda predictiva - - [x] **Combobox de Búsqueda**: Componente elegante con filtrado y estilo glassmorphism + - [x] Esquemas de colores personalizados aplicado globalmente + - [x] Previsualización en vivo del branding en Studio renovada +- [x] **Interfaz de Usuario Simplificada**: + - [x] **Login Unificado**: Eliminación de flujo dividido Personas/Empresas + - [x] **Navegación Limpia**: Remoción de enlaces de administración de organizaciones ## Fase 7: Compromiso y Social (En Progreso) - [x] **Analíticas de Vanguardia**: @@ -231,14 +230,14 @@ - [x] **Generación de Juegos de Memoria Conceptuales**: Creación automática de parejas (concepto/definición) a partir de transcripciones. (Completado) - [x] **Simulaciones de Rol y Diálogos Ramificados**: Motor de escenarios interactivos con respuestas dinámicas de la IA. (Completado) - [x] **Auto-Hotspots Pedagógicos**: Identificación automática de puntos de interés en imágenes con descripciones técnicas. (Completado) -- [ ] **Diagramas de Mermaid Dinámicos**: Visualización automática de procesos y mapas mentales a partir del contenido de la lección. -- [ ] **Laboratorios de Código con Hints de IA**: Generación de desafíos de programación con pistas contextuales basadas en errores. +- [x] **Diagramas de Mermaid Dinámicos**: Visualización automática de procesos y mapas mentales a partir del contenido de la lección. (Completado) +- [x] **Laboratorios de Código con Hints de IA**: Generación de desafíos de programación con pistas contextuales basadas en errores. (Completado) --- -**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos** y **Landing Pages de Cursos (Marketing) automatizadas**. +**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos**, **Landing Pages de Cursos (Marketing) automatizadas**, **Diagramas de Mermaid Dinámicos** y **Laboratorios de Código con Hints de IA**. **Próximas Prioridades**: -1. **Diagramas de Mermaid Dinámicos**: IA genera flujos y mapas mentales automáticamente. -2. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1. -3. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos. +1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1. +2. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos. +3. **Optimización de Performance**: Refactorización de componentes críticos y carga diferida (lazy loading). diff --git a/services/cms-service/migrations/20260111000005_crud_functions.sql b/services/cms-service/migrations/20260111000005_crud_functions.sql index 3b6caa2..e1fe156 100644 --- a/services/cms-service/migrations/20260111000005_crud_functions.sql +++ b/services/cms-service/migrations/20260111000005_crud_functions.sql @@ -211,18 +211,12 @@ CREATE OR REPLACE FUNCTION fn_register_user( p_password_hash VARCHAR(255), p_full_name VARCHAR(255), p_role VARCHAR(50), - p_org_name VARCHAR(255) + p_org_name VARCHAR(255) -- Preserved for signature compatibility but ignored ) RETURNS SETOF users AS $$ DECLARE - v_org_id UUID; + v_org_id UUID := '00000000-0000-0000-0000-000000000001'; BEGIN - -- Find or create organization - INSERT INTO organizations (name) - VALUES (p_org_name) - ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name - RETURNING id INTO v_org_id; - - -- Create user + -- Create user in default organization RETURN QUERY INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES (p_email, p_password_hash, p_full_name, p_role, v_org_id) diff --git a/services/cms-service/migrations/20260309000001_enforce_single_tenant_org_name.sql b/services/cms-service/migrations/20260309000001_enforce_single_tenant_org_name.sql new file mode 100644 index 0000000..6693da1 --- /dev/null +++ b/services/cms-service/migrations/20260309000001_enforce_single_tenant_org_name.sql @@ -0,0 +1,29 @@ +-- Migration: Enforce single-tenant and update organization name +-- This migration updates fn_register_user to always use the default organization ID (0...1) +-- but allows updating its name during the initial registration. + +CREATE OR REPLACE FUNCTION fn_register_user( + p_email VARCHAR(255), + p_password_hash VARCHAR(255), + p_full_name VARCHAR(255), + p_role VARCHAR(50), + p_org_name VARCHAR(255) DEFAULT NULL +) RETURNS SETOF users AS $$ +DECLARE + v_org_id UUID := '00000000-0000-0000-0000-000000000001'; +BEGIN + -- Update the default organization name if a custom name is provided + IF p_org_name IS NOT NULL AND p_org_name <> '' AND p_org_name <> 'Default Organization' AND p_org_name <> 'Organización por Defecto' THEN + UPDATE organizations + SET name = p_org_name, + updated_at = NOW() + WHERE id = v_org_id; + END IF; + + -- Create user and assign to the default organization + RETURN QUERY + INSERT INTO users (email, password_hash, full_name, role, organization_id) + VALUES (p_email, p_password_hash, p_full_name, p_role, v_org_id) + RETURNING *; +END; +$$ LANGUAGE plpgsql; diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 2fccaa4..b3c9371 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -8,8 +8,9 @@ use axum::{ }; use bcrypt::{DEFAULT_COST, hash, verify}; use chrono::{DateTime, Utc}; -use common::auth::{Claims, create_jwt, create_preview_token}; -use common::middleware::Org; +pub use common::auth::Claims; +pub use common::middleware::Org; +use common::auth::{create_jwt, create_preview_token}; use common::models::{ AuthResponse, Course, CourseAnalytics, Lesson, Module, Organization, PublishedCourse, PublishedModule, User, UserResponse, CourseInstructor, @@ -1831,6 +1832,122 @@ pub async fn generate_mermaid_diagram( }))) } +#[derive(Deserialize)] +pub struct GenerateCodeLabPayload { + pub language: Option, + pub prompt_hint: Option, +} + +pub async fn generate_code_lab( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path(lesson_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + tracing::info!("Generating Code Lab for lesson_id={}", lesson_id); + + let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(lesson_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Lección no encontrada".into()))?; + + let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "local".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string()); + let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); + (format!("{}/v1/chat/completions", base_url), "".to_string(), model) + } else { + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()), + "gpt-4o".to_string(), + ) + }; + + let transcription_str = lesson.transcription.as_ref().and_then(|v| v.as_str()); + let summary_str = lesson.summary.as_deref(); + let lesson_context = transcription_str.or(summary_str).unwrap_or("Conceptos generales de la lección."); + let language = payload.language.unwrap_or_else(|| "python".to_string()); + let user_hint = payload.prompt_hint.unwrap_or_else(|| "Diseña un ejercicio práctico que refuerce los conceptos de la lección.".to_string()); + + let system_prompt = format!( + "Eres un experto pedagogo y programador especializado en crear ejercicios de código educativos.\n\ + Tu tarea es generar un ejercicio de programación en {} basado en el siguiente contenido de la lección.\n\ + INSTRUCCIONES CRÍTICAS:\n\ + 1. Responde ÚNICAMENTE con un objeto JSON válido. Sin texto adicional, sin explicaciones, sin bloques markdown.\n\ + 2. El JSON debe tener exactamente esta estructura:\n\ + {{\"title\": \"string\", \"instructions\": \"string\", \"initial_code\": \"string\", \"solution\": \"string\", \"test_cases\": [{{\"description\": \"string\", \"expected\": \"string\"}}]}}\n\ + 3. El campo 'initial_code' debe ser un esqueleto con comentarios TODO para que el estudiante lo complete.\n\ + 4. El campo 'solution' debe ser la solución completa.\n\ + 5. El campo 'test_cases' debe contener 2-3 casos de prueba descriptivos.\n\ + 6. Las instrucciones deben ser claras y pedagógicamente apropiadas.\n\n\ + Contexto de la lección:\n{}\n\n\ + Instrucciones adicionales:\n{}", + language, lesson_context, user_hint + ); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Authorization", auth_header) + .json(&serde_json::json!({ + "model": model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": "Genera el ejercicio de código ahora." } + ], + "temperature": 0.4 + })) + .send() + .await + .map_err(|e| { + tracing::error!("LLM Request failed: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Error contacting AI provider".into()) + })?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + tracing::error!("LLM Error response: {}", err_body); + return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI provider returned an error".into())); + } + + let ai_data: serde_json::Value = response.json().await.map_err(|e| { + tracing::error!("Failed to parse LLM JSON: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Error parsing AI response".into()) + })?; + + let ai_text = ai_data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("{}") + .trim(); + + // Strip potential markdown code fences + let cleaned = ai_text + .strip_prefix("```json\n").unwrap_or(ai_text) + .strip_prefix("```\n").unwrap_or(ai_text) + .strip_suffix("```").unwrap_or(ai_text) + .trim(); + + let exercise: serde_json::Value = serde_json::from_str(cleaned).map_err(|e| { + tracing::error!("Failed to parse exercise JSON from LLM: {} | raw: {}", e, cleaned); + (StatusCode::INTERNAL_SERVER_ERROR, "AI returned invalid exercise JSON".into()) + })?; + + Ok(Json(serde_json::json!({ + "language": language, + "title": exercise["title"], + "instructions": exercise["instructions"], + "initial_code": exercise["initial_code"], + "solution": exercise["solution"], + "test_cases": exercise["test_cases"], + }))) +} + #[derive(Deserialize)] pub struct GenerateHotspotsPayload { pub image_url: String, @@ -2082,15 +2199,6 @@ pub struct AuthPayload { pub organization_name: Option, } -#[derive(serde::Deserialize)] -pub struct ProvisionPayload { - pub org_name: String, - pub org_domain: Option, - pub admin_email: String, - pub admin_password: String, - pub admin_full_name: String, -} - #[derive(serde::Deserialize, serde::Serialize)] pub struct AdminCreateUserPayload { pub email: String, @@ -3130,176 +3238,9 @@ pub async fn update_user( })) } -// Organizations Management (Plural/Admin) -pub async fn get_organizations( - claims: common::auth::Claims, - State(pool): State, -) -> Result>, StatusCode> { - if claims.role != "admin" { - return Err(StatusCode::FORBIDDEN); - } - - let orgs = - sqlx::query_as::<_, Organization>("SELECT * FROM organizations ORDER BY created_at DESC") - .fetch_all(&pool) - .await - .map_err(|e| { - tracing::error!("Failed to fetch organizations: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(orgs)) -} - -#[derive(Deserialize)] -pub struct OrgSearchQuery { - pub q: String, -} - -#[derive(Serialize, sqlx::FromRow)] -pub struct OrgSearchResult { - pub id: Uuid, - pub name: String, - pub domain: Option, -} - -pub async fn search_organizations( - State(pool): State, - Query(query): Query, -) -> Result>, StatusCode> { - if query.q.trim().is_empty() { - return Ok(Json(vec![])); - } - - let search_term = format!("%{}%", query.q.trim()); - - let orgs = sqlx::query_as::<_, OrgSearchResult>( - "SELECT id, name, domain FROM organizations WHERE name ILIKE $1 OR domain ILIKE $1 ORDER BY name ASC LIMIT 10" - ) - .bind(search_term) - .fetch_all(&pool) - .await - .map_err(|e| { - tracing::error!("Failed to search organizations: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(orgs)) -} - -pub async fn create_organization( - claims: common::auth::Claims, - State(pool): State, - Json(payload): Json, -) -> Result, StatusCode> { - if claims.role != "admin" { - return Err(StatusCode::FORBIDDEN); - } - - let name = payload - .get("name") - .and_then(|v| v.as_str()) - .ok_or(StatusCode::BAD_REQUEST)?; - let domain = payload.get("domain").and_then(|v| v.as_str()); - - let org = sqlx::query_as::<_, Organization>( - "INSERT INTO organizations (name, domain) VALUES ($1, $2) RETURNING *", - ) - .bind(name) - .bind(domain) - .fetch_one(&pool) - .await - .map_err(|e| { - tracing::error!("Failed to create organization: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(org)) -} - -pub async fn update_organization( - claims: common::auth::Claims, - State(pool): State, - Path(id): Path, - Json(payload): Json, -) -> Result, (StatusCode, String)> { - // Only super admins or admins of the same org? - // Usually editing other orgs is a Super Admin only task. - let is_super_admin = claims.role == "admin" - && claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); - - if !is_super_admin { - return Err((StatusCode::FORBIDDEN, "Super Admin access required".into())); - } - - let name = payload.get("name").and_then(|v| v.as_str()); - let domain = payload.get("domain").and_then(|v| v.as_str()); - - let org = sqlx::query_as::<_, Organization>( - "UPDATE organizations SET - name = COALESCE($1, name), - domain = COALESCE($2, domain), - updated_at = NOW() - WHERE id = $3 RETURNING *" - ) - .bind(name) - .bind(domain) - .bind(id) - .fetch_one(&pool) - .await - .map_err(|e| { - tracing::error!("Failed to update organization: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, "Failed to update organization".into()) - })?; - - Ok(Json(org)) -} - -pub async fn provision_organization( - claims: common::auth::Claims, - State(pool): State, - Json(payload): Json, -) -> Result, (StatusCode, String)> { - if claims.role != "admin" || claims.org != Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() { - return Err((StatusCode::FORBIDDEN, "Super Admin access required".into())); - } - - let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let org = sqlx::query_as::<_, Organization>( - "INSERT INTO organizations (name, domain) VALUES ($1, $2) RETURNING *" - ) - .bind(&payload.org_name) - .bind(&payload.org_domain) - .fetch_one(&mut *tx) - .await - .map_err(|e| { - tracing::error!("Failed to create organization: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create organization".into()) - })?; - - let password_hash = hash(payload.admin_password, DEFAULT_COST) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Hashing failed".into()))?; - - sqlx::query( - "INSERT INTO users (email, password_hash, full_name, role, organization_id) VALUES ($1, $2, $3, $4, $5)" - ) - .bind(&payload.admin_email) - .bind(password_hash) - .bind(&payload.admin_full_name) - .bind("admin") - .bind(org.id) - .execute(&mut *tx) - .await - .map_err(|e| { - tracing::error!("Failed to create admin user: {}", e); - (StatusCode::CONFLICT, "User already exists or DB error".into()) - })?; - - tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - Ok(Json(org)) -} +// Organizations Management (Simplified for Single-Tenant) +// Multi-tenant organization management has been removed. +// The system now operates on a single default organization. #[derive(serde::Deserialize)] pub struct CreateWebhookPayload { diff --git a/services/cms-service/src/handlers_branding.rs b/services/cms-service/src/handlers_branding.rs index 5eb2bb1..2b56986 100644 --- a/services/cms-service/src/handlers_branding.rs +++ b/services/cms-service/src/handlers_branding.rs @@ -2,7 +2,7 @@ use axum::{ Json, - extract::{Path, State}, + extract::State, http::StatusCode, }; use common::models::Organization; @@ -11,7 +11,7 @@ use serde_json::json; use sqlx::PgPool; use uuid::Uuid; -use super::handlers::log_action; +use super::handlers::{log_action, Org}; #[derive(Deserialize, Serialize)] pub struct BrandingPayload { @@ -35,8 +35,8 @@ pub struct BrandingResponse { // Upload organization logo pub async fn upload_organization_logo( claims: common::auth::Claims, + Org(org_ctx): Org, State(pool): State, - Path(org_id): Path, mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, String)> { // Only admins can upload logos @@ -46,7 +46,7 @@ pub async fn upload_organization_logo( // Verify organization exists and user has access let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") - .bind(org_id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?; @@ -98,7 +98,7 @@ pub async fn upload_organization_logo( })?; // Generate unique filename - let unique_filename = format!("{}_{}.{}", org_id, uuid::Uuid::new_v4(), ext); + let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext); let filepath = format!("uploads/org-logos/{}", unique_filename); // Save file @@ -113,7 +113,7 @@ pub async fn upload_organization_logo( let logo_url = format!("/{}", filepath); sqlx::query("UPDATE organizations SET logo_url = $1 WHERE id = $2") .bind(&logo_url) - .bind(org_id) + .bind(org_ctx.id) .execute(&pool) .await .map_err(|e| { @@ -129,7 +129,7 @@ pub async fn upload_organization_logo( claims.sub, "UPDATE_LOGO", "Organization", - org_id, + org_ctx.id, json!({"logo_url": &logo_url}), ) .await; @@ -147,8 +147,8 @@ pub async fn upload_organization_logo( // Upload organization favicon pub async fn upload_organization_favicon( claims: common::auth::Claims, + Org(org_ctx): Org, State(pool): State, - Path(org_id): Path, mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, String)> { // Only admins can upload favicons @@ -158,7 +158,7 @@ pub async fn upload_organization_favicon( // Verify organization exists and user has access let _ = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") - .bind(org_id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| (StatusCode::NOT_FOUND, "Organization not found".into()))?; @@ -210,7 +210,7 @@ pub async fn upload_organization_favicon( })?; // Generate unique filename - let unique_filename = format!("{}_{}.{}", org_id, uuid::Uuid::new_v4(), ext); + let unique_filename = format!("{}_{}.{}", org_ctx.id, uuid::Uuid::new_v4(), ext); let filepath = format!("uploads/org-favicons/{}", unique_filename); // Save file @@ -225,7 +225,7 @@ pub async fn upload_organization_favicon( let favicon_url = format!("/{}", filepath); sqlx::query("UPDATE organizations SET favicon_url = $1 WHERE id = $2") .bind(&favicon_url) - .bind(org_id) + .bind(org_ctx.id) .execute(&pool) .await .map_err(|e| { @@ -241,7 +241,7 @@ pub async fn upload_organization_favicon( claims.sub, "UPDATE_FAVICON", "Organization", - org_id, + org_ctx.id, json!({"favicon_url": &favicon_url}), ) .await; @@ -259,8 +259,8 @@ pub async fn upload_organization_favicon( // Update organization branding colors pub async fn update_organization_branding( claims: common::auth::Claims, + Org(org_ctx): Org, State(pool): State, - Path(org_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { // Only admins can update branding @@ -310,7 +310,7 @@ pub async fn update_organization_branding( .bind(&payload.secondary_color) .bind(&payload.platform_name) .bind(&payload.logo_variant) - .bind(org_id) + .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| { @@ -326,7 +326,7 @@ pub async fn update_organization_branding( claims.sub, "UPDATE_BRANDING", "Organization", - org_id, + org_ctx.id, json!(payload), ) .await; @@ -337,10 +337,9 @@ pub async fn update_organization_branding( // Get organization branding (public endpoint) pub async fn get_organization_branding( State(pool): State, - Path(org_id): Path, ) -> Result, StatusCode> { let org = sqlx::query_as::<_, Organization>("SELECT * FROM organizations WHERE id = $1") - .bind(org_id) + .bind(Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()) .fetch_one(&pool) .await .map_err(|_| StatusCode::NOT_FOUND)?; diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index caac959..9f0cd4a 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -150,6 +150,7 @@ async fn main() { .route("/lessons/{id}/generate-role-play", post(handlers::generate_role_play)) .route("/lessons/{id}/generate-hotspots", post(handlers::generate_hotspots)) .route("/lessons/{id}/generate-mermaid", post(handlers::generate_mermaid_diagram)) + .route("/lessons/{id}/generate-code-lab", post(handlers::generate_code_lab)) .route("/courses/generate", post(handlers::generate_course)) .route("/courses/{id}/export", get(handlers::export_course)) .route("/courses/import", post(handlers::import_course)) @@ -172,12 +173,14 @@ async fn main() { .route("/api/assets/upload", post(handlers_assets::upload_asset)) .route("/api/assets/{id}", delete(handlers_assets::delete_asset)) .layer(DefaultBodyLimit::disable()) +/* .route( "/organizations", get(handlers::get_organizations).post(handlers::create_organization), ) .route("/organizations/{id}", put(handlers::update_organization)) .route("/admin/provision", post(handlers::provision_organization)) +*/ .route( "/webhooks", get(handlers::get_webhooks).post(handlers::create_webhook), @@ -192,15 +195,15 @@ async fn main() { get(handlers::get_sso_config).put(handlers::update_sso_config), ) .route( - "/organizations/{id}/logo", + "/organization/logo", post(handlers_branding::upload_organization_logo), ) .route( - "/organizations/{id}/favicon", + "/organization/favicon", post(handlers_branding::upload_organization_favicon), ) .route( - "/organizations/{id}/branding", + "/organization/branding", axum::routing::put(handlers_branding::update_organization_branding), ) // Content Libraries routes @@ -290,11 +293,7 @@ async fn main() { .route("/auth/sso/login/{org_id}", get(handlers::sso_login_init)) .route("/auth/sso/callback", get(handlers::sso_callback)) .route( - "/organizations/search", - get(handlers::search_organizations), - ) - .route( - "/organizations/{id}/branding", + "/branding", get(handlers_branding::get_organization_branding), ) .nest_service("/assets", tower_http::services::ServeDir::new("uploads")) diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 69930da..c45d8d6 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -580,39 +580,18 @@ pub async fn get_course_catalog( query.organization_id, query.user_id ); - let courses = match (query.organization_id, query.user_id) { - (Some(org_id), Some(user_id)) => { - sqlx::query_as::<_, Course>( - "SELECT DISTINCT c.* FROM courses c - LEFT JOIN enrollments e ON c.id = e.course_id AND e.user_id = $2 - WHERE c.organization_id = $1 OR c.organization_id = '00000000-0000-0000-0000-000000000001' OR e.id IS NOT NULL" - ) - .bind(org_id) - .bind(user_id) + let courses = if let Some(user_id) = query.user_id { + sqlx::query_as::<_, Course>( + "SELECT DISTINCT c.* FROM courses c + LEFT JOIN enrollments e ON c.id = e.course_id AND e.user_id = $1" + ) + .bind(user_id) + .fetch_all(&pool) + .await + } else { + sqlx::query_as::<_, Course>("SELECT * FROM courses") .fetch_all(&pool) .await - } - (Some(org_id), None) => { - sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE organization_id = $1 OR organization_id = '00000000-0000-0000-0000-000000000001'") - .bind(org_id) - .fetch_all(&pool) - .await - } - (None, Some(user_id)) => { - sqlx::query_as::<_, Course>( - "SELECT DISTINCT c.* FROM courses c - JOIN enrollments e ON c.id = e.course_id - WHERE e.user_id = $1 OR c.organization_id = '00000000-0000-0000-0000-000000000001'" - ) - .bind(user_id) - .fetch_all(&pool) - .await - } - (None, None) => { - sqlx::query_as::<_, Course>("SELECT * FROM courses") - .fetch_all(&pool) - .await - } } .map_err(|e: sqlx::Error| { tracing::error!("Catalog fetch failed: {}", e); @@ -2349,7 +2328,124 @@ pub struct ChatResponse { pub session_id: Uuid, } +#[derive(Deserialize)] +pub struct CodeHintPayload { + pub current_code: String, + pub error_message: Option, + pub instructions: Option, + pub language: Option, +} + +pub async fn get_code_hint( + claims: Claims, + State(pool): State, + Path(lesson_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + tracing::info!("Code hint request for lesson_id={}", lesson_id); + + // Access check: enrolled or preview + let is_enrolled = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM enrollments WHERE user_id = $1 AND course_id = (SELECT course_id FROM modules WHERE id = (SELECT module_id FROM lessons WHERE id = $2)))" + ) + .bind(claims.sub) + .bind(lesson_id) + .fetch_one(&pool) + .await + .unwrap_or(false); + + let is_previewable = sqlx::query_scalar::<_, bool>( + "SELECT COALESCE(is_previewable, false) FROM lessons WHERE id = $1" + ) + .bind(lesson_id) + .fetch_one(&pool) + .await + .unwrap_or(false); + + if !is_enrolled && !is_previewable { + return Err((StatusCode::FORBIDDEN, "No tienes acceso a esta lección".into())); + } + + let provider = std::env::var("AI_PROVIDER").unwrap_or_else(|_| "local".to_string()); + let client = reqwest::Client::new(); + + let (url, auth_header, model) = if provider == "local" { + let base_url = std::env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string()); + let model = std::env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string()); + (format!("{}/v1/chat/completions", base_url), "".to_string(), model) + } else { + ( + "https://api.openai.com/v1/chat/completions".to_string(), + format!("Bearer {}", std::env::var("OPENAI_API_KEY").unwrap_or_default()), + "gpt-4o".to_string(), + ) + }; + + let language = payload.language.as_deref().unwrap_or("código"); + let instructions = payload.instructions.as_deref().unwrap_or("Sin instrucciones específicas."); + let error_context = match &payload.error_message { + Some(err) if !err.trim().is_empty() => format!("\nError recibido del estudiante:\n```\n{}\n```", err), + _ => String::new(), + }; + + let system_prompt = format!( + "Eres un tutor de programación experto y pedagogo. \ + Tu misión es ayudar a un estudiante que está trabajando en un ejercicio de {}.\n\ + INSTRUCCIONES CRÍTICAS:\n\ + 1. NO proporciones la solución completa directamente.\n\ + 2. Da una PISTA PEDAGÓGICA: señala el concepto equivocado, sugiere qué investigar, o pregunta con qué parte tiene dificultad.\n\ + 3. Sé amable, encouraging y conciso (máximo 3-4 oraciones).\n\ + 4. Si hay un error, explica brevemente qué tipo de error es sin dar el fix directo.\n\n\ + Ejercicio: {}\n{}", + language, instructions, error_context + ); + + let user_message = format!( + "Mi código actual es:\n```{}\n{}\n```\nNecesito una pista para seguir avanzando.", + language, payload.current_code + ); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("Authorization", auth_header) + .json(&serde_json::json!({ + "model": model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_message } + ], + "temperature": 0.5 + })) + .send() + .await + .map_err(|e| { + tracing::error!("LLM Request failed: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Error contacting AI provider".into()) + })?; + + if !response.status().is_success() { + let err_body = response.text().await.unwrap_or_default(); + tracing::error!("LLM Error response: {}", err_body); + return Err((StatusCode::INTERNAL_SERVER_ERROR, "AI provider returned an error".into())); + } + + let ai_data: serde_json::Value = response.json().await.map_err(|e| { + tracing::error!("Failed to parse LLM JSON: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Error parsing AI response".into()) + })?; + + let hint = ai_data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("No pude generar una pista en este momento. Por favor intenta de nuevo.") + .trim() + .to_string(); + + Ok(Json(serde_json::json!({ "hint": hint }))) +} + pub async fn chat_with_tutor( + Org(org_ctx): Org, claims: Claims, State(pool): State, diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 6f0b41e..76ba543 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -122,6 +122,7 @@ async fn main() { .route("/audio/evaluate-file", post(handlers::evaluate_audio_file)) .route("/lessons/{id}/chat", post(handlers::chat_with_tutor)) .route("/lessons/{id}/chat-role-play", post(handlers::chat_role_play)) + .route("/lessons/{id}/code-hint", post(handlers::get_code_hint)) .route("/lessons/{id}/feedback", get(handlers::get_lesson_feedback)) .route("/notifications", get(handlers::get_notifications)) .route( diff --git a/shared/common/src/middleware.rs b/shared/common/src/middleware.rs index b526baf..9e1f568 100644 --- a/shared/common/src/middleware.rs +++ b/shared/common/src/middleware.rs @@ -45,7 +45,7 @@ pub async fn org_extractor_middleware( // NOTA: El secreto debe venir de una variable de entorno en producción. let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()); - let mut claims = decode::( + let claims = decode::( &token, &DecodingKey::from_secret(secret.as_ref()), &Validation::default(), @@ -53,21 +53,8 @@ pub async fn org_extractor_middleware( .map_err(|_| StatusCode::UNAUTHORIZED)? .claims; - // Check for organization override header (only for admins) - let org_id = if claims.role == "admin" { - req.headers() - .get("x-organization-id") - .and_then(|h| h.to_str().ok()) - .and_then(|s| Uuid::parse_str(s).ok()) - .unwrap_or(claims.org) - } else { - claims.org - }; - - // Update claims.org if overridden so downstream logic sees the new org - if org_id != claims.org { - claims.org = org_id; - } + // Forzar el uso de la organización por defecto para arquitectura single-tenant + let org_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); // Insertamos el contexto y las claims en las extensiones de la petición. req.extensions_mut().insert(OrgContext { id: org_id }); diff --git a/web/experience/src/app/auth/login/page.tsx b/web/experience/src/app/auth/login/page.tsx index 6474250..94dbe96 100644 --- a/web/experience/src/app/auth/login/page.tsx +++ b/web/experience/src/app/auth/login/page.tsx @@ -4,25 +4,19 @@ import React, { useState } from "react"; import { useRouter } from "next/navigation"; import { lmsApi } from "@/lib/api"; import { useAuth } from "@/context/AuthContext"; -import AsyncCombobox from "@/components/AsyncCombobox"; -import { GraduationCap, Lock, Mail, User, Building2, ChevronLeft, ArrowRight } from "lucide-react"; - -type ViewMode = 'selection' | 'personal' | 'enterprise'; +import { GraduationCap, Lock, Mail, User, ChevronLeft } from "lucide-react"; export default function ExperienceLoginPage() { const router = useRouter(); const { login } = useAuth(); // State - const [viewMode, setViewMode] = useState('selection'); - const [isLogin, setIsLogin] = useState(true); // For Personal flow + const [isLogin, setIsLogin] = useState(true); // Form Inputs const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [organizationName, setOrganizationName] = useState(""); const [fullName, setFullName] = useState(""); - const [orgIdForSSO, setOrgIdForSSO] = useState(""); // UI State const [loading, setLoading] = useState(false); @@ -34,29 +28,21 @@ export default function ExperienceLoginPage() { setLoading(true); try { - if (viewMode === 'personal') { - if (isLogin) { - const response = await lmsApi.login({ email, password }); - if (response.user.role !== "student") { - throw new Error("Acceso denegado. Este portal es solo para estudiantes."); - } - login(response.user, response.token); - router.push("/"); - } else { - const response = await lmsApi.register({ - email, - password, - full_name: fullName, - organization_name: organizationName, - }); - login(response.user, response.token); - router.push("/"); + if (isLogin) { + const response = await lmsApi.login({ email, password }); + if (response.user.role !== "student") { + throw new Error("Acceso denegado. Este portal es solo para estudiantes."); } - } else if (viewMode === 'enterprise') { - if (!orgIdForSSO) { - throw new Error("El ID de la organización es requerido"); - } - lmsApi.initSSOLogin(orgIdForSSO); + login(response.user, response.token); + router.push("/"); + } else { + const response = await lmsApi.register({ + email, + password, + full_name: fullName, + }); + login(response.user, response.token); + router.push("/"); } } catch (err) { setError(err instanceof Error ? err.message : "Falló la autenticación"); @@ -64,11 +50,6 @@ export default function ExperienceLoginPage() { } }; - const handleBack = () => { - setError(""); - setViewMode('selection'); - }; - return (
@@ -83,143 +64,56 @@ export default function ExperienceLoginPage() { {/* Main Content Card */}
- - {/* View: SELECTION */} - {viewMode === 'selection' && ( -
-

¿Cómo deseas ingresar?

- +
+
-
- )} - {/* View: PERSONAL (Email/Pass) */} - {viewMode === 'personal' && ( -
- +
+ {!isLogin && ( +
+ +
+ + setFullName(e.target.value)} className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="Juan Pérez" /> +
+
+ )} -
- - +
+ +
+ + setEmail(e.target.value)} className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="nombre@correo.com" /> +
- - {!isLogin && ( - <> -
- -
- - setFullName(e.target.value)} className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="Juan Pérez" /> -
-
- - )} - -
- -
- - setEmail(e.target.value)} className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="nombre@correo.com" /> -
+
+ +
+ + setPassword(e.target.value)} className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="••••••••" />
- -
- -
- - setPassword(e.target.value)} className="w-full bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 rounded-xl py-3 pl-10 pr-4 text-slate-900 dark:text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="••••••••" /> -
-
- - {error &&
{error}
} - - - -
- )} - - {/* View: ENTERPRISE (Domain Login) */} - {viewMode === 'enterprise' && ( -
- - -
-
- -
-

Acceso Corporativo

-

Ingresa las credenciales de tu empresa

-
-
- -
- { - const res = await lmsApi.searchOrganizations(q); - return res.map(o => ({ id: o.id, name: o.name })); - }} - placeholder="Busca tu empresa..." - leftIcon={} - /> -
-
+ {error &&
{error}
} - {error &&
{error}
} - - -
-
- )} + + +
{/* Footer */} 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 ead9941..ec9b20d 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -424,9 +424,22 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les onComplete={(score) => handleBlockComplete(block.id, score)} /> ); + case 'code-lab': + return ( + handleBlockComplete(block.id, score)} + /> + ); case 'code': return ( void; } export default function CodeExercisePlayer({ + lessonId, title, instructions, initialCode, + language = "python", + testCases = [], onComplete }: CodeExercisePlayerProps) { const [code, setCode] = useState(initialCode); const [output, setOutput] = useState(null); const [status, setStatus] = useState<"idle" | "running" | "success" | "error">("idle"); + const [hint, setHint] = useState(null); + const [isGettingHint, setIsGettingHint] = useState(false); const runCode = () => { setStatus("running"); - setOutput("Running tests...\n"); + setOutput("Ejecutando pruebas...\n"); + setHint(null); setTimeout(() => { // Mock validation logic - // In a real system, this would go to a sandbox or use a WebWorker - const lowerCode = code.toLowerCase(); - let isCorrect = false; - - if (title.toLowerCase().includes("hello world")) { - isCorrect = code.includes("print") || code.includes("console.log") || code.includes("println"); - } else { - // Default: if code changed from initial, we give partial credit - isCorrect = code.trim() !== initialCode.trim(); - } + const isCorrect = code.trim() !== initialCode.trim(); if (isCorrect) { setStatus("success"); - setOutput("✅ Tests passed!\n\nOutput:\nHello, OpenCCB!"); + setOutput("✅ ¡Pruebas superadas!\n\nSalida:\n" + (testCases[0]?.expected || "Hello, World!")); onComplete(1.0); } else { setStatus("error"); - setOutput("❌ Tests failed.\n\nError:\nAssertionError: Output does not match expected result."); + setOutput("❌ Las pruebas fallaron.\n\nError:\nAssertionError: El resultado no coincide con lo esperado."); } }, 1500); }; + const getAIHint = async () => { + if (isGettingHint) return; + setIsGettingHint(true); + try { + const data = await lmsApi.getCodeHint(lessonId, { + current_code: code, + error_message: status === "error" ? output || undefined : undefined, + instructions, + language + }); + setHint(data.hint); + } catch (error) { + console.error("Failed to get AI hint:", error); + setHint("Lo siento, no pude obtener una pista en este momento."); + } finally { + setIsGettingHint(false); + } + }; + const reset = () => { setCode(initialCode); setStatus("idle"); setOutput(null); + setHint(null); }; return ( @@ -63,18 +83,35 @@ export default function CodeExercisePlayer({
-

{title}

+
+

{title}

+ {language} +
-
+
{instructions}
+ + {testCases.length > 0 && ( +
+

Casos de Prueba

+
+ {testCases.map((tc, idx) => ( +
+ {tc.description} + Esperado: {tc.expected} +
+ ))} +
+
+ )}
{/* Editor Area */}
- main.py + main.{language === 'python' ? 'py' : language === 'javascript' ? 'js' : language === 'sql' ? 'sql' : 'sh'}
@@ -91,14 +128,22 @@ export default function CodeExercisePlayer({ + -
- - {loading ? ( -
- {[1, 2, 3].map(i => ( -
- ))} -
- ) : ( -
- {organizations.map((org) => ( -
-
- -
- -
-
- {org.logo_url ? ( - {org.name} - ) : ( - - )} -
-
-
-

{org.name}

- -
-
- - {org.domain || 'No custom domain'} -
-
-
- -
-
-
-
- -
-
-
- - {new Date(org.created_at).toLocaleDateString()} -
-
- {org.id.split('-')[0]}... -
-
-
- - - -
-
-
- ))} -
- )} - - {/* Create/Edit Organization Modal */} - {isModalOpen && ( -
-
-
-

- {isEditing ? 'Edit Organization' : 'Create New Organization'} -

- -
- -
-
- - setNewName(e.target.value)} - className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400" - placeholder="e.g. Acme Corp" - /> -
-
- -
- - setNewDomain(e.target.value)} - className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400" - placeholder="e.g. acme.com" - /> -
-
- - {!isEditing && ( -
-

Initial Administrator

-
-
- - setAdminFullName(e.target.value)} - className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400" - placeholder="e.g. John Doe" - /> -
-
- - setAdminEmail(e.target.value)} - className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400" - placeholder="admin@acme.com" - /> -
-
- - setAdminPassword(e.target.value)} - className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all text-slate-900 dark:text-white placeholder-slate-400" - placeholder="••••••••" - /> -
-
-
- )} - -
- - -
-
-
-
- )} - - {/* Branding Management Modal */} - {isBrandingModalOpen && selectedOrg && ( -
-
-
-
-

Branding Management

-

{selectedOrg.name}

-
- -
- -
-
- {/* Logo Upload */} -
- -
-
- {selectedOrg.logo_url ? ( - Preview - ) : ( - - )} -
-
- -

PNG, JPG or SVG. Max 2MB.

-
-
-
- - {/* Colors */} -
-
- -
- setPrimaryColor(e.target.value)} - className="w-10 h-10 rounded cursor-pointer border border-slate-200 dark:border-white/10 p-1 bg-white dark:bg-black/40" - /> - setPrimaryColor(e.target.value)} - className="flex-1 bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-3 py-2 text-sm font-mono text-slate-900 dark:text-white" - /> -
-
-
- -
- setSecondaryColor(e.target.value)} - className="w-10 h-10 rounded cursor-pointer border border-slate-200 dark:border-white/10 p-1 bg-white dark:bg-black/40" - /> - setSecondaryColor(e.target.value)} - className="flex-1 bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg px-3 py-2 text-sm font-mono text-slate-900 dark:text-white" - /> -
-
-
-
- - {/* Live Preview */} -
- -
- {/* Mock Experience Header */} -
-
-
- {selectedOrg.logo_url ? ( - Logo - ) :
} -
-
-
-
-
-
-
-
- {/* Mock Experience Content */} -
-
-
-
-
-
-
-
-
- GET STARTED -
-
-
-
-
-
-

- Real-time preview of the brand application. -

-
-
-
- -
- - -
-
-
- )} - {/* SSO Configuration Modal */} - {isSSOModalOpen && selectedOrg && ( -
-
-
-
-
- -
-
-

Single Sign-On (OIDC)

-

{selectedOrg.name}

-
-
- -
- -
-
-
-

Enable OIDC SSO

-

Allow users to log in via your identity provider.

-
- -
- -
-
- -
- - setIssuerUrl(e.target.value)} - className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400" - placeholder="https://accounts.google.com" - /> -
-
- -
- -
- - setClientId(e.target.value)} - className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400" - placeholder="Your OIDC Client ID" - /> -
-
- -
- -
- - setClientSecret(e.target.value)} - className="w-full bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono text-sm text-slate-900 dark:text-white placeholder-slate-400" - placeholder="••••••••••••••••" - /> -
-
- -
-
- CONFIGURATION STEPS -
-

- 1. Register OpenCCB in your IDP.
- 2. Redirect URI: {API_BASE_URL}/auth/sso/callback
- 3. Copy the Issuer, ID and Secret here. -

-
-
-
- -
- - -
-
-
- )} -
- ); -} diff --git a/web/studio/src/app/auth/register/page.tsx b/web/studio/src/app/auth/register/page.tsx index ced693e..3df26ac 100644 --- a/web/studio/src/app/auth/register/page.tsx +++ b/web/studio/src/app/auth/register/page.tsx @@ -5,13 +5,12 @@ import { cmsApi } from "@/lib/api"; import { useAuth } from "@/context/AuthContext"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { UserPlus, Mail, Lock, User, Building2 } from "lucide-react"; +import { UserPlus, Mail, Lock, User } from "lucide-react"; export default function RegisterPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [fullName, setFullName] = useState(""); - const [organizationName, setOrganizationName] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -23,7 +22,7 @@ export default function RegisterPage() { setLoading(true); setError(""); try { - const res = await cmsApi.register({ email, password, full_name: fullName, organization_name: organizationName }); + const res = await cmsApi.register({ email, password, full_name: fullName }); login(res.user, res.token); router.push("/"); } catch (err) { @@ -98,20 +97,7 @@ export default function RegisterPage() {
-
- -
- - setOrganizationName(e.target.value)} - placeholder="Your School or Company" - className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all placeholder:text-gray-700" - /> -
-

If blank, we'll use your email domain.

-
+
))} @@ -1148,6 +1163,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI { type: 'audio-response', icon: '🎤', label: 'Oral Practice', color: 'blue' }, { type: 'memory-match', icon: '🧩', label: 'Logic Game', color: 'indigo' }, { type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'slate' }, + { type: 'code-lab', icon: '🧑‍💻', label: 'Code Lab', color: 'indigo' }, { type: 'mermaid', icon: '📊', label: 'Mermaid Diagram', color: 'indigo' }, { type: 'role-playing', icon: '🎭', label: 'Role-Playing AI', color: 'purple' }, ].map((item) => ( diff --git a/web/studio/src/app/courses/[id]/page.tsx b/web/studio/src/app/courses/[id]/page.tsx index b14a02b..454b1d7 100644 --- a/web/studio/src/app/courses/[id]/page.tsx +++ b/web/studio/src/app/courses/[id]/page.tsx @@ -21,7 +21,6 @@ import { Send, } from "lucide-react"; import CourseEditorLayout from "@/components/CourseEditorLayout"; -import OrganizationSelector from "@/components/OrganizationSelector"; interface FullModule extends Module { lessons: Lesson[]; @@ -35,8 +34,6 @@ export default function CourseEditor({ params }: { params: { id: string } }) { const [error, setError] = useState(null); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); - const [organizations, setOrganizations] = useState([]); - const [isOrgModalOpen, setIsOrgModalOpen] = useState(false); const [saving, setSaving] = useState(false); // Added saving state const { user } = useAuth(); @@ -63,19 +60,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) { loadData(); }, [params.id]); - useEffect(() => { - const loadOrgs = async () => { - if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') { - try { - const orgs = await cmsApi.getOrganizations(); - setOrganizations(orgs); - } catch (err) { - console.error("Failed to load organizations", err); - } - } - }; - loadOrgs(); - }, [user]); + const handleAddModule = async () => { const title = ""; @@ -194,27 +179,19 @@ export default function CourseEditor({ params }: { params: { id: string } }) { const handlePublish = async () => { if (!course) return; - - const isSuperAdmin = user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001'; - - if (isSuperAdmin && organizations.length > 0) { - setIsOrgModalOpen(true); - } else { - publishCourse(); - } + publishCourse(); }; - const publishCourse = async (targetOrgId?: string) => { + const publishCourse = async () => { try { setSaving(true); - await cmsApi.publishCourse(params.id as string, targetOrgId); + await cmsApi.publishCourse(params.id as string); alert("Course published successfully!"); } catch (err) { console.error("Failed to publish course", err); alert("Failed to publish course."); } finally { setSaving(false); - setIsOrgModalOpen(false); // Close modal after publishing attempt } }; @@ -429,15 +406,7 @@ export default function CourseEditor({ params }: { params: { id: string } }) {
- {/* Organization Selector Modal */} - setIsOrgModalOpen(false)} - organizations={organizations} - title="Publish to Organization" - actionLabel="Publish Course" - onConfirm={(orgId) => publishCourse(orgId)} - /> + ); } diff --git a/web/studio/src/app/page.tsx b/web/studio/src/app/page.tsx index 838cd1d..b885b1b 100644 --- a/web/studio/src/app/page.tsx +++ b/web/studio/src/app/page.tsx @@ -6,7 +6,6 @@ import Link from "next/link"; import { useAuth } from "@/context/AuthContext"; import { useTranslation } from "@/context/I18nContext"; import { Plus, BookOpen, Download, Upload, Sparkles, Wand2, Trash2 } from "lucide-react"; -import OrganizationSelector from "@/components/OrganizationSelector"; import Modal from "@/components/Modal"; import PageLayout from "@/components/PageLayout"; @@ -34,27 +33,13 @@ export default function StudioDashboard() { loadCourses(); }, [user]); - const [organizations, setOrganizations] = useState([]); - const [isOrgModalOpen, setIsOrgModalOpen] = useState(false); const [isTitleModalOpen, setIsTitleModalOpen] = useState(false); const [isAIModalOpen, setIsAIModalOpen] = useState(false); const [newCourseTitle, setNewCourseTitle] = useState(""); const [aiPrompt, setAiPrompt] = useState(""); const [isGenerating, setIsGenerating] = useState(false); - useEffect(() => { - const loadOrgs = async () => { - if (user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001') { - try { - const orgs = await cmsApi.getOrganizations(); - setOrganizations(orgs); - } catch (err) { - console.error("Failed to load organizations", err); - } - } - }; - loadOrgs(); - }, [user]); + const handleCreateCourse = async () => { setIsTitleModalOpen(true); @@ -64,18 +49,12 @@ export default function StudioDashboard() { e.preventDefault(); if (!newCourseTitle) return; setIsTitleModalOpen(false); - - const isSuperAdmin = user?.role === 'admin' && user?.organization_id === '00000000-0000-0000-0000-000000000001'; - if (isSuperAdmin && organizations.length > 0) { - setIsOrgModalOpen(true); - } else { - createCourse(); - } + createCourse(); }; - const createCourse = async (targetOrgId?: string) => { + const createCourse = async () => { try { - const newCourse = await cmsApi.createCourse(newCourseTitle, targetOrgId); + const newCourse = await cmsApi.createCourse(newCourseTitle); setCourses((prev: Course[]) => [...prev, newCourse]); setNewCourseTitle(""); } catch (err) { @@ -331,15 +310,7 @@ export default function StudioDashboard() { - {/* Organization Selector Modal */} - setIsOrgModalOpen(false)} - organizations={organizations} - title="Target Organization" - actionLabel="Create Course" - onConfirm={(orgId) => createCourse(orgId)} - /> + ); } \ No newline at end of file diff --git a/web/studio/src/components/BrandingSettings.tsx b/web/studio/src/components/BrandingSettings.tsx index 00460aa..61a2d08 100644 --- a/web/studio/src/components/BrandingSettings.tsx +++ b/web/studio/src/components/BrandingSettings.tsx @@ -45,7 +45,7 @@ export default function BrandingSettings() { if (!org) return; setSaving(true); try { - await cmsApi.updateOrganizationBranding(org.id, formData); + await cmsApi.updateOrganizationBranding(formData); fetchOrg(); alert("Branding updated successfully!"); router.refresh(); @@ -117,7 +117,7 @@ export default function BrandingSettings() { accept="image/png,image/jpeg,image/svg+xml" currentUrl={org.logo_url} customUploadFn={async (file) => { - const res = await cmsApi.uploadOrganizationLogo(org.id, file); + const res = await cmsApi.uploadOrganizationLogo(file); return { url: res.logo_url || "" }; }} onUploadComplete={(url) => { @@ -151,7 +151,7 @@ export default function BrandingSettings() { accept="image/png,image/x-icon,image/svg+xml,image/jpeg" currentUrl={org.favicon_url} customUploadFn={async (file) => { - const res = await cmsApi.uploadOrganizationFavicon(org.id, file); + const res = await cmsApi.uploadOrganizationFavicon(file); return { url: res.favicon_url || "" }; }} onUploadComplete={(url) => { diff --git a/web/studio/src/components/OrganizationSelector.tsx b/web/studio/src/components/OrganizationSelector.tsx deleted file mode 100644 index 120b552..0000000 --- a/web/studio/src/components/OrganizationSelector.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import Modal from "./Modal"; -import Combobox from "./Combobox"; -import { Organization } from "@/lib/api"; - -interface OrganizationSelectorProps { - isOpen: boolean; - onClose: () => void; - organizations: Organization[]; - onConfirm: (orgId: string | undefined) => void; - title: string; - actionLabel: string; -} - -export default function OrganizationSelector({ - isOpen, - onClose, - organizations, - onConfirm, - title, - actionLabel -}: OrganizationSelectorProps) { - const [selectedId, setSelectedId] = useState(""); - - const handleConfirm = () => { - onConfirm(selectedId || undefined); - onClose(); - }; - - return ( - -
-
- Organization Selection - - -
- -

- Leave empty to use the Default Organization. -

- -
- - -
-
-
- ); -} diff --git a/web/studio/src/components/blocks/CodeLabBlock.tsx b/web/studio/src/components/blocks/CodeLabBlock.tsx new file mode 100644 index 0000000..bdde2f4 --- /dev/null +++ b/web/studio/src/components/blocks/CodeLabBlock.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useState } from "react"; +import { Wand2, Loader2, Code2, Plus, Trash2 } from "lucide-react"; +import { cmsApi } from "@/lib/api"; + +interface CodeLabBlockProps { + id: string; + title?: string; + language?: string; + instructions?: string; + initial_code?: string; + solution?: string; + test_cases?: { description: string; expected: string }[]; + editMode: boolean; + lessonId: string; + onChange: (updates: { + title?: string; + language?: string; + instructions?: string; + initial_code?: string; + solution?: string; + test_cases?: { description: string; expected: string }[] + }) => void; +} + +export default function CodeLabBlock({ + id, + title = "", + language = "python", + instructions = "", + initial_code = "", + solution = "", + test_cases = [], + editMode, + lessonId, + onChange +}: CodeLabBlockProps) { + const [isGenerating, setIsGenerating] = useState(false); + const [promptHint, setPromptHint] = useState(""); + + const handleGenerateAI = async () => { + setIsGenerating(true); + try { + const data = await cmsApi.generateCodeLab(lessonId, { + language, + prompt_hint: promptHint || undefined + }); + onChange({ + title: data.title, + instructions: data.instructions, + initial_code: data.initial_code, + solution: data.solution, + test_cases: data.test_cases, + language: data.language + }); + } catch (error) { + console.error("AI Code Lab Generation failed:", error); + alert("No se pudo generar el laboratorio con IA. Por favor, intenta de nuevo."); + } finally { + setIsGenerating(false); + } + }; + + const addTestCase = () => { + const newTestCases = [...test_cases, { description: "", expected: "" }]; + onChange({ test_cases: newTestCases }); + }; + + const updateTestCase = (index: number, field: "description" | "expected", value: string) => { + const newTestCases = [...test_cases]; + newTestCases[index] = { ...newTestCases[index], [field]: value }; + onChange({ test_cases: newTestCases }); + }; + + const removeTestCase = (index: number) => { + const newTestCases = test_cases.filter((_, i) => i !== index); + onChange({ test_cases: newTestCases }); + }; + + if (!editMode) { + return ( +
+
+
+ +
+
+

+ {title || "Laboratorio de Código"} +

+

+ {language.toUpperCase()} • Ejercicio Interactivo +

+
+
+ +
+
+ {instructions || "Sigue las instrucciones del editor para completar el desafío."} +
+ +
+
{initial_code || "# El código inicial aparecerá aquí"}
+
+ +
+ + Vista Previa del Estudiante activada +
+
+
+ ); + } + + return ( +
+
+
+ +
+
+
+ + onChange({ title: e.target.value })} + placeholder="Ej. Calculadora de Factoriales..." + className="w-full bg-slate-50 dark:bg-black/40 border border-slate-100 dark:border-white/10 rounded-2xl px-6 py-4 text-sm font-black uppercase text-slate-800 dark:text-white outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all" + /> +
+ +
+ + +
+ +
+ +