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.
This commit is contained in:
2026-04-06 17:04:36 -04:00
parent eea456cd95
commit 7f9b9d69ae
12 changed files with 795 additions and 59 deletions
+9 -4
View File
@@ -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/ {
@@ -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);
@@ -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);
@@ -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();
+132 -15
View File
@@ -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<String>,
pub course_id: Option<Uuid>,
pub english_level: Option<String>,
pub sam_plan_id: Option<i32>,
pub sam_course_id: Option<i32>,
pub search: Option<String>,
pub page: Option<u32>,
pub limit: Option<u32>,
@@ -239,6 +243,9 @@ pub async fn upload_asset(
let mut data = Vec::new();
let mut mimetype = String::new();
let mut course_id: Option<Uuid> = None;
let mut english_level: Option<String> = None;
let mut sam_plan_id: Option<i32> = None;
let mut sam_course_id: Option<i32> = 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::<i32>() {
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::<i32>() {
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<PgPool>,
mut multipart: Multipart,
) -> Result<Json<AssetZipImportResponse>, (StatusCode, String)> {
let mut zip_data = Vec::new();
let mut zip_temp_path: Option<String> = None;
let mut course_id: Option<Uuid> = None;
let mut english_level: Option<String> = None;
let mut sam_plan_id: Option<i32> = None;
let mut sam_course_id: Option<i32> = 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::<i32>() {
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::<i32>() {
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,
@@ -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<PgPool>,
) -> Result<Json<Vec<MySqlPlanInfo>>, (StatusCode, String)> {
// Fetch all study plans from PostgreSQL
// Read from SAM mirror in PostgreSQL with SAM-native fields.
let plans: Vec<MySqlPlanInfo> = 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<PgPool>,
Query(filters): Query<MySqlCoursesFilters>,
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
// Fetch courses filtered by plan from PostgreSQL
// Read from SAM mirror in PostgreSQL with SAM-native fields.
let courses: Vec<MySqlCourseInfo> = 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)
+3
View File
@@ -344,6 +344,9 @@ pub struct Asset {
pub organization_id: Uuid,
pub uploaded_by: Option<Uuid>,
pub course_id: Option<Uuid>,
pub english_level: Option<String>,
pub sam_plan_id: Option<i32>,
pub sam_course_id: Option<i32>,
pub filename: String,
pub storage_path: String,
pub mimetype: String,
+3 -1
View File
@@ -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" },
+231
View File
@@ -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<File | null>(null);
const [ingestRag, setIngestRag] = useState(true);
const [englishLevel, setEnglishLevel] = useState('');
const [plans, setPlans] = useState<MySqlPlan[]>([]);
const [courses, setCourses] = useState<MySqlCourse[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<number | ''>('');
const [selectedCourseId, setSelectedCourseId] = useState<number | ''>('');
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 (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Material Compartido y RAG</h1>
<p className="text-slate-600 dark:text-gray-400 mt-2">
Sube ZIPs desde Admin para dejar contenido global disponible en todas las plantillas y cursos.
</p>
</div>
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/[0.02] p-6 space-y-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-600 dark:text-indigo-400">
<FileArchive className="w-5 h-5" />
</div>
<div>
<h2 className="font-bold text-slate-900 dark:text-white">Importar ZIP de Materiales</h2>
<p className="text-xs text-slate-500 dark:text-gray-500">Se cargan a biblioteca compartida (sin curso especifico).</p>
</div>
</div>
<div className="space-y-3">
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Archivo ZIP</label>
<input
type="file"
accept=".zip,application/zip"
onChange={(e) => 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 && (
<p className="text-xs text-slate-500 dark:text-gray-400">
Seleccionado: {zipFile.name}
</p>
)}
</div>
<label className="flex items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
<input
type="checkbox"
checked={ingestRag}
onChange={(e) => setIngestRag(e.target.checked)}
/>
<span className="font-medium">Ingerir automaticamente en RAG al importar</span>
</label>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Plan de Estudios (SAM)</label>
<select
value={selectedPlanId}
onChange={(e) => {
const value = e.target.value ? Number(e.target.value) : '';
setSelectedPlanId(value);
setSelectedCourseId('');
}}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
>
<option value="">Seleccionar plan</option>
{plans.map((p) => (
<option key={p.idPlanDeEstudios} value={p.idPlanDeEstudios}>{p.NombrePlan}</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Curso (SAM)</label>
<select
value={selectedCourseId}
onChange={(e) => {
const value = e.target.value ? Number(e.target.value) : '';
setSelectedCourseId(value);
const selected = courses.find((c) => c.idCursos === value);
if (selected?.NivelCurso !== undefined && selected?.NivelCurso !== null) {
const n = selected.NivelCurso;
if (n <= 2) setEnglishLevel('beginner_1');
else if (n <= 4) setEnglishLevel('beginner_2');
else if (n <= 6) setEnglishLevel('intermediate_1');
else if (n <= 8) setEnglishLevel('intermediate_2');
else if (n <= 10) setEnglishLevel('advanced_1');
else setEnglishLevel('advanced_2');
}
}}
disabled={!selectedPlanId}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm disabled:opacity-60"
>
<option value="">Seleccionar curso</option>
{courses.map((c) => (
<option key={c.idCursos} value={c.idCursos}>{c.NombreCurso}</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Nivel de Ingles para este ZIP</label>
<select
value={englishLevel}
onChange={(e) => setEnglishLevel(e.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
>
<option value="">Sin nivel (general)</option>
<option value="beginner">Beginner</option>
<option value="beginner_1">Beginner 1</option>
<option value="beginner_2">Beginner 2</option>
<option value="intermediate">Intermediate</option>
<option value="intermediate_1">Intermediate 1</option>
<option value="intermediate_2">Intermediate 2</option>
<option value="advanced">Advanced</option>
<option value="advanced_1">Advanced 1</option>
<option value="advanced_2">Advanced 2</option>
</select>
</div>
<button
type="button"
onClick={handleUpload}
disabled={!canUpload}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-500 disabled:opacity-50"
>
<Upload className="w-4 h-4" />
{loading ? 'Importando...' : 'Importar ZIP Compartido'}
</button>
</div>
{result && (
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/[0.02] p-6 space-y-4">
<h3 className="text-lg font-bold text-slate-900 dark:text-white">Resultado de la Importacion</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="rounded-lg border border-slate-200 dark:border-white/10 p-4">
<div className="flex items-center gap-2 text-slate-700 dark:text-gray-300">
<Database className="w-4 h-4" />
<span className="text-sm font-medium">Assets importados</span>
</div>
<p className="text-2xl font-black mt-2">{result.imported_assets}</p>
</div>
<div className="rounded-lg border border-slate-200 dark:border-white/10 p-4">
<div className="flex items-center gap-2 text-slate-700 dark:text-gray-300">
<CheckCircle2 className="w-4 h-4" />
<span className="text-sm font-medium">Assets ingeridos RAG</span>
</div>
<p className="text-2xl font-black mt-2">{result.rag_ingested_assets}</p>
</div>
<div className="rounded-lg border border-slate-200 dark:border-white/10 p-4">
<div className="flex items-center gap-2 text-slate-700 dark:text-gray-300">
<CheckCircle2 className="w-4 h-4" />
<span className="text-sm font-medium">Chunks RAG</span>
</div>
<p className="text-2xl font-black mt-2">{result.rag_chunks_ingested}</p>
</div>
</div>
{result.failed_entries.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="flex items-center gap-2 text-amber-800 mb-2">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-semibold">Entradas con error</span>
</div>
<ul className="text-xs text-amber-900 space-y-1 max-h-40 overflow-y-auto">
{result.failed_entries.map((entry, idx) => (
<li key={`${entry}-${idx}`}>- {entry}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}
@@ -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<string, unknown>;
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<string, unknown>;
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<CreateTestTemplatePayload>({
name: '',
@@ -63,6 +96,15 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
const [selectedCourseId, setSelectedCourseId] = useState<number | ''>('');
const [loadingPlans, setLoadingPlans] = useState(false);
const [loadingCourses, setLoadingCourses] = useState(false);
const [sharedAssets, setSharedAssets] = useState<Asset[]>([]);
const [selectedLinkedAssets, setSelectedLinkedAssets] = useState<TemplateLinkedAsset[]>([]);
const [loadingSharedAssets, setLoadingSharedAssets] = useState(false);
const [assetSearch, setAssetSearch] = useState('');
const [selectedAssetLevel, setSelectedAssetLevel] = useState<string>('');
const [selectedAssetPlanId, setSelectedAssetPlanId] = useState<number | ''>('');
const [assetPlans, setAssetPlans] = useState<MySqlPlan[]>([]);
const [assetCourses, setAssetCourses] = useState<MySqlCourse[]>([]);
const [selectedAssetCourseId, setSelectedAssetCourseId] = useState<number | ''>('');
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<string, unknown> =
formData.template_data && typeof formData.template_data === 'object'
? { ...(formData.template_data as Record<string, unknown>) }
: {};
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<QuestionType, string> = {
'multiple-choice': 'Opción Múltiple',
@@ -576,6 +688,99 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
</div>
</div>
<div className="bg-emerald-50 p-4 rounded-lg border border-emerald-200">
<h4 className="text-sm font-medium text-emerald-900 mb-2">
Material Compartido para esta Plantilla
</h4>
<p className="text-xs text-emerald-700 mb-3">
Este material queda vinculado a la plantilla y disponible para instructores al reutilizarla.
</p>
<input
type="text"
value={assetSearch}
onChange={(e) => 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..."
/>
<select
value={selectedAssetLevel}
onChange={(e) => setSelectedAssetLevel(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"
>
<option value="">Todos los niveles</option>
<option value="beginner">Beginner</option>
<option value="beginner_1">Beginner 1</option>
<option value="beginner_2">Beginner 2</option>
<option value="intermediate">Intermediate</option>
<option value="intermediate_1">Intermediate 1</option>
<option value="intermediate_2">Intermediate 2</option>
<option value="advanced">Advanced</option>
<option value="advanced_1">Advanced 1</option>
<option value="advanced_2">Advanced 2</option>
</select>
<select
value={selectedAssetPlanId}
onChange={(e) => {
const value = e.target.value ? Number(e.target.value) : '';
setSelectedAssetPlanId(value);
setSelectedAssetCourseId('');
}}
className="w-full px-3 py-2 border border-emerald-300 rounded-lg focus:ring-2 focus:ring-emerald-500 bg-white mb-3"
>
<option value="">Todos los planes SAM</option>
{assetPlans.map((p) => (
<option key={p.idPlanDeEstudios} value={p.idPlanDeEstudios}>{p.NombrePlan}</option>
))}
</select>
<select
value={selectedAssetCourseId}
onChange={(e) => setSelectedAssetCourseId(e.target.value ? Number(e.target.value) : '')}
disabled={!selectedAssetPlanId}
className="w-full px-3 py-2 border border-emerald-300 rounded-lg focus:ring-2 focus:ring-emerald-500 bg-white mb-3 disabled:opacity-60"
>
<option value="">Todos los cursos SAM</option>
{assetCourses.map((c) => (
<option key={c.idCursos} value={c.idCursos}>{c.NombreCurso}</option>
))}
</select>
<div className="max-h-52 overflow-y-auto bg-white border border-emerald-200 rounded-lg divide-y divide-emerald-100">
{loadingSharedAssets && (
<p className="text-xs text-emerald-700 p-3">Cargando materiales...</p>
)}
{!loadingSharedAssets && filteredSharedAssets.length === 0 && (
<p className="text-xs text-emerald-700 p-3">No hay materiales compartidos disponibles.</p>
)}
{!loadingSharedAssets && filteredSharedAssets.map((asset) => {
const checked = selectedLinkedAssets.some((a) => a.id === asset.id);
return (
<label key={asset.id} className="flex items-start gap-3 p-3 cursor-pointer hover:bg-emerald-50">
<input
type="checkbox"
checked={checked}
onChange={() => handleToggleLinkedAsset(asset)}
className="mt-0.5"
/>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{asset.filename}</p>
<p className="text-xs text-gray-600">{asset.mimetype}</p>
</div>
</label>
);
})}
</div>
{selectedLinkedAssets.length > 0 && (
<p className="text-xs text-emerald-800 mt-2">
{selectedLinkedAssets.length} material(es) vinculados a la plantilla.
</p>
)}
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
@@ -155,6 +155,13 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
return 'bg-gray-100 text-gray-800';
};
const getLinkedMaterialsCount = (template: TestTemplate): number => {
const data = template.template_data;
if (!data || typeof data !== 'object') return 0;
const selected = (data as Record<string, unknown>).selected_assets;
return Array.isArray(selected) ? selected.length : 0;
};
return (
<div className="p-6">
<div className="mb-6 flex items-center justify-between">
@@ -326,6 +333,10 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
<Tag className="w-4 h-4" />
<span>Puntos totales: {template.total_points}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<BookOpen className="w-4 h-4" />
<span>Materiales vinculados: {getLinkedMaterialsCount(template)}</span>
</div>
</div>
{template.tags && template.tags.length > 0 && (
@@ -392,6 +403,10 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
<span className="text-blue-700">Aprobación:</span>
<span className="ml-2 font-medium">{selectedTemplate.passing_score}%</span>
</div>
<div className="col-span-2">
<span className="text-blue-700">Materiales vinculados:</span>
<span className="ml-2 font-medium">{getLinkedMaterialsCount(selectedTemplate)}</span>
</div>
</div>
</div>
+33 -5
View File
@@ -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<void> => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }),
ingestAssetForRag: (id: string): Promise<AssetRagIngestResult> =>
apiFetch(`/api/assets/${id}/ingest-rag`, { method: 'POST' }),
importAssetsZip: (file: File, ingestRag = false, courseId?: string): Promise<AssetZipImportResult> => {
importAssetsZip: (
file: File,
ingestRag = false,
courseId?: string,
englishLevel?: string,
samPlanId?: number,
samCourseId?: number,
): Promise<AssetZipImportResult> => {
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<UploadResponse> => {
uploadAsset: (
file: File,
onProgress?: (pct: number) => void,
courseId?: string,
englishLevel?: string,
samPlanId?: number,
samCourseId?: number,
): Promise<UploadResponse> => {
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`);