From 7f9b9d69ae2bcfad929e8b849bbc0ec13307ba77 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 6 Apr 2026 17:04:36 -0400 Subject: [PATCH] feat: Implement SAM structure mirroring in PostgreSQL for study plans and courses - Added functionality to save study plans and courses in SAM format to PostgreSQL. - Updated SQL queries to reflect SAM-native column names and handle conflicts appropriately. - Introduced new fields in the Asset model for English level and SAM identifiers. - Enhanced the TestTemplateForm component to manage linked assets and shared materials. - Created a new AdminSharedMaterialsPage for uploading ZIP files of shared materials. - Added migrations to create SAM mirror tables and update the assets table with new columns. --- nginx/studio.conf | 13 +- ...0260406203000_add_assets_english_level.sql | 5 + ...0260406212000_add_assets_sam_structure.sql | 6 + ...0260406214000_create_sam_mirror_tables.sql | 73 ++++++ services/cms-service/src/handlers_assets.rs | 147 +++++++++-- .../cms-service/src/handlers_question_bank.rs | 82 ++++-- shared/common/src/models.rs | 3 + web/studio/src/app/admin/layout.tsx | 4 +- web/studio/src/app/admin/materials/page.tsx | 231 +++++++++++++++++ .../TestTemplates/TestTemplateForm.tsx | 237 ++++++++++++++++-- .../TestTemplates/TestTemplateManager.tsx | 15 ++ web/studio/src/lib/api.ts | 38 ++- 12 files changed, 795 insertions(+), 59 deletions(-) create mode 100644 services/cms-service/migrations/20260406203000_add_assets_english_level.sql create mode 100644 services/cms-service/migrations/20260406212000_add_assets_sam_structure.sql create mode 100644 services/cms-service/migrations/20260406214000_create_sam_mirror_tables.sql create mode 100644 web/studio/src/app/admin/materials/page.tsx diff --git a/nginx/studio.conf b/nginx/studio.conf index 949ef14..7281286 100644 --- a/nginx/studio.conf +++ b/nginx/studio.conf @@ -1,22 +1,27 @@ # Custom nginx configuration for OpenCCB Studio # This overrides the default location block to route API requests correctly +# Allow large ZIP uploads (RAG bulk import can exceed 2GB). +client_max_body_size 4096m; +client_body_timeout 1800s; + # API routes that need to go to port 3001 # Prefer the explicit `/cms-api/*` prefix for frontend fetches. This avoids collisions # with Next.js pages like `/courses` and `/admin` that share the same host. location /cms-api/ { rewrite ^/cms-api/(.*)$ /$1 break; proxy_pass http://openccb-studio:3001; + proxy_request_buffering off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header Connection ""; proxy_http_version 1.1; - proxy_connect_timeout 60s; - proxy_send_timeout 600s; - proxy_read_timeout 600s; - send_timeout 600s; + proxy_connect_timeout 120s; + proxy_send_timeout 3600s; + proxy_read_timeout 3600s; + send_timeout 3600s; } location /lms-api/ { diff --git a/services/cms-service/migrations/20260406203000_add_assets_english_level.sql b/services/cms-service/migrations/20260406203000_add_assets_english_level.sql new file mode 100644 index 0000000..f3f9ee6 --- /dev/null +++ b/services/cms-service/migrations/20260406203000_add_assets_english_level.sql @@ -0,0 +1,5 @@ +ALTER TABLE assets +ADD COLUMN IF NOT EXISTS english_level TEXT; + +CREATE INDEX IF NOT EXISTS idx_assets_org_english_level +ON assets (organization_id, english_level); diff --git a/services/cms-service/migrations/20260406212000_add_assets_sam_structure.sql b/services/cms-service/migrations/20260406212000_add_assets_sam_structure.sql new file mode 100644 index 0000000..22648f8 --- /dev/null +++ b/services/cms-service/migrations/20260406212000_add_assets_sam_structure.sql @@ -0,0 +1,6 @@ +ALTER TABLE assets +ADD COLUMN IF NOT EXISTS sam_plan_id INTEGER, +ADD COLUMN IF NOT EXISTS sam_course_id INTEGER; + +CREATE INDEX IF NOT EXISTS idx_assets_org_sam_plan_course +ON assets (organization_id, sam_plan_id, sam_course_id); \ No newline at end of file diff --git a/services/cms-service/migrations/20260406214000_create_sam_mirror_tables.sql b/services/cms-service/migrations/20260406214000_create_sam_mirror_tables.sql new file mode 100644 index 0000000..ca1656c --- /dev/null +++ b/services/cms-service/migrations/20260406214000_create_sam_mirror_tables.sql @@ -0,0 +1,73 @@ +CREATE TABLE IF NOT EXISTS sam_study_plans ( + id BIGSERIAL PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + idPlanDeEstudios INTEGER NOT NULL, + Nombre TEXT NOT NULL, + Activo BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, idPlanDeEstudios) +); + +CREATE INDEX IF NOT EXISTS idx_sam_study_plans_org_activo +ON sam_study_plans (organization_id, Activo); + +CREATE TABLE IF NOT EXISTS sam_courses ( + id BIGSERIAL PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + idCursos INTEGER NOT NULL, + idPlanDeEstudios INTEGER NOT NULL, + NombreCurso TEXT NOT NULL, + NivelCurso INTEGER, + Duracion DOUBLE PRECISION, + Activo BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, idCursos), + FOREIGN KEY (organization_id, idPlanDeEstudios) + REFERENCES sam_study_plans (organization_id, idPlanDeEstudios) + ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_sam_courses_org_plan +ON sam_courses (organization_id, idPlanDeEstudios, Activo); + +-- Backfill from existing metadata tables when available. +INSERT INTO sam_study_plans (organization_id, idPlanDeEstudios, Nombre, Activo) +SELECT + organization_id, + mysql_id, + name, + COALESCE(is_active, TRUE) +FROM mysql_study_plans +ON CONFLICT (organization_id, idPlanDeEstudios) DO UPDATE SET + Nombre = EXCLUDED.Nombre, + Activo = EXCLUDED.Activo, + updated_at = NOW(); + +INSERT INTO sam_courses ( + organization_id, + idCursos, + idPlanDeEstudios, + NombreCurso, + NivelCurso, + Duracion, + Activo +) +SELECT + mc.organization_id, + mc.mysql_id, + msp.mysql_id, + mc.name, + mc.level, + mc.duracion, + COALESCE(mc.is_active, TRUE) +FROM mysql_courses mc +JOIN mysql_study_plans msp ON msp.id = mc.study_plan_id +ON CONFLICT (organization_id, idCursos) DO UPDATE SET + idPlanDeEstudios = EXCLUDED.idPlanDeEstudios, + NombreCurso = EXCLUDED.NombreCurso, + NivelCurso = EXCLUDED.NivelCurso, + Duracion = EXCLUDED.Duracion, + Activo = EXCLUDED.Activo, + updated_at = NOW(); \ No newline at end of file diff --git a/services/cms-service/src/handlers_assets.rs b/services/cms-service/src/handlers_assets.rs index 10ff733..916bcec 100644 --- a/services/cms-service/src/handlers_assets.rs +++ b/services/cms-service/src/handlers_assets.rs @@ -19,6 +19,7 @@ use uuid::Uuid; use std::env; use std::path::Path as StdPath; use tokio::process::Command; +use tokio::io::AsyncWriteExt; #[derive(Debug, Serialize)] pub struct AssetUploadResponse { @@ -49,6 +50,9 @@ pub struct AssetZipImportResponse { pub struct AssetFilters { pub mimetype: Option, pub course_id: Option, + pub english_level: Option, + pub sam_plan_id: Option, + pub sam_course_id: Option, pub search: Option, pub page: Option, pub limit: Option, @@ -239,6 +243,9 @@ pub async fn upload_asset( let mut data = Vec::new(); let mut mimetype = String::new(); let mut course_id: Option = None; + let mut english_level: Option = None; + let mut sam_plan_id: Option = None; + let mut sam_course_id: Option = None; while let Some(field) = multipart .next_field() @@ -263,6 +270,25 @@ pub async fn upload_asset( course_id = Some(id); } } + } else if name == "english_level" { + if let Ok(txt) = field.text().await { + let value = txt.trim(); + if !value.is_empty() { + english_level = Some(value.to_string()); + } + } + } else if name == "sam_plan_id" { + if let Ok(txt) = field.text().await { + if let Ok(id) = txt.trim().parse::() { + sam_plan_id = Some(id); + } + } + } else if name == "sam_course_id" { + if let Ok(txt) = field.text().await { + if let Ok(id) = txt.trim().parse::() { + sam_course_id = Some(id); + } + } } } @@ -339,14 +365,17 @@ pub async fn upload_asset( // Record in DB sqlx::query( r#" - INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, filename, storage_path, mimetype, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) "#, ) .bind(asset_id) .bind(org_ctx.id) .bind(claims.sub) .bind(course_id) + .bind(&english_level) + .bind(sam_plan_id) + .bind(sam_course_id) .bind(&stored_filename) .bind(&db_storage_path) .bind(&stored_mimetype) @@ -386,6 +415,21 @@ pub async fn list_assets( param_index += 1; } + if filters.english_level.is_some() { + query.push_str(&format!(" AND english_level = ${}", param_index)); + param_index += 1; + } + + if filters.sam_plan_id.is_some() { + query.push_str(&format!(" AND sam_plan_id = ${}", param_index)); + param_index += 1; + } + + if filters.sam_course_id.is_some() { + query.push_str(&format!(" AND sam_course_id = ${}", param_index)); + param_index += 1; + } + if filters.search.is_some() { query.push_str(&format!(" AND filename ILIKE ${}", param_index)); param_index += 1; @@ -403,6 +447,18 @@ pub async fn list_assets( sql_query = sql_query.bind(cid); } + if let Some(level) = &filters.english_level { + sql_query = sql_query.bind(level); + } + + if let Some(plan_id) = filters.sam_plan_id { + sql_query = sql_query.bind(plan_id); + } + + if let Some(course_id) = filters.sam_course_id { + sql_query = sql_query.bind(course_id); + } + if let Some(search) = &filters.search { sql_query = sql_query.bind(format!("%{}%", search)); } @@ -551,11 +607,14 @@ pub async fn import_assets_zip( State(pool): State, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { - let mut zip_data = Vec::new(); + let mut zip_temp_path: Option = None; let mut course_id: Option = None; + let mut english_level: Option = None; + let mut sam_plan_id: Option = None; + let mut sam_course_id: Option = None; let mut ingest_rag = false; - while let Some(field) = multipart + while let Some(mut field) = multipart .next_field() .await .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? @@ -563,11 +622,32 @@ pub async fn import_assets_zip( let name = field.name().unwrap_or_default().to_string(); if name == "file" { - zip_data = field - .bytes() + let temp_name = format!("uploads/tmp/import-{}.zip", Uuid::new_v4()); + tokio::fs::create_dir_all("uploads/tmp") .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .to_vec(); + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create temp dir: {}", e)))?; + + let mut temp_file = tokio::fs::File::create(&temp_name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create temp zip file: {}", e)))?; + + while let Some(chunk) = field + .chunk() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read upload chunk: {}", e)))? + { + temp_file + .write_all(&chunk) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write temp zip file: {}", e)))?; + } + + temp_file + .flush() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to flush temp zip file: {}", e)))?; + + zip_temp_path = Some(temp_name); } else if name == "course_id" { if let Ok(txt) = field.text().await { if let Ok(id) = Uuid::parse_str(txt.trim()) { @@ -579,17 +659,46 @@ pub async fn import_assets_zip( let v = txt.trim().to_lowercase(); ingest_rag = v == "1" || v == "true" || v == "yes"; } + } else if name == "english_level" { + if let Ok(txt) = field.text().await { + let value = txt.trim(); + if !value.is_empty() { + english_level = Some(value.to_string()); + } + } + } else if name == "sam_plan_id" { + if let Ok(txt) = field.text().await { + if let Ok(id) = txt.trim().parse::() { + sam_plan_id = Some(id); + } + } + } else if name == "sam_course_id" { + if let Ok(txt) = field.text().await { + if let Ok(id) = txt.trim().parse::() { + sam_course_id = Some(id); + } + } } } - if zip_data.is_empty() { + let zip_path = match zip_temp_path { + Some(path) => path, + None => { + return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string())); + } + }; + + let zip_file = std::fs::File::open(&zip_path) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to open temp zip file: {}", e)))?; + + let mut archive = zip::ZipArchive::new(zip_file) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid ZIP file".to_string()))?; + + if archive.is_empty() { + let _ = tokio::fs::remove_file(&zip_path).await; return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string())); } - let reader = std::io::Cursor::new(zip_data); - let mut archive = zip::ZipArchive::new(reader) - .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid ZIP file".to_string()))?; - let mut imported_assets = 0usize; let mut rag_ingested_assets = 0usize; let mut rag_chunks_ingested = 0usize; @@ -725,14 +834,17 @@ pub async fn import_assets_zip( let insert_result = sqlx::query( r#" - INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, filename, storage_path, mimetype, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) "#, ) .bind(asset_id) .bind(org_ctx.id) .bind(claims.sub) .bind(course_id) + .bind(&english_level) + .bind(sam_plan_id) + .bind(sam_course_id) .bind(&stored_filename) .bind(&db_storage_path) .bind(&mimetype) @@ -753,6 +865,9 @@ pub async fn import_assets_zip( organization_id: org_ctx.id, uploaded_by: Some(claims.sub), course_id, + english_level: english_level.clone(), + sam_plan_id, + sam_course_id, filename: stored_filename.clone(), storage_path: db_storage_path.clone(), mimetype: mimetype.clone(), @@ -820,6 +935,8 @@ pub async fn import_assets_zip( } } + let _ = tokio::fs::remove_file(&zip_path).await; + Ok(Json(AssetZipImportResponse { imported_assets, rag_ingested_assets, diff --git a/services/cms-service/src/handlers_question_bank.rs b/services/cms-service/src/handlers_question_bank.rs index 9a298c3..305db7f 100644 --- a/services/cms-service/src/handlers_question_bank.rs +++ b/services/cms-service/src/handlers_question_bank.rs @@ -103,6 +103,24 @@ pub async fn save_mysql_courses_and_plans( let course_type = calculate_course_type(&plan.nombre_plan); tracing::debug!("Saving study plan: {} (ID: {})", plan.nombre_plan, plan.id_plan_de_estudios); + // Mirror SAM structure in PostgreSQL using SAM-native column names. + sqlx::query( + r#" + INSERT INTO sam_study_plans (organization_id, idPlanDeEstudios, Nombre, Activo) + VALUES ($1, $2, $3, TRUE) + ON CONFLICT (organization_id, idPlanDeEstudios) DO UPDATE SET + Nombre = EXCLUDED.Nombre, + Activo = EXCLUDED.Activo, + updated_at = NOW() + "# + ) + .bind(org_id) + .bind(plan.id_plan_de_estudios) + .bind(&plan.nombre_plan) + .execute(pool) + .await + .map_err(|e| format!("Failed to save SAM study plan mirror: {}", e))?; + sqlx::query( r#" INSERT INTO mysql_study_plans (mysql_id, organization_id, name, course_type) @@ -129,6 +147,31 @@ pub async fn save_mysql_courses_and_plans( let level_calculated = calculate_course_level(course.nivel_curso); tracing::debug!("Saving course: {} (ID: {}, Plan ID: {})", course.nombre_curso, course.id_cursos, course.id_plan_de_estudios); + sqlx::query( + r#" + INSERT INTO sam_courses ( + organization_id, idCursos, idPlanDeEstudios, NombreCurso, NivelCurso, Duracion, Activo + ) + VALUES ($1, $2, $3, $4, $5, $6, TRUE) + ON CONFLICT (organization_id, idCursos) DO UPDATE SET + idPlanDeEstudios = EXCLUDED.idPlanDeEstudios, + NombreCurso = EXCLUDED.NombreCurso, + NivelCurso = EXCLUDED.NivelCurso, + Duracion = EXCLUDED.Duracion, + Activo = EXCLUDED.Activo, + updated_at = NOW() + "#, + ) + .bind(org_id) + .bind(course.id_cursos) + .bind(course.id_plan_de_estudios) + .bind(&course.nombre_curso) + .bind(course.nivel_curso) + .bind(course.duracion) + .execute(pool) + .await + .map_err(|e| format!("Failed to save SAM course mirror: {}", e))?; + // Get study_plan_id from mysql_study_plans let study_plan_id: i32 = sqlx::query_scalar( "SELECT id FROM mysql_study_plans WHERE mysql_id = $1 AND organization_id = $2" @@ -820,15 +863,15 @@ pub async fn get_mysql_plans( Org(org_ctx): Org, State(pool): State, ) -> Result>, (StatusCode, String)> { - // Fetch all study plans from PostgreSQL + // Read from SAM mirror in PostgreSQL with SAM-native fields. let plans: Vec = sqlx::query_as( r#" SELECT - mysql_id as id_plan_de_estudios, - name as nombre_plan - FROM mysql_study_plans - WHERE organization_id = $1 AND is_active = true - ORDER BY name + idPlanDeEstudios AS id_plan_de_estudios, + Nombre AS nombre_plan + FROM sam_study_plans + WHERE organization_id = $1 AND Activo = TRUE + ORDER BY Nombre "# ) .bind(org_ctx.id) @@ -845,22 +888,25 @@ pub async fn get_mysql_courses_by_plan( State(pool): State, Query(filters): Query, ) -> Result>, (StatusCode, String)> { - // Fetch courses filtered by plan from PostgreSQL + // Read from SAM mirror in PostgreSQL with SAM-native fields. let courses: Vec = sqlx::query_as( r#" SELECT - c.mysql_id as id_cursos, - c.name as nombre_curso, - c.level as nivel_curso, - sp.mysql_id as id_plan_de_estudios, - sp.name as nombre_plan, - c.duracion::double precision as duracion - FROM mysql_courses c - JOIN mysql_study_plans sp ON c.study_plan_id = sp.id + c.idCursos AS id_cursos, + c.NombreCurso AS nombre_curso, + c.NivelCurso AS nivel_curso, + c.idPlanDeEstudios AS id_plan_de_estudios, + p.Nombre AS nombre_plan, + c.Duracion AS duracion + FROM sam_courses c + JOIN sam_study_plans p + ON p.organization_id = c.organization_id + AND p.idPlanDeEstudios = c.idPlanDeEstudios WHERE c.organization_id = $1 - AND c.is_active = true - AND sp.mysql_id = $2 - ORDER BY c.level + AND c.Activo = TRUE + AND p.Activo = TRUE + AND c.idPlanDeEstudios = $2 + ORDER BY c.NivelCurso "# ) .bind(org_ctx.id) diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 204b27f..b84dc26 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -344,6 +344,9 @@ pub struct Asset { pub organization_id: Uuid, pub uploaded_by: Option, pub course_id: Option, + pub english_level: Option, + pub sam_plan_id: Option, + pub sam_course_id: Option, pub filename: String, pub storage_path: String, pub mimetype: String, diff --git a/web/studio/src/app/admin/layout.tsx b/web/studio/src/app/admin/layout.tsx index 419ba59..e42ceb4 100644 --- a/web/studio/src/app/admin/layout.tsx +++ b/web/studio/src/app/admin/layout.tsx @@ -12,7 +12,8 @@ import { ArrowLeft, Activity, TrendingUp, - Mic + Mic, + FileArchive } from "lucide-react"; export default function AdminLayout({ children }: { children: React.ReactNode }) { @@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { icon: LayoutDashboard, label: "Dashboard", href: "/admin" }, { icon: Building2, label: "Organizations", href: "/admin" }, { icon: Users, label: "Users", href: "/admin/users" }, + { 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" }, { icon: Activity, label: "System Tasks", href: "/admin/tasks" }, diff --git a/web/studio/src/app/admin/materials/page.tsx b/web/studio/src/app/admin/materials/page.tsx new file mode 100644 index 0000000..e5271a1 --- /dev/null +++ b/web/studio/src/app/admin/materials/page.tsx @@ -0,0 +1,231 @@ +'use client'; + +import React, { useMemo, useState } from 'react'; +import { cmsApi, questionBankApi, MySqlPlan, MySqlCourse } from '@/lib/api'; +import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle } from 'lucide-react'; + +export default function AdminSharedMaterialsPage() { + const [zipFile, setZipFile] = useState(null); + const [ingestRag, setIngestRag] = useState(true); + const [englishLevel, setEnglishLevel] = useState(''); + const [plans, setPlans] = useState([]); + const [courses, setCourses] = useState([]); + const [selectedPlanId, setSelectedPlanId] = useState(''); + const [selectedCourseId, setSelectedCourseId] = useState(''); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState<{ + imported_assets: number; + rag_ingested_assets: number; + rag_chunks_ingested: number; + failed_entries: string[]; + } | null>(null); + + const canUpload = useMemo(() => Boolean(zipFile) && !loading, [zipFile, loading]); + + React.useEffect(() => { + questionBankApi.getMySQLPlans().then(setPlans).catch(() => setPlans([])); + }, []); + + React.useEffect(() => { + if (!selectedPlanId) { + setCourses([]); + setSelectedCourseId(''); + return; + } + questionBankApi.getMySQLCoursesByPlan(selectedPlanId).then(setCourses).catch(() => setCourses([])); + }, [selectedPlanId]); + + const handleUpload = async () => { + if (!zipFile) { + alert('Selecciona un archivo ZIP primero.'); + return; + } + + try { + setLoading(true); + setResult(null); + const response = await cmsApi.importAssetsZip( + zipFile, + ingestRag, + undefined, + englishLevel || undefined, + selectedPlanId || undefined, + selectedCourseId || undefined, + ); + setResult(response); + alert('Importacion ZIP finalizada.'); + } catch (error) { + console.error('ZIP import failed:', error); + const msg = error instanceof Error ? error.message : 'Error al importar ZIP'; + alert(msg); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Material Compartido y RAG

