feat: frontend-ready backend — pagination, auto-derivatives, list endpoints, bulk ops, OpenAPI
Pagination: count_by_owner + count_search on AssetRepository,
timeline/search return real total count (not page len).
Auto-derivatives: worker enqueues GenerateDerivative when
ExtractMetadata job completes, closing the upload→thumbnail gap.
List endpoints: GET /albums, GET /stacks with user scoping.
ListAlbumsHandler, ListStacksHandler, find_by_owner on AssetStackRepository.
Tag filtering: tag_name field on AssetFilters, JOIN asset_tags+tags
in postgres search/count queries.
Bulk operations: POST /assets/bulk-delete, POST /assets/bulk-tag.
Album update: PUT /albums/{id} with UpdateAlbumHandler (title, description).
OpenAPI: utoipa annotations on all 47 endpoints + all request/response
schemas registered. Scalar UI at /scalar covers full API.
This commit is contained in:
@@ -74,6 +74,84 @@ impl TryFrom<AssetRow> for Asset {
|
||||
|
||||
pg_repo!(PostgresAssetRepository);
|
||||
|
||||
fn build_search_where(filters: &AssetFilters) -> (String, bool) {
|
||||
let mut clause = String::new();
|
||||
let mut idx = 2u32;
|
||||
if filters.asset_type.is_some() {
|
||||
clause.push_str(&format!(" AND a.asset_type = ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
if filters.mime_type.is_some() {
|
||||
clause.push_str(&format!(" AND a.mime_type = ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
if filters.date_from.is_some() {
|
||||
clause.push_str(&format!(" AND a.created_at >= ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
if filters.date_to.is_some() {
|
||||
clause.push_str(&format!(" AND a.created_at <= ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
if filters.is_processed.is_some() {
|
||||
clause.push_str(&format!(" AND a.is_processed = ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
let has_tag = filters.tag_name.is_some();
|
||||
if has_tag {
|
||||
clause.push_str(&format!(" AND t.name = ${idx}"));
|
||||
}
|
||||
(clause, has_tag)
|
||||
}
|
||||
|
||||
fn count_filter_params(filters: &AssetFilters) -> u32 {
|
||||
let mut n = 0u32;
|
||||
if filters.asset_type.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.mime_type.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.date_from.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.date_to.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.is_processed.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.tag_name.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn bind_filters<'q, O>(
|
||||
mut query: sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments>,
|
||||
filters: &'q AssetFilters,
|
||||
) -> sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments> {
|
||||
if let Some(ref t) = filters.asset_type {
|
||||
query = query.bind(asset_type_to_str(t));
|
||||
}
|
||||
if let Some(ref m) = filters.mime_type {
|
||||
query = query.bind(m.as_str());
|
||||
}
|
||||
if let Some(ref d) = filters.date_from {
|
||||
query = query.bind(d.as_datetime());
|
||||
}
|
||||
if let Some(ref d) = filters.date_to {
|
||||
query = query.bind(d.as_datetime());
|
||||
}
|
||||
if let Some(p) = filters.is_processed {
|
||||
query = query.bind(p);
|
||||
}
|
||||
if let Some(ref tag) = filters.tag_name {
|
||||
query = query.bind(tag.as_str());
|
||||
}
|
||||
query
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AssetRepository for PostgresAssetRepository {
|
||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> {
|
||||
@@ -134,41 +212,66 @@ impl AssetRepository for PostgresAssetRepository {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Asset>, DomainError> {
|
||||
let mut sql = String::from(
|
||||
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
|
||||
file_size, is_processed, owner_user_id, created_at
|
||||
FROM assets WHERE owner_user_id = $1",
|
||||
let (where_clause, has_tag) = build_search_where(filters);
|
||||
let mut sql = format!(
|
||||
"SELECT a.asset_id, a.volume_id, a.relative_path, a.checksum, a.asset_type, a.mime_type,
|
||||
a.file_size, a.is_processed, a.owner_user_id, a.created_at
|
||||
FROM assets a{} WHERE a.owner_user_id = $1{}",
|
||||
if has_tag {
|
||||
" JOIN asset_tags at ON at.asset_id = a.asset_id JOIN tags t ON t.tag_id = at.tag_id"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
where_clause
|
||||
);
|
||||
let mut param_idx = 2u32;
|
||||
|
||||
if filters.asset_type.is_some() {
|
||||
sql.push_str(&format!(" AND asset_type = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.mime_type.is_some() {
|
||||
sql.push_str(&format!(" AND mime_type = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.date_from.is_some() {
|
||||
sql.push_str(&format!(" AND created_at >= ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.date_to.is_some() {
|
||||
sql.push_str(&format!(" AND created_at <= ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.is_processed.is_some() {
|
||||
sql.push_str(&format!(" AND is_processed = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
let param_count = count_filter_params(filters);
|
||||
sql.push_str(&format!(
|
||||
" ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
|
||||
param_idx,
|
||||
param_idx + 1
|
||||
" ORDER BY a.created_at DESC LIMIT ${} OFFSET ${}",
|
||||
param_count + 2,
|
||||
param_count + 3
|
||||
));
|
||||
|
||||
let mut query = sqlx::query_as::<_, AssetRow>(&sql).bind(*owner_id.as_uuid());
|
||||
query = bind_filters(query, filters);
|
||||
|
||||
let rows = query
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
|
||||
let (count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE owner_user_id = $1")
|
||||
.bind(*owner_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn count_search(
|
||||
&self,
|
||||
owner_id: &SystemId,
|
||||
filters: &AssetFilters,
|
||||
) -> Result<u64, DomainError> {
|
||||
let (where_clause, has_tag) = build_search_where(filters);
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) FROM assets a{} WHERE a.owner_user_id = $1{}",
|
||||
if has_tag {
|
||||
" JOIN asset_tags at ON at.asset_id = a.asset_id JOIN tags t ON t.tag_id = at.tag_id"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
where_clause
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, (i64,)>(&sql).bind(*owner_id.as_uuid());
|
||||
|
||||
if let Some(ref t) = filters.asset_type {
|
||||
query = query.bind(asset_type_to_str(t));
|
||||
@@ -185,15 +288,12 @@ impl AssetRepository for PostgresAssetRepository {
|
||||
if let Some(p) = filters.is_processed {
|
||||
query = query.bind(p);
|
||||
}
|
||||
if let Some(ref tag) = filters.tag_name {
|
||||
query = query.bind(tag.as_str());
|
||||
}
|
||||
|
||||
let rows = query
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
let (count,) = query.fetch_one(&self.pool).await.map_pg()?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||
@@ -805,6 +905,19 @@ impl AssetStackRepository for PostgresAssetStackRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn find_by_owner(&self, owner_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, StackRow>(
|
||||
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
|
||||
FROM asset_stacks WHERE owner_user_id = $1",
|
||||
)
|
||||
.bind(*owner_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, StackRow>(
|
||||
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
|
||||
|
||||
Reference in New Issue
Block a user