diff --git a/services/cms-service/migrations/20260421000001_add_assets_zip_batch_metadata.sql b/services/cms-service/migrations/20260421000001_add_assets_zip_batch_metadata.sql new file mode 100644 index 0000000..cb40001 --- /dev/null +++ b/services/cms-service/migrations/20260421000001_add_assets_zip_batch_metadata.sql @@ -0,0 +1,11 @@ +ALTER TABLE assets +ADD COLUMN IF NOT EXISTS zip_batch_id UUID, +ADD COLUMN IF NOT EXISTS source_zip_name TEXT; + +CREATE INDEX IF NOT EXISTS idx_assets_zip_batch_id +ON assets (organization_id, zip_batch_id) +WHERE zip_batch_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_assets_source_zip_name +ON assets (organization_id, source_zip_name) +WHERE source_zip_name IS NOT NULL; diff --git a/services/cms-service/src/handlers_assets.rs b/services/cms-service/src/handlers_assets.rs index 3cce5a8..502cea2 100644 --- a/services/cms-service/src/handlers_assets.rs +++ b/services/cms-service/src/handlers_assets.rs @@ -72,6 +72,17 @@ pub struct AssetZipImportResponse { pub rag_background_items: usize, } +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AssetImportHistoryItem { + pub zip_batch_id: Uuid, + pub source_zip_name: String, + pub english_level: Option, + pub sam_plan_id: Option, + pub sam_course_id: Option, + pub asset_count: i64, + pub created_at: chrono::DateTime, +} + #[derive(Debug, Deserialize)] pub struct AssetFilters { pub mimetype: Option, @@ -608,6 +619,37 @@ pub async fn list_assets( Ok(Json(assets)) } +pub async fn list_asset_import_history( + Org(org_ctx): Org, + State(pool): State, +) -> Result>, (StatusCode, String)> { + let items = sqlx::query_as::<_, AssetImportHistoryItem>( + r#" + SELECT + zip_batch_id, + source_zip_name, + english_level, + sam_plan_id, + sam_course_id, + COUNT(*)::bigint AS asset_count, + MAX(created_at) AS created_at + FROM assets + WHERE organization_id = $1 + AND zip_batch_id IS NOT NULL + AND source_zip_name IS NOT NULL + GROUP BY zip_batch_id, source_zip_name, english_level, sam_plan_id, sam_course_id + ORDER BY MAX(created_at) DESC + LIMIT 100 + "#, + ) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(items)) +} + /// DELETE /api/assets/:id - Eliminar un activo y su archivo físico pub async fn delete_asset( Org(org_ctx): Org, @@ -879,6 +921,8 @@ async fn process_zip_entry_without_rag( org_id: Uuid, user_id: Uuid, pool: PgPool, + zip_batch_id: Uuid, + source_zip_name: String, course_id: Option, english_level: Option, sam_plan_id: Option, @@ -1054,13 +1098,15 @@ async fn process_zip_entry_without_rag( sqlx::query( r#" - INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + INSERT INTO assets (id, organization_id, uploaded_by, zip_batch_id, source_zip_name, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) "#, ) .bind(asset_id) .bind(org_id) .bind(user_id) + .bind(zip_batch_id) + .bind(source_zip_name) .bind(course_id) .bind(&english_level) .bind(sam_plan_id) @@ -1103,6 +1149,7 @@ pub async fn import_assets_zip( ); let mut zip_temp_path: Option = None; + let mut zip_original_name: Option = None; let mut course_id: Option = None; let mut english_level: Option = None; let mut sam_plan_id: Option = None; @@ -1121,6 +1168,15 @@ pub async fn import_assets_zip( let name = field.name().unwrap_or_default().to_string(); if name == "file" { + if let Some(file_name) = field.file_name() { + zip_original_name = Some( + StdPath::new(file_name) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(file_name) + .to_string(), + ); + } let temp_name = format!("uploads/tmp/import-{}.zip", Uuid::new_v4()); tokio::fs::create_dir_all("uploads/tmp") .await @@ -1221,6 +1277,8 @@ pub async fn import_assets_zip( return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string())); } }; + let zip_batch_id = Uuid::new_v4(); + let source_zip_name = zip_original_name.unwrap_or_else(|| "import.zip".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)))?; @@ -1399,6 +1457,7 @@ pub async fn import_assets_zip( let english_level_cl = english_level.clone(); let s3_settings_cl = s3_settings.clone(); let s3_client_cl = s3_client.clone(); + let source_zip_name_cl = source_zip_name.clone(); join_set.spawn(async move { process_zip_entry_without_rag( @@ -1406,6 +1465,8 @@ pub async fn import_assets_zip( org_id, user_id, pool_cl, + zip_batch_id, + source_zip_name_cl, course_id, english_level_cl, sam_plan_id, @@ -1598,13 +1659,15 @@ pub async fn import_assets_zip( let insert_result = sqlx::query( r#" - INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + INSERT INTO assets (id, organization_id, uploaded_by, zip_batch_id, source_zip_name, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) "#, ) .bind(asset_id) .bind(org_ctx.id) .bind(claims.sub) + .bind(zip_batch_id) + .bind(&source_zip_name) .bind(course_id) .bind(&english_level) .bind(sam_plan_id) @@ -1641,6 +1704,8 @@ pub async fn import_assets_zip( sam_plan_id, sam_course_id: effective_sam_course_id, unit_number, + zip_batch_id: Some(zip_batch_id), + source_zip_name: Some(source_zip_name.clone()), filename: stored_filename.clone(), storage_path: db_storage_path.clone(), mimetype: mimetype.clone(), diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 435b774..083dd3d 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -276,6 +276,7 @@ async fn main() { .route("/audit-logs", get(handlers::get_audit_logs)) .route("/api/ai/review-text", post(handlers::review_text)) .route("/api/assets", get(handlers_assets::list_assets)) + .route("/api/assets/import-history", get(handlers_assets::list_asset_import_history)) .route("/api/assets/upload", post(handlers_assets::upload_asset)) .route("/api/assets/import-zip", post(handlers_assets::import_assets_zip)) .route( diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index d90fa44..4aca313 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -349,6 +349,8 @@ pub struct Asset { pub sam_plan_id: Option, pub sam_course_id: Option, pub unit_number: Option, + pub zip_batch_id: Option, + pub source_zip_name: Option, pub filename: String, pub storage_path: String, pub mimetype: String, diff --git a/web/studio/src/app/admin/materials/page.tsx b/web/studio/src/app/admin/materials/page.tsx index d8a896c..36dc317 100644 --- a/web/studio/src/app/admin/materials/page.tsx +++ b/web/studio/src/app/admin/materials/page.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useMemo, useState } from 'react'; -import { cmsApi, questionBankApi, MySqlPlan, MySqlCourse } from '@/lib/api'; +import { cmsApi, questionBankApi, MySqlPlan, MySqlCourse, AssetImportHistoryItem } from '@/lib/api'; import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle, Scissors } from 'lucide-react'; export default function AdminSharedMaterialsPage() { @@ -31,6 +31,7 @@ export default function AdminSharedMaterialsPage() { rag_background_started?: boolean; rag_background_items?: number; } | null>(null); + const [importHistory, setImportHistory] = useState([]); const canUpload = useMemo(() => Boolean(zipFile) && !loading, [zipFile, loading]); @@ -62,6 +63,7 @@ export default function AdminSharedMaterialsPage() { React.useEffect(() => { questionBankApi.getMySQLPlans().then(setPlans).catch(() => setPlans([])); + cmsApi.getAssetImportHistory().then(setImportHistory).catch(() => setImportHistory([])); }, []); React.useEffect(() => { @@ -165,6 +167,7 @@ export default function AdminSharedMaterialsPage() { useDevProcessing, ); setResult(response); + cmsApi.getAssetImportHistory().then(setImportHistory).catch(() => setImportHistory([])); setPhase('done'); alert('Importacion ZIP finalizada.'); } catch (error) { @@ -415,6 +418,48 @@ export default function AdminSharedMaterialsPage() { )} +
+
+