+

+ Sube ZIPs desde Admin para dejar contenido global disponible en todas las plantillas y cursos. +

+
+ +
+
+
+ +
+
+

Importar ZIP de Materiales

+

Se cargan a biblioteca compartida (sin curso especifico).

+
+
+ +
+ + setZipFile(e.target.files?.[0] || null)} + className="block w-full text-sm text-slate-700 dark:text-gray-200 file:mr-4 file:rounded-lg file:border-0 file:bg-indigo-600 file:px-4 file:py-2 file:font-semibold file:text-white hover:file:bg-indigo-500" + /> + {zipFile && ( +

+ Seleccionado: {zipFile.name} +

+ )} +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + {result && ( +
+

Resultado de la Importacion

+
+
+
+ + Assets importados +
+

{result.imported_assets}

+
+
+
+ + Assets ingeridos RAG +
+

{result.rag_ingested_assets}

+
+
+
+ + Chunks RAG +
+

{result.rag_chunks_ingested}

+
+
+ + {result.failed_entries.length > 0 && ( +
+
+ + Entradas con error +
+
    + {result.failed_entries.map((entry, idx) => ( +
  • - {entry}
  • + ))} +
+
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/web/studio/src/components/TestTemplates/TestTemplateForm.tsx b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx index c96235a..40c944c 100644 --- a/web/studio/src/components/TestTemplates/TestTemplateForm.tsx +++ b/web/studio/src/components/TestTemplates/TestTemplateForm.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { cmsApi, questionBankApi, CreateTestTemplatePayload, CourseLevel, CourseType, TestType, QuestionType, MySqlPlan, MySqlCourse } from '@/lib/api'; +import { cmsApi, questionBankApi, CreateTestTemplatePayload, CourseLevel, CourseType, TestType, QuestionType, MySqlPlan, MySqlCourse, Asset } from '@/lib/api'; import { X, Save, Plus, Trash2, Sparkles, ChevronDown, ChevronUp, Copy, GripVertical, Edit2 } from 'lucide-react'; interface Section { @@ -32,6 +32,39 @@ interface TestTemplateFormProps { onCancel?: () => void; } +interface TemplateLinkedAsset { + id: string; + filename?: string; + mimetype?: string; +} + +const readLinkedAssetsFromTemplateData = (templateData: unknown): TemplateLinkedAsset[] => { + if (!templateData || typeof templateData !== 'object') return []; + + const data = templateData as Record; + const raw = data.selected_assets; + if (!Array.isArray(raw)) return []; + + return raw + .map((item) => { + if (typeof item === 'string') { + return { id: item }; + } + if (item && typeof item === 'object') { + const obj = item as Record; + if (typeof obj.id === 'string') { + return { + id: obj.id, + filename: typeof obj.filename === 'string' ? obj.filename : undefined, + mimetype: typeof obj.mimetype === 'string' ? obj.mimetype : undefined, + }; + } + } + return null; + }) + .filter((asset): asset is TemplateLinkedAsset => Boolean(asset)); +}; + export default function TestTemplateForm({ templateId, onSuccess, onCancel }: TestTemplateFormProps) { const [formData, setFormData] = useState({ name: '', @@ -63,6 +96,15 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te const [selectedCourseId, setSelectedCourseId] = useState(''); const [loadingPlans, setLoadingPlans] = useState(false); const [loadingCourses, setLoadingCourses] = useState(false); + const [sharedAssets, setSharedAssets] = useState([]); + const [selectedLinkedAssets, setSelectedLinkedAssets] = useState([]); + const [loadingSharedAssets, setLoadingSharedAssets] = useState(false); + const [assetSearch, setAssetSearch] = useState(''); + const [selectedAssetLevel, setSelectedAssetLevel] = useState(''); + const [selectedAssetPlanId, setSelectedAssetPlanId] = useState(''); + const [assetPlans, setAssetPlans] = useState([]); + const [assetCourses, setAssetCourses] = useState([]); + const [selectedAssetCourseId, setSelectedAssetCourseId] = useState(''); const isEditing = Boolean(templateId); // Load MySQL plans on mount @@ -81,6 +123,39 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te loadPlans(); }, []); + useEffect(() => { + questionBankApi.getMySQLPlans().then(setAssetPlans).catch(() => setAssetPlans([])); + }, []); + + useEffect(() => { + if (!selectedAssetPlanId) { + setAssetCourses([]); + setSelectedAssetCourseId(''); + return; + } + questionBankApi.getMySQLCoursesByPlan(selectedAssetPlanId).then(setAssetCourses).catch(() => setAssetCourses([])); + }, [selectedAssetPlanId]); + + useEffect(() => { + const loadSharedAssets = async () => { + try { + setLoadingSharedAssets(true); + const assets = await cmsApi.getAssets({ + english_level: selectedAssetLevel || undefined, + sam_plan_id: selectedAssetPlanId || undefined, + sam_course_id: selectedAssetCourseId || undefined, + }); + setSharedAssets(assets.filter((asset) => !asset.course_id)); + } catch (error) { + console.error('Failed to load shared assets:', error); + } finally { + setLoadingSharedAssets(false); + } + }; + + loadSharedAssets(); + }, [selectedAssetLevel, selectedAssetPlanId, selectedAssetCourseId]); + // Load courses when plan is selected useEffect(() => { const loadCourses = async () => { @@ -125,6 +200,10 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te tags: data.template.tags || [], }); + if (data.template.level) { + setSelectedAssetLevel(data.template.level); + } + setSections( (data.sections || []).map((section) => ({ id: section.id, @@ -170,6 +249,8 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te if (data.template.mysql_course_id) { setSelectedCourseId(data.template.mysql_course_id); } + + setSelectedLinkedAssets(readLinkedAssetsFromTemplateData(data.template.template_data)); } catch (error) { console.error('Failed to load template for edit:', error); alert('No se pudo cargar la plantilla para editar'); @@ -214,23 +295,32 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te try { setSaving(true); + const templateDataObject: Record = + formData.template_data && typeof formData.template_data === 'object' + ? { ...(formData.template_data as Record) } + : {}; + + templateDataObject.selected_assets = selectedLinkedAssets; + + const payloadBase = { + name: formData.name, + description: formData.description, + mysql_course_id: formData.mysql_course_id, + level: formData.level, + course_type: formData.course_type, + test_type: formData.test_type, + duration_minutes: formData.duration_minutes, + passing_score: formData.passing_score, + total_points: formData.total_points, + instructions: formData.instructions, + template_data: templateDataObject, + tags: formData.tags, + }; + let targetTemplateId = templateId; if (isEditing && templateId) { - await cmsApi.updateTestTemplate(templateId, { - name: formData.name, - description: formData.description, - mysql_course_id: formData.mysql_course_id, - level: formData.level, - course_type: formData.course_type, - test_type: formData.test_type, - duration_minutes: formData.duration_minutes, - passing_score: formData.passing_score, - total_points: formData.total_points, - instructions: formData.instructions, - template_data: formData.template_data, - tags: formData.tags, - }); + await cmsApi.updateTestTemplate(templateId, payloadBase); const existingData = await cmsApi.getTestTemplate(templateId); @@ -242,7 +332,7 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te await cmsApi.deleteTemplateSection(templateId, existingSection.id); } } else { - const template = await cmsApi.createTestTemplate(formData); + const template = await cmsApi.createTestTemplate(payloadBase); targetTemplateId = template.id; } @@ -427,6 +517,28 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te } }; + const handleToggleLinkedAsset = (asset: Asset) => { + setSelectedLinkedAssets((prev) => { + const exists = prev.some((selected) => selected.id === asset.id); + if (exists) { + return prev.filter((selected) => selected.id !== asset.id); + } + return [ + ...prev, + { id: asset.id, filename: asset.filename, mimetype: asset.mimetype }, + ]; + }); + }; + + const filteredSharedAssets = sharedAssets.filter((asset) => { + if (!assetSearch.trim()) return true; + const q = assetSearch.toLowerCase(); + return ( + asset.filename.toLowerCase().includes(q) || + asset.mimetype.toLowerCase().includes(q) + ); + }); + const getQuestionTypeLabel = (type: QuestionType) => { const labels: Record = { 'multiple-choice': 'Opción Múltiple', @@ -576,6 +688,99 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te +
+

+ Material Compartido para esta Plantilla +

+

+ Este material queda vinculado a la plantilla y disponible para instructores al reutilizarla. +

+ + setAssetSearch(e.target.value)} + className="w-full px-3 py-2 border border-emerald-300 rounded-lg focus:ring-2 focus:ring-emerald-500 bg-white mb-3" + placeholder="Buscar material por nombre o tipo..." + /> + + + + + + + +
+ {loadingSharedAssets && ( +

Cargando materiales...

+ )} + {!loadingSharedAssets && filteredSharedAssets.length === 0 && ( +

No hay materiales compartidos disponibles.

+ )} + {!loadingSharedAssets && filteredSharedAssets.map((asset) => { + const checked = selectedLinkedAssets.some((a) => a.id === asset.id); + return ( + + ); + })} +
+ + {selectedLinkedAssets.length > 0 && ( +

+ {selectedLinkedAssets.length} material(es) vinculados a la plantilla. +

+ )} +
+
+
+ Materiales vinculados: + {getLinkedMaterialsCount(selectedTemplate)} +
diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 5980f0b..7ef8528 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -13,12 +13,13 @@ const getApiBaseUrl = (defaultPort: string, envVar?: string) => { const hostname = window.location.hostname; const protocol = window.location.protocol; - // Producción - dominios específicos (fallback sin prefijo) + // Producción - usar prefijo explícito para evitar colisiones con rutas de Next.js. + // Ejemplo: /question-bank (página) vs /question-bank/mysql-plans (API). if (hostname === STUDIO_DOMAIN) { - return `${protocol}//${STUDIO_DOMAIN}`; + return `${protocol}//${STUDIO_DOMAIN}/cms-api`; } if (hostname === LEARNING_DOMAIN) { - return `${protocol}//${LEARNING_DOMAIN}`; + return `${protocol}//${LEARNING_DOMAIN}/cms-api`; } // Desarrollo local @@ -599,6 +600,7 @@ export interface Asset { organization_id: string; uploaded_by: string | null; course_id: string | null; + english_level?: string | null; filename: string; storage_path: string; mimetype: string; @@ -609,6 +611,9 @@ export interface Asset { export interface AssetFilters { mimetype?: string; course_id?: string; + english_level?: string; + sam_plan_id?: number; + sam_course_id?: number; search?: string; page?: number; limit?: number; @@ -948,6 +953,9 @@ export const cmsApi = { if (filters) { if (filters.mimetype) params.append('mimetype', filters.mimetype); if (filters.course_id) params.append('course_id', filters.course_id); + if (filters.english_level) params.append('english_level', filters.english_level); + if (filters.sam_plan_id) params.append('sam_plan_id', String(filters.sam_plan_id)); + if (filters.sam_course_id) params.append('sam_course_id', String(filters.sam_course_id)); if (filters.search) params.append('search', filters.search); if (filters.page) params.append('page', filters.page.toString()); if (filters.limit) params.append('limit', filters.limit.toString()); @@ -959,12 +967,22 @@ export const cmsApi = { deleteAsset: (id: string): Promise => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }), ingestAssetForRag: (id: string): Promise => apiFetch(`/api/assets/${id}/ingest-rag`, { method: 'POST' }), - importAssetsZip: (file: File, ingestRag = false, courseId?: string): Promise => { + importAssetsZip: ( + file: File, + ingestRag = false, + courseId?: string, + englishLevel?: string, + samPlanId?: number, + samCourseId?: number, + ): Promise => { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file); formData.append('ingest_rag', ingestRag ? 'true' : 'false'); if (courseId) formData.append('course_id', courseId); + if (englishLevel) formData.append('english_level', englishLevel); + if (samPlanId) formData.append('sam_plan_id', String(samPlanId)); + if (samCourseId) formData.append('sam_course_id', String(samCourseId)); const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`); @@ -990,11 +1008,21 @@ export const cmsApi = { xhr.send(formData); }); }, - uploadAsset: (file: File, onProgress?: (pct: number) => void, courseId?: string): Promise => { + uploadAsset: ( + file: File, + onProgress?: (pct: number) => void, + courseId?: string, + englishLevel?: string, + samPlanId?: number, + samCourseId?: number, + ): Promise => { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file); if (courseId) formData.append('course_id', courseId); + if (englishLevel) formData.append('english_level', englishLevel); + if (samPlanId) formData.append('sam_plan_id', String(samPlanId)); + if (samCourseId) formData.append('sam_course_id', String(samCourseId)); const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_BASE_URL}/api/assets/upload`);