use crate::{ constants::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE}, errors::AppError, extractors::{JwtClaims, UploadedAsset}, parsers, state::AppState, }; use api_types::{ requests::{RegisterAssetRequest, TagAssetRequest}, responses::{AssetResponse, IngestResponse, TagResponse, TimelineResponse}, }; use application::{ catalog::{ DeleteAssetCommand, GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery, ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand, }, organization::TagAssetCommand, storage::IngestAssetCommand, }; use axum::{ Json, body::Body, extract::{Multipart, Path, Query, State}, http::{StatusCode, header}, response::Response, }; use domain::{ catalog::entities::AssetFilters, errors::DomainError, value_objects::{DateTimeStamp, MetadataValue, StructuredData, SystemId}, }; #[derive(Debug, serde::Deserialize)] pub struct TimelineParams { pub limit: Option, pub offset: Option, } #[derive(Debug, serde::Deserialize)] pub struct SearchParams { #[serde(rename = "type")] pub asset_type: Option, pub mime_type: Option, pub date_from: Option, pub date_to: Option, pub is_processed: Option, pub limit: Option, pub offset: Option, } pub async fn search_assets( State(state): State, claims: JwtClaims, Query(params): Query, ) -> Result, AppError> { let asset_type = params .asset_type .as_deref() .map(parsers::asset_type) .transpose()?; let date_from = params .date_from .as_deref() .map(|s| { let d = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .map_err(|_| AppError::from(DomainError::Validation("Invalid date_from".into())))?; d.and_hms_opt(0, 0, 0) .map(|dt| DateTimeStamp::from_datetime(dt.and_utc())) .ok_or_else(|| AppError::from(DomainError::Validation("Invalid date_from".into()))) }) .transpose()?; let date_to = params .date_to .as_deref() .map(|s| { let d = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .map_err(|_| AppError::from(DomainError::Validation("Invalid date_to".into())))?; d.and_hms_opt(23, 59, 59) .map(|dt| DateTimeStamp::from_datetime(dt.and_utc())) .ok_or_else(|| AppError::from(DomainError::Validation("Invalid date_to".into()))) }) .transpose()?; let filters = AssetFilters { asset_type, mime_type: params.mime_type, date_from, date_to, is_processed: params.is_processed, }; let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE); let offset = params.offset.unwrap_or(0); let query = SearchAssetsQuery { owner_id: claims.user_id, filters, limit, offset, }; let results = state.catalog.search_assets.execute(query).await?; let total = results.len(); let assets = results .iter() .map(|a| AssetResponse::from_domain(a, &StructuredData::new())) .collect(); Ok(Json(TimelineResponse { assets, total })) } pub async fn ingest( State(state): State, claims: JwtClaims, multipart: Multipart, ) -> Result<(StatusCode, Json), AppError> { let upload = UploadedAsset::from_multipart(multipart).await?; let cmd = IngestAssetCommand { uploader_id: claims.user_id, client_device_id: upload.client_device_id, filename: upload.filename, target_path_id: upload.target_path_id, file_size: upload.data.len() as u64, data: upload.data, }; let (asset, session) = state.catalog.ingest_asset.execute(cmd).await?; Ok(( StatusCode::CREATED, Json(IngestResponse { asset: AssetResponse::from_domain(&asset, &StructuredData::new()), session_id: *session.session_id.as_uuid(), }), )) } pub async fn get_asset( State(state): State, claims: JwtClaims, Path((asset_id,)): Path<(uuid::Uuid,)>, ) -> Result, AppError> { let query = GetAssetQuery { asset_id: SystemId::from_uuid(asset_id), user_id: claims.user_id, }; let (asset, metadata) = state.catalog.get_asset.execute(query).await?; Ok(Json(AssetResponse::from_domain(&asset, &metadata))) } pub async fn timeline( State(state): State, claims: JwtClaims, Query(params): Query, ) -> Result, AppError> { let query = GetTimelineQuery { owner_id: claims.user_id, caller_id: None, limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE), offset: params.offset.unwrap_or(0), }; let results = state.catalog.get_timeline.execute(query).await?; let total = results.len(); let assets = results .iter() .map(|(asset, meta)| AssetResponse::from_domain(asset, meta)) .collect(); Ok(Json(TimelineResponse { assets, total })) } pub async fn update_metadata( State(state): State, claims: JwtClaims, Path((asset_id,)): Path<(uuid::Uuid,)>, Json(req): Json, ) -> Result, AppError> { let mut data = StructuredData::new(); for (k, v) in req.data { data.insert(k, MetadataValue::from(v)); } let cmd = UpdateMetadataCommand { asset_id: SystemId::from_uuid(asset_id), user_id: claims.user_id, data, }; state.catalog.update_metadata.execute(cmd).await?; Ok(Json(serde_json::json!({ "status": "updated" }))) } pub async fn serve_file( State(state): State, claims: JwtClaims, Path((asset_id,)): Path<(uuid::Uuid,)>, ) -> Result { let query = ReadAssetFileQuery { asset_id: SystemId::from_uuid(asset_id), caller_id: claims.user_id, }; let result = state.catalog.read_asset_file.execute(query).await?; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, &result.mime_type) .header(header::CONTENT_LENGTH, result.size) .header( header::CONTENT_DISPOSITION, format!("inline; filename=\"{}\"", result.filename), ) .body(Body::from_stream(result.stream)) .map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string()))) } pub async fn tag_asset( State(state): State, claims: JwtClaims, Path((asset_id,)): Path<(uuid::Uuid,)>, Json(req): Json, ) -> Result<(StatusCode, Json), AppError> { let cmd = TagAssetCommand { asset_id: SystemId::from_uuid(asset_id), tag_name: req.tag_name, user_id: claims.user_id, }; let (tag, _asset_tag) = state.organization.tag_asset.execute(cmd).await?; Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag)))) } pub async fn delete_asset( State(state): State, claims: JwtClaims, Path((asset_id,)): Path<(uuid::Uuid,)>, ) -> Result { let cmd = DeleteAssetCommand { asset_id: SystemId::from_uuid(asset_id), deleted_by: claims.user_id, }; state.catalog.delete_asset.execute(cmd).await?; Ok(StatusCode::NO_CONTENT) } pub async fn serve_derivative( State(state): State, claims: JwtClaims, Path((asset_id, profile)): Path<(uuid::Uuid, String)>, ) -> Result { let profile = parsers::derivative_profile(&profile)?; let query = ReadDerivativeQuery { asset_id: SystemId::from_uuid(asset_id), profile, caller_id: claims.user_id, }; let result = state.catalog.read_derivative.execute(query).await?; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, &result.mime_type) .header(header::CONTENT_LENGTH, result.size) .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") .body(Body::from_stream(result.stream)) .map_err(|e| AppError::from(DomainError::Internal(e.to_string()))) } pub async fn register_asset( State(state): State, claims: JwtClaims, Json(req): Json, ) -> Result<(StatusCode, Json), AppError> { let asset_type = parsers::asset_type(&req.asset_type)?; let cmd = RegisterAssetCommand { volume_id: SystemId::from_uuid(req.volume_id), relative_path: req.relative_path, checksum: req.checksum, asset_type, mime_type: req.mime_type, file_size: req.file_size, owner_id: claims.user_id, }; let (asset, _dup_group) = state.catalog.register_asset.execute(cmd).await?; Ok(( StatusCode::CREATED, Json(AssetResponse::from_domain(&asset, &StructuredData::new())), )) }