ZIPs ya importados

+

+ Revisa nombre del ZIP y nivel antes de repetir una carga. +

+
+ {importHistory.length === 0 ? ( +

Aun no hay importaciones ZIP registradas.

+ ) : ( +
+ {importHistory.map((item) => ( +
+
+
+

{item.source_zip_name}

+

+ {new Date(item.created_at).toLocaleString()} · {item.asset_count} assets +

+
+
+ + Nivel: {item.english_level || 'general'} + + {item.sam_plan_id && ( + + Plan SAM: {item.sam_plan_id} + + )} + {item.sam_course_id && ( + + Curso SAM: {item.sam_course_id} + + )} +
+
+
+ ))} +
+ )} +
+ {result && (

Resultado de la Importacion

diff --git a/web/studio/src/app/library/assets/page.tsx b/web/studio/src/app/library/assets/page.tsx index 590beee..55560f0 100644 --- a/web/studio/src/app/library/assets/page.tsx +++ b/web/studio/src/app/library/assets/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useRef } from "react"; import { Search, Image as ImageIcon, FileText, Film, File as FileIcon, Loader2, Upload, Trash2, ExternalLink, Filter, Plus @@ -11,38 +11,70 @@ import { useTranslation } from "@/context/I18nContext"; import PageLayout from "@/components/PageLayout"; export default function AssetLibraryPage() { + const PAGE_SIZE = 100; const { t } = useTranslation(); const { user } = useAuth(); const [assets, setAssets] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [filterType, setFilterType] = useState("all"); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); + const sentinelRef = useRef(null); - const loadAssets = useCallback(async () => { - setIsLoading(true); + const loadAssets = useCallback(async (nextPage = 1, append = false) => { + if (append) { + setIsLoadingMore(true); + } else { + setIsLoading(true); + } try { - const filters: AssetFilters = {}; + const filters: AssetFilters = { page: nextPage, limit: PAGE_SIZE }; if (searchTerm) filters.search = searchTerm; if (filterType !== "all") filters.mimetype = filterType; const data = await cmsApi.getAssets(filters); - setAssets(data); + setAssets((prev) => append ? [...prev, ...data] : data); + setPage(nextPage); + setHasMore(data.length === PAGE_SIZE); } catch (error) { console.error("Failed to load assets:", error); } finally { - setIsLoading(false); + if (append) { + setIsLoadingMore(false); + } else { + setIsLoading(false); + } } }, [searchTerm, filterType]); useEffect(() => { const timer = setTimeout(() => { - loadAssets(); + setAssets([]); + setPage(1); + setHasMore(true); + loadAssets(1, false); }, 300); return () => clearTimeout(timer); }, [loadAssets]); + useEffect(() => { + if (!sentinelRef.current || !hasMore || isLoading || isLoadingMore) return; + + const observer = new IntersectionObserver((entries) => { + const first = entries[0]; + if (first?.isIntersecting) { + loadAssets(page + 1, true); + } + }, { rootMargin: '300px' }); + + observer.observe(sentinelRef.current); + return () => observer.disconnect(); + }, [hasMore, isLoading, isLoadingMore, loadAssets, page]); + const handleFileUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; @@ -56,7 +88,7 @@ export default function AssetLibraryPage() { setUploadProgress(pct); }); } - loadAssets(); + loadAssets(1, false); } catch (error) { console.error("Upload failed:", error); alert("Failed to upload assets."); @@ -237,6 +269,20 @@ export default function AssetLibraryPage() { ))}
)} + + {!isLoading && assets.length > 0 && ( +
+ {isLoadingMore ? ( +
+ Cargando mas assets... +
+ ) : !hasMore ? ( +
+ Fin de la biblioteca +
+ ) : null} +
+ )} ); diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index bf650f9..60d7203 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -725,6 +725,8 @@ export interface Asset { uploaded_by: string | null; course_id: string | null; english_level?: string | null; + zip_batch_id?: string | null; + source_zip_name?: string | null; filename: string; storage_path: string; mimetype: string; @@ -732,6 +734,16 @@ export interface Asset { created_at: string; } +export interface AssetImportHistoryItem { + zip_batch_id: string; + source_zip_name: string; + english_level?: string | null; + sam_plan_id?: number | null; + sam_course_id?: number | null; + asset_count: number; + created_at: string; +} + export interface AssetFilters { mimetype?: string; course_id?: string; @@ -1117,6 +1129,7 @@ export const cmsApi = { const query = params.toString(); return apiFetch(`/api/assets${query ? `?${query}` : ''}`); }, + getAssetImportHistory: (): Promise => apiFetch('/api/assets/import-history'), getCourseAssets: (courseId: string): Promise => apiFetch(`/api/assets?course_id=${courseId}`), deleteAsset: (id: string): Promise => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }), ingestAssetForRag: (id: string): Promise =>