use axum::{ Json, extract::{Path, Query, State, Multipart}, http::StatusCode, }; use common::models::{Asset}; use common::{auth::Claims, middleware::Org}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use std::path::Path as StdPath; #[derive(Debug, Serialize)] pub struct AssetUploadResponse { pub id: Uuid, pub filename: String, pub url: String, pub mimetype: String, pub size_bytes: i64, } #[derive(Debug, Deserialize)] pub struct AssetFilters { pub mimetype: Option, pub course_id: Option, pub search: Option, pub page: Option, pub limit: Option, } /// POST /api/assets/upload - Subir un archivo a la biblioteca global pub async fn upload_asset( Org(org_ctx): Org, claims: Claims, State(pool): State, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { let mut filename = String::new(); let mut data = Vec::new(); let mut mimetype = String::new(); let mut course_id: Option = None; while let Some(field) = multipart .next_field() .await .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? { let name = field.name().unwrap_or_default().to_string(); if name == "file" { filename = field.file_name().unwrap_or("unnamed").to_string(); mimetype = field .content_type() .unwrap_or("application/octet-stream") .to_string(); data = field .bytes() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .to_vec(); } else if name == "course_id" { if let Ok(txt) = field.text().await { if let Ok(id) = Uuid::parse_str(&txt) { course_id = Some(id); } } } } if data.is_empty() { return Err((StatusCode::BAD_REQUEST, "No file uploaded".to_string())); } let asset_id = Uuid::new_v4(); let extension = StdPath::new(&filename) .extension() .and_then(|s| s.to_str()) .unwrap_or(""); let storage_filename = format!("{}.{}", asset_id, extension); let storage_path = format!("uploads/{}", storage_filename); // Ensure uploads directory exists tokio::fs::create_dir_all("uploads") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Write file tokio::fs::write(&storage_path, data) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let size_bytes = tokio::fs::metadata(&storage_path) .await .map(|m| m.len() as i64) .unwrap_or(0); // 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) "#, ) .bind(asset_id) .bind(org_ctx.id) .bind(claims.sub) .bind(course_id) .bind(&filename) .bind(&storage_path) .bind(&mimetype) .bind(size_bytes) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(AssetUploadResponse { id: asset_id, filename, url: format!("/assets/{}", storage_filename), mimetype, size_bytes, })) } /// GET /api/assets - Listar activos de la organización pub async fn list_assets( Org(org_ctx): Org, State(pool): State, Query(filters): Query, ) -> Result>, (StatusCode, String)> { let limit = filters.limit.unwrap_or(50) as i64; let offset = ((filters.page.unwrap_or(1).max(1) - 1) * filters.limit.unwrap_or(50)) as i64; let mut query = String::from("SELECT * FROM assets WHERE organization_id = $1"); let mut param_index = 2; if filters.mimetype.is_some() { query.push_str(&format!(" AND mimetype ILIKE ${}", param_index)); param_index += 1; } if filters.course_id.is_some() { query.push_str(&format!(" AND course_id = ${}", param_index)); param_index += 1; } if filters.search.is_some() { query.push_str(&format!(" AND filename ILIKE ${}", param_index)); param_index += 1; } query.push_str(&format!(" ORDER BY created_at DESC LIMIT ${} OFFSET ${}", param_index, param_index + 1)); let mut sql_query = sqlx::query_as::<_, Asset>(&query).bind(org_ctx.id); if let Some(mt) = &filters.mimetype { sql_query = sql_query.bind(format!("%{}%", mt)); } if let Some(cid) = filters.course_id { sql_query = sql_query.bind(cid); } if let Some(search) = &filters.search { sql_query = sql_query.bind(format!("%{}%", search)); } let assets = sql_query .bind(limit) .bind(offset) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(assets)) } /// DELETE /api/assets/:id - Eliminar un activo y su archivo físico pub async fn delete_asset( Org(org_ctx): Org, State(pool): State, Path(id): Path, ) -> Result { // 1. Get asset metadata to find file path let asset: Asset = sqlx::query_as( "SELECT * FROM assets WHERE id = $1 AND organization_id = $2" ) .bind(id) .bind(org_ctx.id) .fetch_optional(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?; // 2. Delete from DB sqlx::query("DELETE FROM assets WHERE id = $1") .bind(id) .execute(&pool) .await .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 3. Delete physical file (async) let _ = tokio::fs::remove_file(&asset.storage_path).await; Ok(StatusCode::NO_CONTENT) }