Indexes: share_targets.target_id, duplicate_groups.status, GIN on stacks members + duplicate candidates JSONB, composite (owner_user_id, created_at DESC) on assets. N+1 elimination: batch metadata loading via find_by_assets(ids) using WHERE asset_id = ANY($1), used in timeline + sidecar export. Visibility: cache find_targets_for_user per request via OnceCell, extract filter_visible helper to reduce duplication. Streaming: FileStoragePort.open_file() returns (DataStream, u64), LocalFileStorage uses ReaderStream instead of loading full file. serve_file/serve_derivative use Body::from_stream(). Unbounded queries: sidecar full_export/import batched in 500-row chunks instead of u32::MAX. find_unresolved paginated with limit/offset. list_duplicates API accepts pagination params.
286 lines
9.0 KiB
Rust
286 lines
9.0 KiB
Rust
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<u32>,
|
|
pub offset: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
pub struct SearchParams {
|
|
#[serde(rename = "type")]
|
|
pub asset_type: Option<String>,
|
|
pub mime_type: Option<String>,
|
|
pub date_from: Option<String>,
|
|
pub date_to: Option<String>,
|
|
pub is_processed: Option<bool>,
|
|
pub limit: Option<u32>,
|
|
pub offset: Option<u32>,
|
|
}
|
|
|
|
pub async fn search_assets(
|
|
State(state): State<AppState>,
|
|
claims: JwtClaims,
|
|
Query(params): Query<SearchParams>,
|
|
) -> Result<Json<TimelineResponse>, 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<AppState>,
|
|
claims: JwtClaims,
|
|
multipart: Multipart,
|
|
) -> Result<(StatusCode, Json<IngestResponse>), 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<AppState>,
|
|
claims: JwtClaims,
|
|
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
|
) -> Result<Json<AssetResponse>, 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<AppState>,
|
|
claims: JwtClaims,
|
|
Query(params): Query<TimelineParams>,
|
|
) -> Result<Json<TimelineResponse>, 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<AppState>,
|
|
claims: JwtClaims,
|
|
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
|
Json(req): Json<api_types::requests::UpdateMetadataRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
|
claims: JwtClaims,
|
|
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
|
) -> Result<Response, AppError> {
|
|
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<AppState>,
|
|
claims: JwtClaims,
|
|
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
|
Json(req): Json<TagAssetRequest>,
|
|
) -> Result<(StatusCode, Json<TagResponse>), 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<AppState>,
|
|
claims: JwtClaims,
|
|
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
|
) -> Result<StatusCode, AppError> {
|
|
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<AppState>,
|
|
claims: JwtClaims,
|
|
Path((asset_id, profile)): Path<(uuid::Uuid, String)>,
|
|
) -> Result<Response, AppError> {
|
|
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<AppState>,
|
|
claims: JwtClaims,
|
|
Json(req): Json<RegisterAssetRequest>,
|
|
) -> Result<(StatusCode, Json<AssetResponse>), 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())),
|
|
))
|
|
}
|