207 lines
6.0 KiB
Rust
207 lines
6.0 KiB
Rust
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<String>,
|
|
pub course_id: Option<Uuid>,
|
|
pub search: Option<String>,
|
|
pub page: Option<u32>,
|
|
pub limit: Option<u32>,
|
|
}
|
|
|
|
/// 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<PgPool>,
|
|
mut multipart: Multipart,
|
|
) -> Result<Json<AssetUploadResponse>, (StatusCode, String)> {
|
|
let mut filename = String::new();
|
|
let mut data = Vec::new();
|
|
let mut mimetype = String::new();
|
|
let mut course_id: Option<Uuid> = 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<PgPool>,
|
|
Query(filters): Query<AssetFilters>,
|
|
) -> Result<Json<Vec<Asset>>, (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<PgPool>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<StatusCode, (StatusCode, String)> {
|
|
// 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)
|
|
}
|