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
+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`);