diff --git a/services/cms-service/migrations/20260413000001_organization_exercise_settings.sql b/services/cms-service/migrations/20260413000001_organization_exercise_settings.sql new file mode 100644 index 0000000..de0d58e --- /dev/null +++ b/services/cms-service/migrations/20260413000001_organization_exercise_settings.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS organization_exercise_settings ( + organization_id UUID PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE, + audio_response_enabled BOOLEAN NOT NULL DEFAULT TRUE, + hotspot_enabled BOOLEAN NOT NULL DEFAULT TRUE, + memory_match_enabled BOOLEAN NOT NULL DEFAULT TRUE, + peer_review_enabled BOOLEAN NOT NULL DEFAULT TRUE, + role_playing_enabled BOOLEAN NOT NULL DEFAULT TRUE, + mermaid_enabled BOOLEAN NOT NULL DEFAULT FALSE, + code_lab_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_org_exercise_settings_updated_at + ON organization_exercise_settings (updated_at DESC); diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 261ea5d..65cce95 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1,4 +1,5 @@ use crate::exporter; +use crate::handlers_exercise_settings::load_organization_exercise_settings; use crate::webhooks::WebhookService; pub mod tasks; use axum::{ @@ -6,6 +7,12 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, }; +use aws_config::BehaviorVersion; +use aws_config::meta::region::RegionProviderChain; +use aws_sdk_s3::{ + Client as S3Client, + config::{Credentials, Region}, +}; use bcrypt::{DEFAULT_COST, hash, verify}; use chrono::{DateTime, Utc}; pub use common::auth::Claims; @@ -30,6 +37,21 @@ use openidconnect::{ RedirectUrl, Scope, TokenResponse, }; +async fn is_org_exercise_enabled( + pool: &PgPool, + organization_id: Uuid, + feature: &str, +) -> Result { + let settings = load_organization_exercise_settings(pool, organization_id) + .await + .map_err(|e| { + tracing::error!("Failed to load exercise settings for org {}: {}", organization_id, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(settings.is_enabled(feature)) +} + #[derive(Deserialize)] pub struct SSOCallbackParams { pub code: String, @@ -63,6 +85,126 @@ fn count_tokens(text: &str) -> i32 { ((char_count as f64) / 4.0).ceil() as i32 } +#[derive(Debug, Clone)] +struct HotspotS3Settings { + region: String, + endpoint: Option, + force_path_style: bool, +} + +fn get_hotspot_s3_settings() -> Option { + let enabled = env::var("ASSETS_STORAGE") + .unwrap_or_else(|_| "local".to_string()) + .to_lowercase(); + + if enabled != "s3" { + return None; + } + + let region = env::var("S3_REGION").unwrap_or_else(|_| "us-east-2".to_string()); + let endpoint = env::var("S3_ENDPOINT").ok().filter(|v| !v.trim().is_empty()); + let force_path_style = env::var("S3_FORCE_PATH_STYLE") + .map(|v| { + let lower = v.to_lowercase(); + lower == "1" || lower == "true" || lower == "yes" + }) + .unwrap_or(false); + + Some(HotspotS3Settings { + region, + endpoint, + force_path_style, + }) +} + +async fn build_hotspot_s3_client(settings: &HotspotS3Settings) -> Result { + let region_provider = RegionProviderChain::first_try(Some(Region::new(settings.region.clone()))) + .or_default_provider(); + + let mut loader = aws_config::defaults(BehaviorVersion::latest()).region(region_provider); + + let access_key = env::var("AWS_ACCESS_KEY_ID").ok(); + let secret_key = env::var("AWS_SECRET_ACCESS_KEY").ok(); + if let (Some(ak), Some(sk)) = (access_key, secret_key) { + let creds = Credentials::new(ak, sk, None, None, "env"); + loader = loader.credentials_provider(creds); + } + + let shared_config = loader.load().await; + let mut s3_builder = aws_sdk_s3::config::Builder::from(&shared_config); + if let Some(endpoint) = &settings.endpoint { + s3_builder = s3_builder.endpoint_url(endpoint); + } + if settings.force_path_style { + s3_builder = s3_builder.force_path_style(true); + } + + Ok(S3Client::from_conf(s3_builder.build())) +} + +fn parse_hotspot_s3_proxy_path(path: &str) -> Option<(String, String)> { + let normalized = path.trim_start_matches('/'); + let remainder = normalized + .strip_prefix("cms-api/api/assets/s3-proxy/") + .or_else(|| normalized.strip_prefix("api/assets/s3-proxy/"))?; + + let mut parts = remainder.splitn(2, '/'); + let bucket = parts.next()?.trim(); + let key = parts.next()?.trim(); + if bucket.is_empty() || key.is_empty() { + return None; + } + + Some((bucket.to_string(), key.to_string())) +} + +fn parse_hotspot_s3_uri(path: &str) -> Option<(String, String)> { + let remainder = path.strip_prefix("s3://")?; + let mut parts = remainder.splitn(2, '/'); + let bucket = parts.next()?.trim(); + let key = parts.next()?.trim(); + if bucket.is_empty() || key.is_empty() { + return None; + } + + Some((bucket.to_string(), key.to_string())) +} + +async fn read_hotspot_s3_proxy_bytes(path: &str) -> Result, String)>, StatusCode> { + let s3_ref = parse_hotspot_s3_proxy_path(path).or_else(|| parse_hotspot_s3_uri(path)); + let Some((bucket, key)) = s3_ref else { + return Ok(None); + }; + + let Some(settings) = get_hotspot_s3_settings() else { + tracing::warn!("Hotspot received S3 proxy path but ASSETS_STORAGE is not configured for S3: {}", path); + return Err(StatusCode::NOT_FOUND); + }; + + let client = build_hotspot_s3_client(&settings).await?; + let output = client + .get_object() + .bucket(&bucket) + .key(&key) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to read hotspot image from S3 {}/{}: {}", bucket, key, e); + StatusCode::BAD_GATEWAY + })?; + + let bytes = output.body.collect().await.map_err(|e| { + tracing::error!("Failed to collect hotspot image body from S3 {}/{}: {}", bucket, key, e); + StatusCode::BAD_GATEWAY + })?; + + let mime = mime_guess::from_path(&key) + .first_or_octet_stream() + .to_string(); + + Ok(Some((bytes.into_bytes().to_vec(), mime))) +} + pub async fn publish_course( Org(org_ctx): Org, claims: Claims, @@ -1879,19 +2021,29 @@ pub async fn reorder_lessons( #[derive(Deserialize)] pub struct GenerateMermaidPayload { + #[allow(dead_code)] pub prompt_hint: Option, } pub async fn generate_mermaid_diagram( Org(org_ctx): Org, - _claims: Claims, + claims: Claims, State(pool): State, Path(lesson_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { + if !is_org_exercise_enabled(&pool, org_ctx.id, "mermaid") + .await + .map_err(|status| (status, "No se pudo validar la configuración de Mermaid".to_string()))? + { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "La generación de diagramas Mermaid está desactivada para esta organización".to_string(), + )); + } + tracing::info!("Generating Mermaid Diagram for lesson_id={}", lesson_id); - // Fetch lesson for context let lesson = sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") .bind(lesson_id) .bind(org_ctx.id) @@ -1972,19 +2124,17 @@ pub async fn generate_mermaid_diagram( .unwrap_or("") .trim(); - // Clean any accidental markdown backticks the LLM might have still inserted let cleaned_response = ai_response .strip_prefix("```mermaid\n").unwrap_or(ai_response) .strip_prefix("```\n").unwrap_or(ai_response) .strip_suffix("```").unwrap_or(ai_response).trim(); - // Calculate and log token usage let input_tokens = count_tokens(&system_prompt) + count_tokens("Genera el código Mermaid directamente."); let output_tokens = count_tokens(cleaned_response); let total_tokens = input_tokens + output_tokens; let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)") - .bind(_claims.sub) + .bind(claims.sub) .bind(org_ctx.id) .bind(total_tokens) .bind(input_tokens) @@ -1996,8 +2146,8 @@ pub async fn generate_mermaid_diagram( "lesson_id": lesson_id, "hint": payload.prompt_hint, })) - .bind(&system_prompt) // prompt - .bind(cleaned_response) // response + .bind(&system_prompt) + .bind(cleaned_response) .execute(&pool) .await; @@ -2019,6 +2169,16 @@ pub async fn generate_code_lab( Path(lesson_id): Path, Json(payload): Json, ) -> Result, (StatusCode, String)> { + if !is_org_exercise_enabled(&pool, org_ctx.id, "code-lab") + .await + .map_err(|status| (status, "No se pudo validar la configuración de Code Lab".to_string()))? + { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "Code Lab está desactivado para esta organización".to_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") @@ -2159,28 +2319,128 @@ pub async fn generate_hotspots( Path(lesson_id): Path, Json(payload): Json, ) -> Result, StatusCode> { + if !is_org_exercise_enabled(&pool, org_ctx.id, "hotspot").await? { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + // Check token limit before proceeding (estimate 2000 tokens for hotspots) if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 2000).await { return Err(StatusCode::TOO_MANY_REQUESTS); } // 1. Resolve image path - // imageUrl in frontend is like "/assets/filename.ext" - // We need to map it to "uploads/filename.ext" - let filename = payload.image_url.split('/').last().unwrap_or_default(); - if filename.is_empty() { + // Accept common formats used by Studio: + // - /assets/ + // - /uploads/ + // - assets/ + // - uploads/ + // - absolute URLs (we use only their path component) + let raw_input = payload.image_url.trim(); + if raw_input.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + let is_absolute_url = raw_input.starts_with("http://") || raw_input.starts_with("https://"); + let mut path_only = if is_absolute_url { + match reqwest::Url::parse(raw_input) { + Ok(url) => url.path().to_string(), + Err(_) => raw_input.to_string(), + } + } else { + raw_input.to_string() + }; + + if let Some((without_query, _)) = path_only.split_once('?') { + path_only = without_query.to_string(); + } + + let mut storage_path = if let Some(rest) = path_only.strip_prefix("/assets/") { + format!("uploads/{}", rest) + } else if let Some(rest) = path_only.strip_prefix("assets/") { + format!("uploads/{}", rest) + } else if let Some(rest) = path_only.strip_prefix("/uploads/") { + format!("uploads/{}", rest) + } else if path_only.starts_with("uploads/") { + path_only.clone() + } else { + let filename = path_only.split('/').last().unwrap_or_default(); + if filename.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + format!("uploads/{}", filename) + }; + + storage_path = storage_path.replace('\\', "/"); + if storage_path.contains("..") { + tracing::warn!("Invalid hotspot image path traversal attempt: {}", storage_path); return Err(StatusCode::BAD_REQUEST); } - let storage_path = format!("uploads/{}", filename); // 2. Read and encode image - let image_data = tokio::fs::read(&storage_path).await.map_err(|e| { - tracing::error!("Failed to read image at {}: {}", storage_path, e); - StatusCode::NOT_FOUND - })?; + // Prefer direct HTTP fetch for absolute URLs (e.g. /api/assets/s3-proxy), + // and fallback to local disk resolution for legacy /assets/* and uploads/* paths. + let (image_data, mime_type) = if is_absolute_url { + match reqwest::get(raw_input).await { + Ok(response) if response.status().is_success() => { + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + mime_guess::from_path(&path_only) + .first_or_octet_stream() + .to_string() + }); + + let bytes = response.bytes().await.map_err(|e| { + tracing::error!("Failed to read hotspot image bytes from {}: {}", raw_input, e); + StatusCode::BAD_GATEWAY + })?; + + (bytes.to_vec(), content_type) + } + Ok(response) => { + tracing::warn!( + "Hotspot image URL {} returned non-success status {}. Falling back to local path {}", + raw_input, + response.status(), + storage_path + ); + let bytes = tokio::fs::read(&storage_path).await.map_err(|e| { + tracing::error!("Failed to read image at {}: {}", storage_path, e); + StatusCode::NOT_FOUND + })?; + let mime = mime_guess::from_path(&storage_path).first_or_octet_stream().to_string(); + (bytes, mime) + } + Err(err) => { + tracing::warn!( + "Hotspot image URL fetch failed for {}: {}. Falling back to local path {}", + raw_input, + err, + storage_path + ); + let bytes = tokio::fs::read(&storage_path).await.map_err(|e| { + tracing::error!("Failed to read image at {}: {}", storage_path, e); + StatusCode::NOT_FOUND + })?; + let mime = mime_guess::from_path(&storage_path).first_or_octet_stream().to_string(); + (bytes, mime) + } + } + } else if let Some((bytes, mime)) = read_hotspot_s3_proxy_bytes(&path_only).await? { + (bytes, mime) + } else { + let bytes = tokio::fs::read(&storage_path).await.map_err(|e| { + tracing::error!("Failed to read image at {}: {}", storage_path, e); + StatusCode::NOT_FOUND + })?; + let mime = mime_guess::from_path(&storage_path).first_or_octet_stream().to_string(); + (bytes, mime) + }; let base64_image = general_purpose::STANDARD.encode(image_data); - let mime_type = mime_guess::from_path(&storage_path).first_or_octet_stream().to_string(); let image_url_data = format!("data:{};base64,{}", mime_type, base64_image); // 3. Fetch lesson context (optional but helpful for AI) @@ -2372,6 +2632,10 @@ pub async fn generate_role_play( Path(lesson_id): Path, Json(payload): Json, ) -> Result, StatusCode> { + if !is_org_exercise_enabled(&pool, org_ctx.id, "role-playing").await? { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + // Check token limit before proceeding (estimate 2500 tokens for role-play) if let Err(_) = common::token_limits::check_ai_token_limit(&pool, claims.sub, 2500).await { return Err(StatusCode::TOO_MANY_REQUESTS); diff --git a/services/cms-service/src/handlers_exercise_settings.rs b/services/cms-service/src/handlers_exercise_settings.rs new file mode 100644 index 0000000..63f4b51 --- /dev/null +++ b/services/cms-service/src/handlers_exercise_settings.rs @@ -0,0 +1,197 @@ +use axum::{ + Json, + extract::State, + http::StatusCode, +}; +use common::auth::Claims; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use uuid::Uuid; + +use super::handlers::{log_action, Org}; + +#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)] +pub struct OrganizationExerciseSettings { + pub organization_id: Uuid, + pub audio_response_enabled: bool, + pub hotspot_enabled: bool, + pub memory_match_enabled: bool, + pub peer_review_enabled: bool, + pub role_playing_enabled: bool, + pub mermaid_enabled: bool, + pub code_lab_enabled: bool, +} + +impl OrganizationExerciseSettings { + pub fn defaults(organization_id: Uuid) -> Self { + Self { + organization_id, + audio_response_enabled: true, + hotspot_enabled: true, + memory_match_enabled: true, + peer_review_enabled: true, + role_playing_enabled: true, + mermaid_enabled: false, + code_lab_enabled: true, + } + } + + pub fn is_enabled(&self, feature: &str) -> bool { + match feature { + "audio-response" => self.audio_response_enabled, + "hotspot" => self.hotspot_enabled, + "memory-match" => self.memory_match_enabled, + "peer-review" => self.peer_review_enabled, + "role-playing" => self.role_playing_enabled, + "mermaid" => self.mermaid_enabled, + "code-lab" => self.code_lab_enabled, + _ => true, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateOrganizationExerciseSettingsPayload { + pub audio_response_enabled: bool, + pub hotspot_enabled: bool, + pub memory_match_enabled: bool, + pub peer_review_enabled: bool, + pub role_playing_enabled: bool, + pub mermaid_enabled: bool, + pub code_lab_enabled: bool, +} + +pub async fn load_organization_exercise_settings( + pool: &PgPool, + organization_id: Uuid, +) -> Result { + let settings = sqlx::query_as::<_, OrganizationExerciseSettings>( + r#" + SELECT + organization_id, + audio_response_enabled, + hotspot_enabled, + memory_match_enabled, + peer_review_enabled, + role_playing_enabled, + mermaid_enabled, + code_lab_enabled + FROM organization_exercise_settings + WHERE organization_id = $1 + "#, + ) + .bind(organization_id) + .fetch_optional(pool) + .await?; + + Ok(settings.unwrap_or_else(|| OrganizationExerciseSettings::defaults(organization_id))) +} + +async fn upsert_organization_exercise_settings( + pool: &PgPool, + organization_id: Uuid, + payload: &UpdateOrganizationExerciseSettingsPayload, +) -> Result { + sqlx::query_as::<_, OrganizationExerciseSettings>( + r#" + INSERT INTO organization_exercise_settings ( + organization_id, + audio_response_enabled, + hotspot_enabled, + memory_match_enabled, + peer_review_enabled, + role_playing_enabled, + mermaid_enabled, + code_lab_enabled, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) + ON CONFLICT (organization_id) DO UPDATE SET + audio_response_enabled = EXCLUDED.audio_response_enabled, + hotspot_enabled = EXCLUDED.hotspot_enabled, + memory_match_enabled = EXCLUDED.memory_match_enabled, + peer_review_enabled = EXCLUDED.peer_review_enabled, + role_playing_enabled = EXCLUDED.role_playing_enabled, + mermaid_enabled = EXCLUDED.mermaid_enabled, + code_lab_enabled = EXCLUDED.code_lab_enabled, + updated_at = NOW() + RETURNING + organization_id, + audio_response_enabled, + hotspot_enabled, + memory_match_enabled, + peer_review_enabled, + role_playing_enabled, + mermaid_enabled, + code_lab_enabled + "#, + ) + .bind(organization_id) + .bind(payload.audio_response_enabled) + .bind(payload.hotspot_enabled) + .bind(payload.memory_match_enabled) + .bind(payload.peer_review_enabled) + .bind(payload.role_playing_enabled) + .bind(payload.mermaid_enabled) + .bind(payload.code_lab_enabled) + .fetch_one(pool) + .await +} + +pub async fn get_organization_exercise_settings( + Org(org_ctx): Org, + State(pool): State, +) -> Result, (StatusCode, String)> { + let settings = load_organization_exercise_settings(&pool, org_ctx.id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al cargar configuración de ejercicios: {}", e), + ) + })?; + + Ok(Json(settings)) +} + +pub async fn update_organization_exercise_settings( + claims: Claims, + Org(org_ctx): Org, + State(pool): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if claims.role != "admin" { + return Err((StatusCode::FORBIDDEN, "Se requiere acceso de administrador".into())); + } + + let settings = upsert_organization_exercise_settings(&pool, org_ctx.id, &payload) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Error al guardar configuración de ejercicios: {}", e), + ) + })?; + + log_action( + &pool, + claims.org, + claims.sub, + "UPDATE_EXERCISE_SETTINGS", + "Organization", + org_ctx.id, + json!({ + "audio_response_enabled": settings.audio_response_enabled, + "hotspot_enabled": settings.hotspot_enabled, + "memory_match_enabled": settings.memory_match_enabled, + "peer_review_enabled": settings.peer_review_enabled, + "role_playing_enabled": settings.role_playing_enabled, + "mermaid_enabled": settings.mermaid_enabled, + "code_lab_enabled": settings.code_lab_enabled, + }), + ) + .await; + + Ok(Json(settings)) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 00f0444..b0cf4e0 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_exercise_settings; mod handlers_assets; mod handlers_dependencies; mod handlers_library; @@ -296,6 +297,11 @@ async fn main() { "/organization/branding", axum::routing::put(handlers_branding::update_organization_branding), ) + .route( + "/organization/exercise-settings", + get(handlers_exercise_settings::get_organization_exercise_settings) + .put(handlers_exercise_settings::update_organization_exercise_settings), + ) // Rutas de librerías de contenido .route( "/library/blocks", diff --git a/web/experience/src/components/blocks/MediaPlayer.tsx b/web/experience/src/components/blocks/MediaPlayer.tsx index 2ce6627..a21f422 100644 --- a/web/experience/src/components/blocks/MediaPlayer.tsx +++ b/web/experience/src/components/blocks/MediaPlayer.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { Play, Lock, AlertCircle } from "lucide-react"; -import { lmsApi, getCmsApiUrl } from "@/lib/api"; +import { lmsApi, getCmsApiUrl, getImageUrl } from "@/lib/api"; interface MediaPlayerProps { id: string; @@ -49,7 +49,9 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf const getFullUrl = (path: string) => { - if (path.startsWith('http')) return path; + if (path.startsWith('http') || path.startsWith('s3://') || path.startsWith('org/')) { + return getImageUrl(path); + } // Map /uploads to /assets for the backend const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path; const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index 65c06b8..fd5ff56 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -27,7 +27,59 @@ export const getCmsApiUrl = () => { export const getImageUrl = (path?: string) => { if (!path) return ''; - if (path.startsWith('http')) return path; + if (path.startsWith('http')) { + // Avoid browser CORS issues with private S3 objects by proxying through CMS. + try { + const parsed = new URL(path); + const host = parsed.hostname; + const isAwsS3 = host.includes('.s3.') || host.endsWith('.amazonaws.com'); + if (isAwsS3) { + const key = parsed.pathname.replace(/^\//, ''); + let bucket = ''; + + // virtual-host style: .s3..amazonaws.com + if (host.includes('.s3.')) { + bucket = host.split('.s3.')[0]; + } else { + // path-style: s3..amazonaws.com// + const [first, ...rest] = key.split('/'); + if (first && rest.length) { + bucket = first; + const normalizedKey = rest.join('/'); + return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${normalizedKey}`; + } + } + + if (bucket && key) { + return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`; + } + } + } catch { + // Ignore URL parsing errors and fallback to original path. + } + + return path; + } + + // Handle persisted S3 URI format: s3://bucket/key + if (path.startsWith('s3://')) { + const withoutScheme = path.slice(5); + const firstSlash = withoutScheme.indexOf('/'); + if (firstSlash > 0) { + const bucket = withoutScheme.slice(0, firstSlash); + const key = withoutScheme.slice(firstSlash + 1); + if (bucket && key) { + return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`; + } + } + } + + // Handle plain object keys when stored directly in DB. + if (/^org\/.+/.test(path)) { + const defaultBucket = process.env.NEXT_PUBLIC_S3_BUCKET || 'openccb-802726101181-us-east-2-an'; + return `${getCmsApiUrl()}/api/assets/s3-proxy/${encodeURIComponent(defaultBucket)}/${path.replace(/^\//, '')}`; + } + const cleanPath = path.startsWith('/uploads') ? path.replace('/uploads', '/assets') : path; const finalPath = cleanPath.startsWith('/') ? cleanPath : `/${cleanPath}`; return `${getCmsApiUrl()}${finalPath}`; diff --git a/web/studio/src/app/admin/layout.tsx b/web/studio/src/app/admin/layout.tsx index d730e99..f1f6cd0 100644 --- a/web/studio/src/app/admin/layout.tsx +++ b/web/studio/src/app/admin/layout.tsx @@ -15,7 +15,7 @@ import { Mic, FileArchive, Gauge, - MessageSquareQuestion + MessageSquare } from "lucide-react"; export default function AdminLayout({ children }: { children: React.ReactNode }) { @@ -26,7 +26,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { icon: Building2, label: "Organizations", href: "/admin" }, { icon: Users, label: "Users", href: "/admin/users" }, { icon: Gauge, label: "Tokens IA", href: "/admin/token-usage" }, - { icon: MessageSquareQuestion, label: "FAQ Moderation", href: "/admin/faq-review" }, + { icon: MessageSquare, label: "FAQ Moderation", href: "/admin/faq-review" }, { icon: FileArchive, label: "Material Compartido", href: "/admin/materials" }, { icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" }, { icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" }, diff --git a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 2e60107..11715ff 100644 --- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback } from "react"; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; -import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, getImageUrl, generateUUID } from '@/lib/api'; +import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, OrganizationExerciseSettings, getImageUrl, generateUUID } from '@/lib/api'; import { Layout, CheckCircle2, @@ -45,6 +45,17 @@ import Modal from "@/components/Modal"; import MediaPlayer from "@/components/MediaPlayer"; export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) { + const defaultExerciseSettings: OrganizationExerciseSettings = { + organization_id: "", + audio_response_enabled: true, + hotspot_enabled: true, + memory_match_enabled: true, + peer_review_enabled: true, + role_playing_enabled: true, + mermaid_enabled: false, + code_lab_enabled: true, + }; + const [lesson, setLesson] = useState(null); const [loading, setLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -82,6 +93,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false); const [aiQuizContext, setAiQuizContext] = useState(""); const [aiQuizType, setAiQuizType] = useState("multiple-choice"); + const [exerciseSettings, setExerciseSettings] = useState(defaultExerciseSettings); const [editValue, setEditValue] = useState(""); @@ -92,8 +104,12 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI const loadData = async () => { try { // Use cmsApi for consistency - const lessonData = await cmsApi.getLesson(params.lessonId); + const [lessonData, orgExerciseSettings] = await Promise.all([ + cmsApi.getLesson(params.lessonId), + cmsApi.getOrganizationExerciseSettings(), + ]); setLesson(lessonData); + setExerciseSettings(orgExerciseSettings); setSummary(lessonData.summary || ""); setIsGraded(lessonData.is_graded || false); setSelectedCategoryId(lessonData.grading_category_id || ""); @@ -151,6 +167,12 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI loadData(); }, [params.id, params.lessonId]); + useEffect(() => { + if (!exerciseSettings.role_playing_enabled && aiQuizType === 'role-playing') { + setAiQuizType('multiple-choice'); + } + }, [exerciseSettings.role_playing_enabled, aiQuizType]); + const handleSaveLessonTitle = async () => { if (!lesson || !editValue) return; try { @@ -220,6 +242,20 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI }; const addBlock = (type: Block['type']) => { + const blockedBySettings = + (type === 'audio-response' && !exerciseSettings.audio_response_enabled) || + (type === 'hotspot' && !exerciseSettings.hotspot_enabled) || + (type === 'memory-match' && !exerciseSettings.memory_match_enabled) || + (type === 'peer-review' && !exerciseSettings.peer_review_enabled) || + (type === 'role-playing' && !exerciseSettings.role_playing_enabled) || + (type === 'mermaid' && !exerciseSettings.mermaid_enabled) || + (type === 'code-lab' && !exerciseSettings.code_lab_enabled); + + if (blockedBySettings) { + alert('Este tipo de ejercicio está desactivado para tu organización.'); + return; + } + const newBlock: Block = { id: generateUUID(), type, @@ -338,6 +374,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI setIsGeneratingQuiz(true); try { if (aiQuizType === 'role-playing') { + if (!exerciseSettings.role_playing_enabled) { + throw new Error('Role Playing está desactivado para esta organización.'); + } const data = await cmsApi.generateRolePlay(lesson.id, { prompt_hint: aiQuizContext }); @@ -1074,6 +1113,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI editMode={editMode} courseId={params.id} lessonId={params.lessonId} + aiGenerationEnabled={exerciseSettings.hotspot_enabled} onChange={(updates) => updateBlock(block.id, updates)} /> )} @@ -1105,6 +1145,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI editMode={editMode} lessonId={lesson.id} courseId={params.id} + aiGenerationEnabled={exerciseSettings.mermaid_enabled} onChange={(updates) => updateBlock(block.id, updates)} /> )} @@ -1112,6 +1153,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI updateBlock(block.id, updates)} /> )} @@ -1126,6 +1168,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI test_cases={block.test_cases} editMode={editMode} lessonId={params.lessonId} + aiGenerationEnabled={exerciseSettings.code_lab_enabled} onChange={(updates) => updateBlock(block.id, updates)} /> )} @@ -1167,12 +1210,13 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI { type: 'matching', icon: '🔗', label: 'Relations', color: 'violet' }, { type: 'ordering', icon: '🔢', label: 'Sequence', color: 'blue' }, { type: 'short-answer', icon: '💬', label: 'Open-Ended', color: 'indigo' }, - { type: 'hotspot', icon: '🎯', label: 'Hotspot', color: 'amber' }, - { 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: 'mermaid', icon: '📊', label: 'Mermaid Diagram', color: 'indigo' }, - { type: 'role-playing', icon: '🎭', label: 'Role-Playing AI', color: 'purple' }, + ...(exerciseSettings.hotspot_enabled ? [{ type: 'hotspot', icon: '🎯', label: 'Hotspot', color: 'amber' }] : []), + ...(exerciseSettings.audio_response_enabled ? [{ type: 'audio-response', icon: '🎤', label: 'Oral Practice', color: 'blue' }] : []), + ...(exerciseSettings.memory_match_enabled ? [{ type: 'memory-match', icon: '🧩', label: 'Logic Game', color: 'indigo' }] : []), + ...(exerciseSettings.peer_review_enabled ? [{ type: 'peer-review', icon: '👥', label: 'Peer Review', color: 'slate' }] : []), + ...(exerciseSettings.mermaid_enabled ? [{ type: 'mermaid', icon: '📊', label: 'Mermaid Diagram', color: 'indigo' }] : []), + ...(exerciseSettings.role_playing_enabled ? [{ type: 'role-playing', icon: '🎭', label: 'Role-Playing AI', color: 'purple' }] : []), + ...(exerciseSettings.code_lab_enabled ? [{ type: 'code-lab', icon: '💻', label: 'Code Lab', color: 'slate' }] : []), ].map((item) => ( + ); + })} + + +
+ +
+ + ); +} diff --git a/web/studio/src/components/blocks/CodeLabBlock.tsx b/web/studio/src/components/blocks/CodeLabBlock.tsx index bdde2f4..4c0eb40 100644 --- a/web/studio/src/components/blocks/CodeLabBlock.tsx +++ b/web/studio/src/components/blocks/CodeLabBlock.tsx @@ -14,6 +14,7 @@ interface CodeLabBlockProps { test_cases?: { description: string; expected: string }[]; editMode: boolean; lessonId: string; + aiGenerationEnabled?: boolean; onChange: (updates: { title?: string; language?: string; @@ -34,12 +35,17 @@ export default function CodeLabBlock({ test_cases = [], editMode, lessonId, + aiGenerationEnabled = true, onChange }: CodeLabBlockProps) { const [isGenerating, setIsGenerating] = useState(false); const [promptHint, setPromptHint] = useState(""); const handleGenerateAI = async () => { + if (!aiGenerationEnabled) { + alert("Code Lab está desactivado para esta organización."); + return; + } setIsGenerating(true); try { const data = await cmsApi.generateCodeLab(lessonId, { @@ -157,15 +163,21 @@ export default function CodeLabBlock({

Generación con IA

+ {!aiGenerationEnabled && ( +
+ Code Lab está desactivado para esta organización. Puedes seguir editando el bloque manualmente. +
+ )}