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:
2026-05-31 23:06:25 +02:00
parent bcaf49cc81
commit 7b5bb66b37
33 changed files with 1048 additions and 72 deletions

View File

@@ -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