From 7b5bb66b37d4ef7cb048d8c99cc3545f6abef240 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 23:06:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20frontend-ready=20backend=20=E2=80=94=20?= =?UTF-8?q?pagination,=20auto-derivatives,=20list=20endpoints,=20bulk=20op?= =?UTF-8?q?s,=20OpenAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/adapters/postgres/src/catalog/mod.rs | 187 ++++++++++++++---- crates/api-types/src/requests.rs | 28 +++ crates/api-types/src/responses.rs | 2 +- crates/application/src/catalog/mod.rs | 5 +- .../src/catalog/queries/get_timeline.rs | 15 +- .../src/catalog/queries/list_stacks.rs | 22 +++ crates/application/src/catalog/queries/mod.rs | 1 + .../src/catalog/queries/search_assets.rs | 17 +- crates/application/src/catalog/visibility.rs | 12 ++ .../src/organization/commands/mod.rs | 2 + .../src/organization/commands/update_album.rs | 44 +++++ crates/application/src/organization/mod.rs | 2 + .../src/organization/queries/list_albums.rs | 22 +++ .../src/organization/queries/mod.rs | 1 + .../application/src/testing/repositories.rs | 18 ++ .../tests/catalog/queries/get_timeline.rs | 10 +- crates/bootstrap/src/services/catalog.rs | 4 + crates/bootstrap/src/services/organization.rs | 7 +- crates/domain/src/catalog/entities.rs | 1 + crates/domain/src/catalog/ports.rs | 7 + crates/presentation/src/handlers/albums.rs | 90 ++++++++- crates/presentation/src/handlers/assets.rs | 184 ++++++++++++++++- crates/presentation/src/handlers/auth.rs | 16 ++ .../presentation/src/handlers/duplicates.rs | 22 +++ .../presentation/src/handlers/processing.rs | 78 ++++++++ crates/presentation/src/handlers/sharing.rs | 35 ++++ crates/presentation/src/handlers/sidecar.rs | 52 +++++ crates/presentation/src/handlers/stacks.rs | 57 +++++- crates/presentation/src/handlers/storage.rs | 30 +++ crates/presentation/src/openapi/mod.rs | 98 ++++++++- crates/presentation/src/routes.rs | 17 +- crates/presentation/src/state.rs | 10 +- crates/worker/src/main.rs | 24 ++- 33 files changed, 1048 insertions(+), 72 deletions(-) create mode 100644 crates/application/src/catalog/queries/list_stacks.rs create mode 100644 crates/application/src/organization/commands/update_album.rs create mode 100644 crates/application/src/organization/queries/list_albums.rs diff --git a/crates/adapters/postgres/src/catalog/mod.rs b/crates/adapters/postgres/src/catalog/mod.rs index 66fcba4..1be9a41 100644 --- a/crates/adapters/postgres/src/catalog/mod.rs +++ b/crates/adapters/postgres/src/catalog/mod.rs @@ -74,6 +74,84 @@ impl TryFrom 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, DomainError> { @@ -134,41 +212,66 @@ impl AssetRepository for PostgresAssetRepository { limit: u32, offset: u32, ) -> Result, 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 { + 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 { + 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, 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, DomainError> { let rows = sqlx::query_as::<_, StackRow>( "SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs index 4678cc8..f5293c2 100644 --- a/crates/api-types/src/requests.rs +++ b/crates/api-types/src/requests.rs @@ -91,6 +91,34 @@ pub struct RegisterAssetRequest { pub file_size: u64, } +// --- Bulk --- + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct BulkDeleteRequest { + pub asset_ids: Vec, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct BulkTagRequest { + pub asset_ids: Vec, + pub tag_name: String, +} + +// --- Album update --- + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateAlbumRequest { + pub title: Option, + pub description: Option, +} + +// --- Stack reorder --- + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct ReorderStackRequest { + pub member_order: Vec, +} + // --- Stacks --- #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index d3e93c3..033db74 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -87,7 +87,7 @@ impl AssetResponse { #[derive(Debug, serde::Serialize, utoipa::ToSchema)] pub struct TimelineResponse { pub assets: Vec, - pub total: usize, + pub total: u64, } #[derive(Debug, serde::Serialize, utoipa::ToSchema)] diff --git a/crates/application/src/catalog/mod.rs b/crates/application/src/catalog/mod.rs index e57707b..d717182 100644 --- a/crates/application/src/catalog/mod.rs +++ b/crates/application/src/catalog/mod.rs @@ -14,10 +14,11 @@ pub use commands::resolve_duplicate::{ pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler}; pub use queries::get_asset::{GetAssetHandler, GetAssetQuery}; pub use queries::get_stack::{GetStackHandler, GetStackQuery}; -pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery}; +pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery, TimelineResult}; +pub use queries::list_stacks::{ListStacksHandler, ListStacksQuery}; pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery}; pub use queries::read_derivative::{ DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery, }; -pub use queries::search_assets::{SearchAssetsHandler, SearchAssetsQuery}; +pub use queries::search_assets::{SearchAssetsHandler, SearchAssetsQuery, SearchResult}; pub use visibility::VisibilityFilteredAssetRepository; diff --git a/crates/application/src/catalog/queries/get_timeline.rs b/crates/application/src/catalog/queries/get_timeline.rs index ef5450f..44dd168 100644 --- a/crates/application/src/catalog/queries/get_timeline.rs +++ b/crates/application/src/catalog/queries/get_timeline.rs @@ -16,6 +16,11 @@ pub struct GetTimelineQuery { pub offset: u32, } +pub struct TimelineResult { + pub items: Vec<(Asset, StructuredData)>, + pub total: u64, +} + pub struct GetTimelineHandler { asset_repo: Arc, metadata_repo: Arc, @@ -51,13 +56,11 @@ impl GetTimelineHandler { } } - pub async fn execute( - &self, - query: GetTimelineQuery, - ) -> Result, DomainError> { + pub async fn execute(&self, query: GetTimelineQuery) -> Result { let caller_id = query.caller_id.unwrap_or(query.owner_id); let repo = self.effective_repo(caller_id); + let total = repo.count_by_owner(&query.owner_id).await?; let assets = repo .find_by_owner(&query.owner_id, query.limit, query.offset) .await?; @@ -65,7 +68,7 @@ impl GetTimelineHandler { let asset_ids: Vec = assets.iter().map(|a| a.asset_id).collect(); let all_layers = self.metadata_repo.find_by_assets(&asset_ids).await?; - let results = assets + let items = assets .into_iter() .map(|asset| { let layers: Vec<_> = all_layers @@ -78,6 +81,6 @@ impl GetTimelineHandler { }) .collect(); - Ok(results) + Ok(TimelineResult { items, total }) } } diff --git a/crates/application/src/catalog/queries/list_stacks.rs b/crates/application/src/catalog/queries/list_stacks.rs new file mode 100644 index 0000000..0870ecf --- /dev/null +++ b/crates/application/src/catalog/queries/list_stacks.rs @@ -0,0 +1,22 @@ +use domain::{ + entities::AssetStack, errors::DomainError, ports::AssetStackRepository, value_objects::SystemId, +}; +use std::sync::Arc; + +pub struct ListStacksQuery { + pub owner_id: SystemId, +} + +pub struct ListStacksHandler { + stack_repo: Arc, +} + +impl ListStacksHandler { + pub fn new(stack_repo: Arc) -> Self { + Self { stack_repo } + } + + pub async fn execute(&self, query: ListStacksQuery) -> Result, DomainError> { + self.stack_repo.find_by_owner(&query.owner_id).await + } +} diff --git a/crates/application/src/catalog/queries/mod.rs b/crates/application/src/catalog/queries/mod.rs index 5db004e..a64f9af 100644 --- a/crates/application/src/catalog/queries/mod.rs +++ b/crates/application/src/catalog/queries/mod.rs @@ -1,6 +1,7 @@ pub mod get_asset; pub mod get_stack; pub mod get_timeline; +pub mod list_stacks; pub mod read_asset_file; pub mod read_derivative; pub mod search_assets; diff --git a/crates/application/src/catalog/queries/search_assets.rs b/crates/application/src/catalog/queries/search_assets.rs index 24e2b6b..6a47fd8 100644 --- a/crates/application/src/catalog/queries/search_assets.rs +++ b/crates/application/src/catalog/queries/search_assets.rs @@ -14,6 +14,11 @@ pub struct SearchAssetsQuery { pub offset: u32, } +pub struct SearchResult { + pub items: Vec, + pub total: u64, +} + pub struct SearchAssetsHandler { asset_repo: Arc, } @@ -23,9 +28,15 @@ impl SearchAssetsHandler { Self { asset_repo } } - pub async fn execute(&self, query: SearchAssetsQuery) -> Result, DomainError> { - self.asset_repo + pub async fn execute(&self, query: SearchAssetsQuery) -> Result { + let total = self + .asset_repo + .count_search(&query.owner_id, &query.filters) + .await?; + let items = self + .asset_repo .search(&query.owner_id, &query.filters, query.limit, query.offset) - .await + .await?; + Ok(SearchResult { items, total }) } } diff --git a/crates/application/src/catalog/visibility.rs b/crates/application/src/catalog/visibility.rs index b8ab5fe..4f8e770 100644 --- a/crates/application/src/catalog/visibility.rs +++ b/crates/application/src/catalog/visibility.rs @@ -122,6 +122,18 @@ impl AssetRepository for VisibilityFilteredAssetRepository { self.filter_visible(assets).await } + async fn count_by_owner(&self, owner_id: &SystemId) -> Result { + self.inner.count_by_owner(owner_id).await + } + + async fn count_search( + &self, + owner_id: &SystemId, + filters: &AssetFilters, + ) -> Result { + self.inner.count_search(owner_id, filters).await + } + async fn save(&self, asset: &Asset) -> Result<(), DomainError> { self.inner.save(asset).await } diff --git a/crates/application/src/organization/commands/mod.rs b/crates/application/src/organization/commands/mod.rs index bb3b324..c37ff50 100644 --- a/crates/application/src/organization/commands/mod.rs +++ b/crates/application/src/organization/commands/mod.rs @@ -1,7 +1,9 @@ pub mod create_album; pub mod manage_album_entries; pub mod tag_asset; +pub mod update_album; pub use create_album::{CreateAlbumCommand, CreateAlbumHandler}; pub use manage_album_entries::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler}; pub use tag_asset::{TagAssetCommand, TagAssetHandler}; +pub use update_album::{UpdateAlbumCommand, UpdateAlbumHandler}; diff --git a/crates/application/src/organization/commands/update_album.rs b/crates/application/src/organization/commands/update_album.rs new file mode 100644 index 0000000..6b1bd43 --- /dev/null +++ b/crates/application/src/organization/commands/update_album.rs @@ -0,0 +1,44 @@ +use domain::{errors::DomainError, ports::AlbumRepository, value_objects::SystemId}; +use std::sync::Arc; + +pub struct UpdateAlbumCommand { + pub album_id: SystemId, + pub user_id: SystemId, + pub title: Option, + pub description: Option, +} + +pub struct UpdateAlbumHandler { + album_repo: Arc, +} + +impl UpdateAlbumHandler { + pub fn new(album_repo: Arc) -> Self { + Self { album_repo } + } + + pub async fn execute( + &self, + cmd: UpdateAlbumCommand, + ) -> Result { + let mut album = self + .album_repo + .find_by_id(&cmd.album_id) + .await? + .ok_or_else(|| DomainError::NotFound("Album not found".into()))?; + + if album.creator_user_id != cmd.user_id { + return Err(DomainError::Forbidden("Not your album".into())); + } + + if let Some(title) = cmd.title { + album.title = title; + } + if let Some(desc) = cmd.description { + album.description = desc; + } + + self.album_repo.save(&album).await?; + Ok(album) + } +} diff --git a/crates/application/src/organization/mod.rs b/crates/application/src/organization/mod.rs index 38f701e..3e535e3 100644 --- a/crates/application/src/organization/mod.rs +++ b/crates/application/src/organization/mod.rs @@ -4,4 +4,6 @@ pub mod queries; pub use commands::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler}; pub use commands::{CreateAlbumCommand, CreateAlbumHandler}; pub use commands::{TagAssetCommand, TagAssetHandler}; +pub use commands::{UpdateAlbumCommand, UpdateAlbumHandler}; pub use queries::get_album::{GetAlbumHandler, GetAlbumQuery}; +pub use queries::list_albums::{ListAlbumsHandler, ListAlbumsQuery}; diff --git a/crates/application/src/organization/queries/list_albums.rs b/crates/application/src/organization/queries/list_albums.rs new file mode 100644 index 0000000..92215c0 --- /dev/null +++ b/crates/application/src/organization/queries/list_albums.rs @@ -0,0 +1,22 @@ +use domain::{ + entities::Album, errors::DomainError, ports::AlbumRepository, value_objects::SystemId, +}; +use std::sync::Arc; + +pub struct ListAlbumsQuery { + pub user_id: SystemId, +} + +pub struct ListAlbumsHandler { + album_repo: Arc, +} + +impl ListAlbumsHandler { + pub fn new(album_repo: Arc) -> Self { + Self { album_repo } + } + + pub async fn execute(&self, query: ListAlbumsQuery) -> Result, DomainError> { + self.album_repo.find_by_creator(&query.user_id).await + } +} diff --git a/crates/application/src/organization/queries/mod.rs b/crates/application/src/organization/queries/mod.rs index 43722a8..ab9885b 100644 --- a/crates/application/src/organization/queries/mod.rs +++ b/crates/application/src/organization/queries/mod.rs @@ -1 +1,2 @@ pub mod get_album; +pub mod list_albums; diff --git a/crates/application/src/testing/repositories.rs b/crates/application/src/testing/repositories.rs index 401229f..4a436f0 100644 --- a/crates/application/src/testing/repositories.rs +++ b/crates/application/src/testing/repositories.rs @@ -145,6 +145,16 @@ impl AssetRepository for InMemoryAssetRepository { .collect()) } + async fn count_by_owner(&self, owner_id: &SystemId) -> Result { + Ok(self + .data + .lock() + .await + .values() + .filter(|a| &a.owner_user_id == owner_id) + .count() as u64) + } + async fn search( &self, owner_id: &SystemId, @@ -155,6 +165,14 @@ impl AssetRepository for InMemoryAssetRepository { self.find_by_owner(owner_id, limit, offset).await } + async fn count_search( + &self, + owner_id: &SystemId, + _filters: &AssetFilters, + ) -> Result { + self.count_by_owner(owner_id).await + } + async fn save(&self, asset: &Asset) -> Result<(), DomainError> { self.data .lock() diff --git a/crates/application/tests/catalog/queries/get_timeline.rs b/crates/application/tests/catalog/queries/get_timeline.rs index 9fc436f..7bdb95d 100644 --- a/crates/application/tests/catalog/queries/get_timeline.rs +++ b/crates/application/tests/catalog/queries/get_timeline.rs @@ -28,7 +28,7 @@ async fn returns_paginated_assets() { let handler = GetTimelineHandler::new(asset_repo, meta_repo); - let page = handler + let result = handler .execute(GetTimelineQuery { owner_id: owner, caller_id: None, @@ -38,7 +38,8 @@ async fn returns_paginated_assets() { .await .unwrap(); - assert_eq!(page.len(), 3); + assert_eq!(result.items.len(), 3); + assert_eq!(result.total, 5); } #[tokio::test] @@ -48,7 +49,7 @@ async fn returns_empty_for_no_assets() { let handler = GetTimelineHandler::new(asset_repo, meta_repo); - let page = handler + let result = handler .execute(GetTimelineQuery { owner_id: SystemId::new(), caller_id: None, @@ -58,5 +59,6 @@ async fn returns_empty_for_no_assets() { .await .unwrap(); - assert!(page.is_empty()); + assert!(result.items.is_empty()); + assert_eq!(result.total, 0); } diff --git a/crates/bootstrap/src/services/catalog.rs b/crates/bootstrap/src/services/catalog.rs index f144a51..dafde0e 100644 --- a/crates/bootstrap/src/services/catalog.rs +++ b/crates/bootstrap/src/services/catalog.rs @@ -88,6 +88,9 @@ pub fn build( )); let get_stack = Arc::new(GetStackHandler::new(stack_repo.clone())); let delete_stack = Arc::new(DeleteStackHandler::new(stack_repo.clone())); + let list_stacks = Arc::new(application::catalog::ListStacksHandler::new( + stack_repo.clone(), + )); let detect_live_photos = Arc::new(DetectLivePhotosHandler::new(asset_repo.clone(), stack_repo)); let register_asset = Arc::new(RegisterAssetHandler::new( @@ -112,5 +115,6 @@ pub fn build( get_stack, delete_stack, detect_live_photos, + list_stacks, } } diff --git a/crates/bootstrap/src/services/organization.rs b/crates/bootstrap/src/services/organization.rs index 3d2b641..b373f01 100644 --- a/crates/bootstrap/src/services/organization.rs +++ b/crates/bootstrap/src/services/organization.rs @@ -4,7 +4,8 @@ use adapters_postgres::{ PgPool, PostgresAlbumRepository, PostgresAssetRepository, PostgresTagRepository, }; use application::organization::{ - CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler, TagAssetHandler, + CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler, + TagAssetHandler, UpdateAlbumHandler, }; use presentation::state::OrganizationHandlers; @@ -15,13 +16,17 @@ pub fn build(pool: &PgPool) -> OrganizationHandlers { let create_album = Arc::new(CreateAlbumHandler::new(album_repo.clone())); let get_album = Arc::new(GetAlbumHandler::new(album_repo.clone())); + let list_albums = Arc::new(ListAlbumsHandler::new(album_repo.clone())); + let update_album = Arc::new(UpdateAlbumHandler::new(album_repo.clone())); let manage_album_entries = Arc::new(ManageAlbumEntriesHandler::new(album_repo)); let tag_asset = Arc::new(TagAssetHandler::new(asset_repo, tag_repo)); OrganizationHandlers { create_album, get_album, + list_albums, manage_album_entries, + update_album, tag_asset, } } diff --git a/crates/domain/src/catalog/entities.rs b/crates/domain/src/catalog/entities.rs index 1000181..fee8719 100644 --- a/crates/domain/src/catalog/entities.rs +++ b/crates/domain/src/catalog/entities.rs @@ -63,6 +63,7 @@ pub struct AssetFilters { pub date_from: Option, pub date_to: Option, pub is_processed: Option, + pub tag_name: Option, } // --- AssetMetadata --- diff --git a/crates/domain/src/catalog/ports.rs b/crates/domain/src/catalog/ports.rs index 7111302..f4ae1bc 100644 --- a/crates/domain/src/catalog/ports.rs +++ b/crates/domain/src/catalog/ports.rs @@ -19,6 +19,7 @@ pub trait AssetRepository: Send + Sync { limit: u32, offset: u32, ) -> Result, DomainError>; + async fn count_by_owner(&self, owner_id: &SystemId) -> Result; async fn search( &self, owner_id: &SystemId, @@ -26,6 +27,11 @@ pub trait AssetRepository: Send + Sync { limit: u32, offset: u32, ) -> Result, DomainError>; + async fn count_search( + &self, + owner_id: &SystemId, + filters: &AssetFilters, + ) -> Result; async fn save(&self, asset: &Asset) -> Result<(), DomainError>; async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; } @@ -57,6 +63,7 @@ pub trait AssetMetadataRepository: Send + Sync { #[async_trait] pub trait AssetStackRepository: Send + Sync { async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_owner(&self, owner_id: &SystemId) -> Result, DomainError>; async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>; async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; diff --git a/crates/presentation/src/handlers/albums.rs b/crates/presentation/src/handlers/albums.rs index 080f0c3..f0077b1 100644 --- a/crates/presentation/src/handlers/albums.rs +++ b/crates/presentation/src/handlers/albums.rs @@ -1,10 +1,12 @@ use crate::{errors::AppError, extractors::JwtClaims, state::AppState}; +use api_types::requests::UpdateAlbumRequest; use api_types::{ requests::{AlbumEntryRequest, CreateAlbumRequest}, responses::AlbumResponse, }; use application::organization::{ - AlbumAction, CreateAlbumCommand, GetAlbumQuery, ManageAlbumEntriesCommand, + AlbumAction, CreateAlbumCommand, GetAlbumQuery, ListAlbumsQuery, ManageAlbumEntriesCommand, + UpdateAlbumCommand, }; use axum::{ Json, @@ -13,6 +15,35 @@ use axum::{ }; use domain::value_objects::SystemId; +#[utoipa::path( + get, path = "/api/v1/albums", + security(("bearer_token" = [])), + responses( + (status = 200, description = "List of albums", body = Vec), + (status = 401, description = "Unauthorized") + ) +)] +pub async fn list_albums( + State(state): State, + claims: JwtClaims, +) -> Result>, AppError> { + let query = ListAlbumsQuery { + user_id: claims.user_id, + }; + let albums = state.organization.list_albums.execute(query).await?; + let resp = albums.iter().map(AlbumResponse::from_domain).collect(); + Ok(Json(resp)) +} + +#[utoipa::path( + post, path = "/api/v1/albums", + request_body = CreateAlbumRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Album created", body = AlbumResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn create_album( State(state): State, claims: JwtClaims, @@ -29,6 +60,15 @@ pub async fn create_album( )) } +#[utoipa::path( + get, path = "/api/v1/albums/{id}", + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Album ID")), + responses( + (status = 200, description = "Album details", body = AlbumResponse), + (status = 404, description = "Not found") + ) +)] pub async fn get_album( State(state): State, claims: JwtClaims, @@ -42,6 +82,42 @@ pub async fn get_album( Ok(Json(AlbumResponse::from_domain(&album))) } +#[utoipa::path( + put, path = "/api/v1/albums/{id}", + request_body = UpdateAlbumRequest, + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Album ID")), + responses( + (status = 200, description = "Album updated", body = AlbumResponse), + (status = 404, description = "Not found") + ) +)] +pub async fn update_album( + State(state): State, + claims: JwtClaims, + Path((album_id,)): Path<(uuid::Uuid,)>, + Json(req): Json, +) -> Result, AppError> { + let cmd = UpdateAlbumCommand { + album_id: SystemId::from_uuid(album_id), + user_id: claims.user_id, + title: req.title, + description: req.description, + }; + let album = state.organization.update_album.execute(cmd).await?; + Ok(Json(AlbumResponse::from_domain(&album))) +} + +#[utoipa::path( + post, path = "/api/v1/albums/{id}/entries", + request_body = AlbumEntryRequest, + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Album ID")), + responses( + (status = 201, description = "Entry added", body = AlbumResponse), + (status = 404, description = "Not found") + ) +)] pub async fn add_entry( State(state): State, claims: JwtClaims, @@ -62,6 +138,18 @@ pub async fn add_entry( )) } +#[utoipa::path( + delete, path = "/api/v1/albums/{id}/entries/{asset_id}", + security(("bearer_token" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Album ID"), + ("asset_id" = uuid::Uuid, Path, description = "Asset ID") + ), + responses( + (status = 200, description = "Entry removed", body = AlbumResponse), + (status = 404, description = "Not found") + ) +)] pub async fn remove_entry( State(state): State, claims: JwtClaims, diff --git a/crates/presentation/src/handlers/assets.rs b/crates/presentation/src/handlers/assets.rs index 00a9a11..fc22e6a 100644 --- a/crates/presentation/src/handlers/assets.rs +++ b/crates/presentation/src/handlers/assets.rs @@ -44,10 +44,29 @@ pub struct SearchParams { pub date_from: Option, pub date_to: Option, pub is_processed: Option, + pub tag: Option, pub limit: Option, pub offset: Option, } +#[utoipa::path( + get, path = "/api/v1/assets", + security(("bearer_token" = [])), + params( + ("type" = Option, Query, description = "Asset type filter"), + ("mime_type" = Option, Query, description = "MIME type filter"), + ("date_from" = Option, Query, description = "Start date (YYYY-MM-DD)"), + ("date_to" = Option, Query, description = "End date (YYYY-MM-DD)"), + ("is_processed" = Option, Query, description = "Processed filter"), + ("tag" = Option, Query, description = "Tag name filter"), + ("limit" = Option, Query, description = "Page size"), + ("offset" = Option, Query, description = "Page offset") + ), + responses( + (status = 200, description = "Search results", body = TimelineResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn search_assets( State(state): State, claims: JwtClaims, @@ -89,6 +108,7 @@ pub async fn search_assets( date_from, date_to, is_processed: params.is_processed, + tag_name: params.tag, }; let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE); @@ -100,15 +120,27 @@ pub async fn search_assets( limit, offset, }; - let results = state.catalog.search_assets.execute(query).await?; - let total = results.len(); - let assets = results + let result = state.catalog.search_assets.execute(query).await?; + let assets = result + .items .iter() .map(|a| AssetResponse::from_domain(a, &StructuredData::new())) .collect(); - Ok(Json(TimelineResponse { assets, total })) + Ok(Json(TimelineResponse { + assets, + total: result.total, + })) } +#[utoipa::path( + post, path = "/api/v1/assets/ingest", + security(("bearer_token" = [])), + request_body(content_type = "multipart/form-data"), + responses( + (status = 201, description = "Asset ingested", body = IngestResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn ingest( State(state): State, claims: JwtClaims, @@ -136,6 +168,15 @@ pub async fn ingest( )) } +#[utoipa::path( + get, path = "/api/v1/assets/{id}", + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Asset ID")), + responses( + (status = 200, description = "Asset details", body = AssetResponse), + (status = 404, description = "Not found") + ) +)] pub async fn get_asset( State(state): State, claims: JwtClaims, @@ -149,6 +190,18 @@ pub async fn get_asset( Ok(Json(AssetResponse::from_domain(&asset, &metadata))) } +#[utoipa::path( + get, path = "/api/v1/assets/timeline", + security(("bearer_token" = [])), + params( + ("limit" = Option, Query, description = "Page size"), + ("offset" = Option, Query, description = "Page offset") + ), + responses( + (status = 200, description = "Timeline view", body = TimelineResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn timeline( State(state): State, claims: JwtClaims, @@ -160,15 +213,28 @@ pub async fn timeline( 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 + let result = state.catalog.get_timeline.execute(query).await?; + let assets = result + .items .iter() .map(|(asset, meta)| AssetResponse::from_domain(asset, meta)) .collect(); - Ok(Json(TimelineResponse { assets, total })) + Ok(Json(TimelineResponse { + assets, + total: result.total, + })) } +#[utoipa::path( + put, path = "/api/v1/assets/{id}/metadata", + request_body = api_types::requests::UpdateMetadataRequest, + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Asset ID")), + responses( + (status = 200, description = "Metadata updated"), + (status = 404, description = "Not found") + ) +)] pub async fn update_metadata( State(state): State, claims: JwtClaims, @@ -189,6 +255,15 @@ pub async fn update_metadata( Ok(Json(serde_json::json!({ "status": "updated" }))) } +#[utoipa::path( + get, path = "/api/v1/assets/{id}/file", + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Asset ID")), + responses( + (status = 200, description = "File content", content_type = "application/octet-stream"), + (status = 404, description = "Not found") + ) +)] pub async fn serve_file( State(state): State, claims: JwtClaims, @@ -212,6 +287,16 @@ pub async fn serve_file( .map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string()))) } +#[utoipa::path( + post, path = "/api/v1/assets/{id}/tags", + request_body = TagAssetRequest, + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Asset ID")), + responses( + (status = 201, description = "Tag applied", body = TagResponse), + (status = 404, description = "Not found") + ) +)] pub async fn tag_asset( State(state): State, claims: JwtClaims, @@ -227,6 +312,15 @@ pub async fn tag_asset( Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag)))) } +#[utoipa::path( + delete, path = "/api/v1/assets/{id}", + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Asset ID")), + responses( + (status = 204, description = "Asset deleted"), + (status = 404, description = "Not found") + ) +)] pub async fn delete_asset( State(state): State, claims: JwtClaims, @@ -240,6 +334,18 @@ pub async fn delete_asset( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + get, path = "/api/v1/assets/{id}/derivatives/{profile}", + security(("bearer_token" = [])), + params( + ("id" = uuid::Uuid, Path, description = "Asset ID"), + ("profile" = String, Path, description = "Derivative profile") + ), + responses( + (status = 200, description = "Derivative content", content_type = "application/octet-stream"), + (status = 404, description = "Not found") + ) +)] pub async fn serve_derivative( State(state): State, claims: JwtClaims, @@ -262,6 +368,15 @@ pub async fn serve_derivative( .map_err(|e| AppError::from(DomainError::Internal(e.to_string()))) } +#[utoipa::path( + post, path = "/api/v1/assets/register", + request_body = RegisterAssetRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Asset registered", body = AssetResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn register_asset( State(state): State, claims: JwtClaims, @@ -283,3 +398,56 @@ pub async fn register_asset( Json(AssetResponse::from_domain(&asset, &StructuredData::new())), )) } + +#[utoipa::path( + post, path = "/api/v1/assets/bulk-delete", + request_body = api_types::requests::BulkDeleteRequest, + security(("bearer_token" = [])), + responses( + (status = 200, description = "Assets deleted"), + (status = 401, description = "Unauthorized") + ) +)] +pub async fn bulk_delete( + State(state): State, + claims: JwtClaims, + Json(req): Json, +) -> Result, AppError> { + let mut deleted = 0u32; + for id in req.asset_ids { + let cmd = DeleteAssetCommand { + asset_id: SystemId::from_uuid(id), + deleted_by: claims.user_id, + }; + state.catalog.delete_asset.execute(cmd).await?; + deleted += 1; + } + Ok(Json(serde_json::json!({ "deleted": deleted }))) +} + +#[utoipa::path( + post, path = "/api/v1/assets/bulk-tag", + request_body = api_types::requests::BulkTagRequest, + security(("bearer_token" = [])), + responses( + (status = 200, description = "Assets tagged"), + (status = 401, description = "Unauthorized") + ) +)] +pub async fn bulk_tag( + State(state): State, + claims: JwtClaims, + Json(req): Json, +) -> Result, AppError> { + let mut tagged = 0u32; + for id in req.asset_ids { + let cmd = application::organization::TagAssetCommand { + asset_id: SystemId::from_uuid(id), + tag_name: req.tag_name.clone(), + user_id: claims.user_id, + }; + state.organization.tag_asset.execute(cmd).await?; + tagged += 1; + } + Ok(Json(serde_json::json!({ "tagged": tagged }))) +} diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index 6789211..0e07700 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -92,6 +92,14 @@ pub async fn me( Ok(Json(UserResponse::from_domain(&user))) } +#[utoipa::path( + post, path = "/api/v1/auth/refresh", + request_body = RefreshTokenRequest, + responses( + (status = 200, description = "Token refreshed", body = AuthResponse), + (status = 401, description = "Invalid refresh token") + ) +)] pub async fn refresh( State(state): State, ValidatedJson(req): ValidatedJson, @@ -113,6 +121,14 @@ pub async fn refresh( })) } +#[utoipa::path( + post, path = "/api/v1/auth/logout", + security(("bearer_token" = [])), + responses( + (status = 204, description = "Logged out"), + (status = 401, description = "Unauthorized") + ) +)] pub async fn logout( State(state): State, claims: JwtClaims, diff --git a/crates/presentation/src/handlers/duplicates.rs b/crates/presentation/src/handlers/duplicates.rs index 1288f9c..27388e6 100644 --- a/crates/presentation/src/handlers/duplicates.rs +++ b/crates/presentation/src/handlers/duplicates.rs @@ -19,6 +19,18 @@ pub struct ListDuplicatesParams { pub offset: Option, } +#[utoipa::path( + get, path = "/api/v1/duplicates", + security(("bearer_token" = [])), + params( + ("limit" = Option, Query, description = "Page size"), + ("offset" = Option, Query, description = "Page offset") + ), + responses( + (status = 200, description = "Duplicate groups", body = Vec), + (status = 401, description = "Unauthorized") + ) +)] pub async fn list_duplicates( State(state): State, claims: JwtClaims, @@ -37,6 +49,16 @@ pub async fn list_duplicates( Ok(Json(resp)) } +#[utoipa::path( + post, path = "/api/v1/duplicates/{id}/resolve", + request_body = ResolveDuplicateRequest, + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Duplicate group ID")), + responses( + (status = 200, description = "Duplicate resolved"), + (status = 404, description = "Not found") + ) +)] pub async fn resolve_duplicate( State(state): State, claims: JwtClaims, diff --git a/crates/presentation/src/handlers/processing.rs b/crates/presentation/src/handlers/processing.rs index fe9b46a..bcbc3a7 100644 --- a/crates/presentation/src/handlers/processing.rs +++ b/crates/presentation/src/handlers/processing.rs @@ -40,6 +40,19 @@ pub struct ListJobsParams { pub offset: Option, } +#[utoipa::path( + get, path = "/api/v1/jobs", + security(("bearer_token" = [])), + params( + ("status" = Option, Query, description = "Status filter"), + ("limit" = Option, Query, description = "Page size"), + ("offset" = Option, Query, description = "Page offset") + ), + responses( + (status = 200, description = "Job list", body = JobListResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn list_jobs( State(state): State, claims: JwtClaims, @@ -59,6 +72,15 @@ pub async fn list_jobs( })) } +#[utoipa::path( + post, path = "/api/v1/jobs", + request_body = EnqueueJobRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Job enqueued", body = JobResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn enqueue_job( State(state): State, claims: JwtClaims, @@ -82,6 +104,15 @@ pub async fn enqueue_job( Ok((StatusCode::CREATED, Json(JobResponse::from_domain(&job)))) } +#[utoipa::path( + post, path = "/api/v1/jobs/{id}/start", + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Job ID")), + responses( + (status = 200, description = "Job started", body = JobResponse), + (status = 404, description = "Not found") + ) +)] pub async fn start_job( State(state): State, claims: JwtClaims, @@ -95,6 +126,16 @@ pub async fn start_job( Ok(Json(JobResponse::from_domain(&job))) } +#[utoipa::path( + post, path = "/api/v1/jobs/{id}/complete", + request_body = CompleteJobRequest, + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Job ID")), + responses( + (status = 200, description = "Job completed", body = JobResponse), + (status = 404, description = "Not found") + ) +)] pub async fn complete_job( State(state): State, claims: JwtClaims, @@ -110,6 +151,16 @@ pub async fn complete_job( Ok(Json(JobResponse::from_domain(&job))) } +#[utoipa::path( + post, path = "/api/v1/jobs/{id}/fail", + request_body = FailJobRequest, + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Job ID")), + responses( + (status = 200, description = "Job failed", body = JobResponse), + (status = 404, description = "Not found") + ) +)] pub async fn fail_job( State(state): State, claims: JwtClaims, @@ -125,6 +176,15 @@ pub async fn fail_job( Ok(Json(JobResponse::from_domain(&job))) } +#[utoipa::path( + get, path = "/api/v1/jobs/batches/{id}", + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Batch ID")), + responses( + (status = 200, description = "Batch progress", body = BatchProgressResponse), + (status = 404, description = "Not found") + ) +)] pub async fn batch_progress( State(state): State, claims: JwtClaims, @@ -138,6 +198,15 @@ pub async fn batch_progress( Ok(Json(BatchProgressResponse::from_domain(&progress))) } +#[utoipa::path( + post, path = "/api/v1/plugins", + request_body = ManagePluginRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Plugin managed", body = PluginResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn manage_plugin( State(state): State, claims: JwtClaims, @@ -182,6 +251,15 @@ pub async fn manage_plugin( )) } +#[utoipa::path( + post, path = "/api/v1/pipelines", + request_body = ConfigurePipelineRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Pipeline configured", body = PipelineResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn configure_pipeline( State(state): State, claims: JwtClaims, diff --git a/crates/presentation/src/handlers/sharing.rs b/crates/presentation/src/handlers/sharing.rs index 0b3889c..4e7a069 100644 --- a/crates/presentation/src/handlers/sharing.rs +++ b/crates/presentation/src/handlers/sharing.rs @@ -15,6 +15,15 @@ use domain::value_objects::{DateTimeStamp, SystemId}; const DEFAULT_ACCESS_LEVEL: &str = "view_only"; +#[utoipa::path( + post, path = "/api/v1/sharing", + request_body = ShareResourceRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Resource shared", body = ShareScopeResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn share_resource( State(state): State, claims: JwtClaims, @@ -38,6 +47,15 @@ pub async fn share_resource( )) } +#[utoipa::path( + post, path = "/api/v1/sharing/links", + request_body = GenerateShareLinkRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Share link generated", body = ShareLinkResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn generate_link( State(state): State, claims: JwtClaims, @@ -65,6 +83,15 @@ pub async fn generate_link( )) } +#[utoipa::path( + delete, path = "/api/v1/sharing/{id}", + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Share scope ID")), + responses( + (status = 204, description = "Share revoked"), + (status = 404, description = "Not found") + ) +)] pub async fn revoke( State(state): State, claims: JwtClaims, @@ -78,6 +105,14 @@ pub async fn revoke( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + get, path = "/api/v1/sharing/access/{token}", + params(("token" = String, Path, description = "Share token")), + responses( + (status = 200, description = "Shared resource", body = SharedResourceResponse), + (status = 404, description = "Invalid token") + ) +)] pub async fn access_by_token( State(state): State, Path((token,)): Path<(String,)>, diff --git a/crates/presentation/src/handlers/sidecar.rs b/crates/presentation/src/handlers/sidecar.rs index 7a6f836..fe58613 100644 --- a/crates/presentation/src/handlers/sidecar.rs +++ b/crates/presentation/src/handlers/sidecar.rs @@ -10,6 +10,15 @@ use axum::{ }; use domain::value_objects::SystemId; +#[utoipa::path( + post, path = "/api/v1/sidecar/export/{asset_id}", + security(("bearer_token" = [])), + params(("asset_id" = uuid::Uuid, Path, description = "Asset ID")), + responses( + (status = 200, description = "Sidecar exported", body = SidecarExportResponse), + (status = 404, description = "Not found") + ) +)] pub async fn export_sidecar( State(state): State, claims: JwtClaims, @@ -23,6 +32,14 @@ pub async fn export_sidecar( Ok(Json(SidecarExportResponse::from_domain(&record))) } +#[utoipa::path( + post, path = "/api/v1/sidecar/detect-changes", + security(("bearer_token" = [])), + responses( + (status = 200, description = "Changes detected", body = DetectChangesResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn detect_changes( State(state): State, claims: JwtClaims, @@ -38,6 +55,15 @@ pub async fn detect_changes( })) } +#[utoipa::path( + post, path = "/api/v1/sidecar/import/{asset_id}", + security(("bearer_token" = [])), + params(("asset_id" = uuid::Uuid, Path, description = "Asset ID")), + responses( + (status = 200, description = "Sidecar imported", body = SidecarImportResponse), + (status = 404, description = "Not found") + ) +)] pub async fn import_sidecar( State(state): State, claims: JwtClaims, @@ -54,6 +80,16 @@ pub async fn import_sidecar( })) } +#[utoipa::path( + post, path = "/api/v1/sidecar/resolve/{asset_id}", + request_body = api_types::requests::ResolveConflictRequest, + security(("bearer_token" = [])), + params(("asset_id" = uuid::Uuid, Path, description = "Asset ID")), + responses( + (status = 200, description = "Conflict resolved", body = SidecarExportResponse), + (status = 404, description = "Not found") + ) +)] pub async fn resolve_conflict( State(state): State, claims: JwtClaims, @@ -70,6 +106,14 @@ pub async fn resolve_conflict( Ok(Json(SidecarExportResponse::from_domain(&record))) } +#[utoipa::path( + post, path = "/api/v1/sidecar/full-export", + security(("bearer_token" = [])), + responses( + (status = 200, description = "Full export completed", body = DetectChangesResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn full_export( State(state): State, claims: JwtClaims, @@ -83,6 +127,14 @@ pub async fn full_export( })) } +#[utoipa::path( + post, path = "/api/v1/sidecar/full-import", + security(("bearer_token" = [])), + responses( + (status = 200, description = "Full import completed", body = DetectChangesResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn full_import( State(state): State, claims: JwtClaims, diff --git a/crates/presentation/src/handlers/stacks.rs b/crates/presentation/src/handlers/stacks.rs index 974215d..3615fbd 100644 --- a/crates/presentation/src/handlers/stacks.rs +++ b/crates/presentation/src/handlers/stacks.rs @@ -1,7 +1,7 @@ use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState}; use api_types::{requests::CreateStackRequest, responses::StackResponse}; use application::catalog::{ - CreateStackCommand, DeleteStackCommand, DetectLivePhotosCommand, GetStackQuery, + CreateStackCommand, DeleteStackCommand, DetectLivePhotosCommand, GetStackQuery, ListStacksQuery, }; use axum::{ Json, @@ -10,6 +10,35 @@ use axum::{ }; use domain::value_objects::SystemId; +#[utoipa::path( + get, path = "/api/v1/stacks", + security(("bearer_token" = [])), + responses( + (status = 200, description = "List of stacks", body = Vec), + (status = 401, description = "Unauthorized") + ) +)] +pub async fn list_stacks( + State(state): State, + claims: JwtClaims, +) -> Result>, AppError> { + let query = ListStacksQuery { + owner_id: claims.user_id, + }; + let stacks = state.catalog.list_stacks.execute(query).await?; + let resp = stacks.iter().map(StackResponse::from_domain).collect(); + Ok(Json(resp)) +} + +#[utoipa::path( + post, path = "/api/v1/stacks", + request_body = CreateStackRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Stack created", body = StackResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn create_stack( State(state): State, claims: JwtClaims, @@ -38,6 +67,15 @@ pub async fn create_stack( )) } +#[utoipa::path( + get, path = "/api/v1/stacks/{id}", + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Stack ID")), + responses( + (status = 200, description = "Stack details", body = StackResponse), + (status = 404, description = "Not found") + ) +)] pub async fn get_stack( State(state): State, claims: JwtClaims, @@ -51,6 +89,15 @@ pub async fn get_stack( Ok(Json(StackResponse::from_domain(&stack))) } +#[utoipa::path( + delete, path = "/api/v1/stacks/{id}", + security(("bearer_token" = [])), + params(("id" = uuid::Uuid, Path, description = "Stack ID")), + responses( + (status = 204, description = "Stack deleted"), + (status = 404, description = "Not found") + ) +)] pub async fn delete_stack( State(state): State, claims: JwtClaims, @@ -64,6 +111,14 @@ pub async fn delete_stack( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, path = "/api/v1/stacks/detect-live-photos", + security(("bearer_token" = [])), + responses( + (status = 200, description = "Detected live photo stacks", body = Vec), + (status = 401, description = "Unauthorized") + ) +)] pub async fn detect_live_photos( State(state): State, claims: JwtClaims, diff --git a/crates/presentation/src/handlers/storage.rs b/crates/presentation/src/handlers/storage.rs index eba34ec..21ca677 100644 --- a/crates/presentation/src/handlers/storage.rs +++ b/crates/presentation/src/handlers/storage.rs @@ -11,6 +11,15 @@ use axum::{ }; use domain::value_objects::SystemId; +#[utoipa::path( + post, path = "/api/v1/storage/volumes", + request_body = RegisterVolumeRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Volume registered", body = VolumeResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn register_volume( State(state): State, claims: JwtClaims, @@ -29,6 +38,15 @@ pub async fn register_volume( )) } +#[utoipa::path( + post, path = "/api/v1/storage/library-paths", + request_body = RegisterLibraryPathRequest, + security(("bearer_token" = [])), + responses( + (status = 201, description = "Library path registered", body = LibraryPathResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn register_library_path( State(state): State, claims: JwtClaims, @@ -51,6 +69,18 @@ pub async fn register_library_path( const DEFAULT_QUOTA_USAGE_TYPE: &str = "storage_bytes"; const DEFAULT_QUOTA_AMOUNT: u64 = 0; +#[utoipa::path( + get, path = "/api/v1/storage/quota", + security(("bearer_token" = [])), + params( + ("usage_type" = Option, Query, description = "Usage type"), + ("amount" = Option, Query, description = "Requested amount") + ), + responses( + (status = 200, description = "Quota check result", body = QuotaCheckResponse), + (status = 401, description = "Unauthorized") + ) +)] pub async fn check_quota( State(state): State, claims: JwtClaims, diff --git a/crates/presentation/src/openapi/mod.rs b/crates/presentation/src/openapi/mod.rs index 0183eb7..44b60e9 100644 --- a/crates/presentation/src/openapi/mod.rs +++ b/crates/presentation/src/openapi/mod.rs @@ -13,15 +13,111 @@ use utoipa_scalar::{Scalar, Servable}; crate::handlers::auth::register, crate::handlers::auth::login, crate::handlers::auth::me, + crate::handlers::auth::refresh, + crate::handlers::auth::logout, + crate::handlers::albums::list_albums, + crate::handlers::albums::create_album, + crate::handlers::albums::get_album, + crate::handlers::albums::update_album, + crate::handlers::albums::add_entry, + crate::handlers::albums::remove_entry, + crate::handlers::assets::search_assets, + crate::handlers::assets::ingest, + crate::handlers::assets::timeline, + crate::handlers::assets::get_asset, + crate::handlers::assets::update_metadata, + crate::handlers::assets::serve_file, + crate::handlers::assets::serve_derivative, + crate::handlers::assets::tag_asset, + crate::handlers::assets::delete_asset, + crate::handlers::assets::register_asset, + crate::handlers::assets::bulk_delete, + crate::handlers::assets::bulk_tag, + crate::handlers::stacks::list_stacks, + crate::handlers::stacks::create_stack, + crate::handlers::stacks::get_stack, + crate::handlers::stacks::delete_stack, + crate::handlers::stacks::detect_live_photos, + crate::handlers::duplicates::list_duplicates, + crate::handlers::duplicates::resolve_duplicate, + crate::handlers::sharing::share_resource, + crate::handlers::sharing::generate_link, + crate::handlers::sharing::revoke, + crate::handlers::sharing::access_by_token, + crate::handlers::storage::register_volume, + crate::handlers::storage::register_library_path, + crate::handlers::storage::check_quota, + crate::handlers::processing::list_jobs, + crate::handlers::processing::enqueue_job, + crate::handlers::processing::start_job, + crate::handlers::processing::complete_job, + crate::handlers::processing::fail_job, + crate::handlers::processing::batch_progress, + crate::handlers::processing::manage_plugin, + crate::handlers::processing::configure_pipeline, + crate::handlers::sidecar::export_sidecar, + crate::handlers::sidecar::detect_changes, + crate::handlers::sidecar::import_sidecar, + crate::handlers::sidecar::resolve_conflict, + crate::handlers::sidecar::full_export, + crate::handlers::sidecar::full_import, ), components(schemas( api_types::requests::RegisterRequest, api_types::requests::LoginRequest, + api_types::requests::RefreshTokenRequest, + api_types::requests::CreateAlbumRequest, + api_types::requests::UpdateAlbumRequest, + api_types::requests::AlbumEntryRequest, + api_types::requests::RegisterAssetRequest, + api_types::requests::UpdateMetadataRequest, + api_types::requests::TagAssetRequest, + api_types::requests::BulkDeleteRequest, + api_types::requests::BulkTagRequest, + api_types::requests::CreateStackRequest, + api_types::requests::StackMemberRequest, + api_types::requests::ResolveDuplicateRequest, + api_types::requests::ShareResourceRequest, + api_types::requests::GenerateShareLinkRequest, + api_types::requests::RegisterVolumeRequest, + api_types::requests::RegisterLibraryPathRequest, + api_types::requests::CheckQuotaParams, + api_types::requests::EnqueueJobRequest, + api_types::requests::CompleteJobRequest, + api_types::requests::FailJobRequest, + api_types::requests::ManagePluginRequest, + api_types::requests::ConfigurePipelineRequest, + api_types::requests::PipelineStepRequest, api_types::responses::AuthResponse, api_types::responses::UserResponse, + api_types::responses::AlbumResponse, + api_types::responses::AssetResponse, + api_types::responses::TimelineResponse, + api_types::responses::IngestResponse, + api_types::responses::TagResponse, + api_types::responses::StackResponse, + api_types::responses::StackMemberResponse, + api_types::responses::DuplicateGroupResponse, + api_types::responses::DuplicateCandidateResponse, + api_types::responses::ShareScopeResponse, + api_types::responses::ShareLinkResponse, + api_types::responses::SharedResourceResponse, + api_types::responses::VolumeResponse, + api_types::responses::LibraryPathResponse, + api_types::responses::QuotaCheckResponse, + api_types::responses::JobResponse, + api_types::responses::JobListResponse, + api_types::responses::BatchProgressResponse, + api_types::responses::PluginResponse, + api_types::responses::PipelineResponse, + api_types::responses::SidecarExportResponse, + api_types::responses::DetectChangesResponse, + api_types::responses::SidecarImportResponse, + api_types::requests::ResolveConflictRequest, )), modifiers(&SecurityAddon), - info(title = "k-template", version = "0.1.0") + info(title = "K-Photos API", version = "0.1.0", + description = "Self-hosted photo management API") )] pub struct ApiDoc; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index f6d0c11..a2f23ef 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -26,8 +26,14 @@ fn protected_routes(state: &AppState) -> Router { .route("/auth/me", get(auth::me)) .route("/auth/logout", post(auth::logout)) // albums - .route("/albums", post(albums::create_album)) - .route("/albums/{id}", get(albums::get_album)) + .route( + "/albums", + get(albums::list_albums).post(albums::create_album), + ) + .route( + "/albums/{id}", + get(albums::get_album).put(albums::update_album), + ) .route("/albums/{id}/entries", post(albums::add_entry)) .route( "/albums/{id}/entries/{asset_id}", @@ -49,8 +55,13 @@ fn protected_routes(state: &AppState) -> Router { get(assets::serve_derivative), ) .route("/assets/{id}/tags", post(assets::tag_asset)) + .route("/assets/bulk-delete", post(assets::bulk_delete)) + .route("/assets/bulk-tag", post(assets::bulk_tag)) // stacks - .route("/stacks", post(stacks::create_stack)) + .route( + "/stacks", + get(stacks::list_stacks).post(stacks::create_stack), + ) .route( "/stacks/detect-live-photos", post(stacks::detect_live_photos), diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index c1761c9..0f56b37 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -4,15 +4,16 @@ use application::{ catalog::{ CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler, GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler, - ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler, - SearchAssetsHandler, UpdateMetadataHandler, + ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, + ResolveDuplicateHandler, SearchAssetsHandler, UpdateMetadataHandler, }, identity::{ GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler, RegisterUserHandler, }, organization::{ - CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler, TagAssetHandler, + CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler, + TagAssetHandler, UpdateAlbumHandler, }, processing::{ CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler, @@ -59,13 +60,16 @@ pub struct CatalogHandlers { pub get_stack: Arc, pub delete_stack: Arc, pub detect_live_photos: Arc, + pub list_stacks: Arc, } #[derive(Clone)] pub struct OrganizationHandlers { pub create_album: Arc, pub get_album: Arc, + pub list_albums: Arc, pub manage_album_entries: Arc, + pub update_album: Arc, pub tag_asset: Arc, } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index f5573d3..12787a2 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -8,7 +8,7 @@ use tracing::{error, info, warn}; use application::processing::{EnqueueJobCommand, ProcessNextJobCommand}; use domain::entities::JobType; use domain::events::DomainEvent; -use domain::ports::EventConsumer; +use domain::ports::{EventConsumer, JobRepository}; use domain::value_objects::StructuredData; mod config; @@ -70,6 +70,7 @@ async fn main() -> anyhow::Result<()> { registry, event_pub.clone(), )); + let job_repo: Arc = repos.job.clone(); let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub)); // ── Shutdown signal ─────────────────────────────────────────────── @@ -180,6 +181,27 @@ async fn main() -> anyhow::Result<()> { error!(error = %e, "event loop: failed to enqueue SyncSidecar"); } } + DomainEvent::JobCompleted { job_id, .. } => { + info!(job_id = %job_id, "event loop: JobCompleted → check derivative generation"); + (envelope.ack)(); + // Look up the job to see if it was ExtractMetadata + if let Ok(Some(job)) = job_repo.find_by_id(job_id).await + && job.job_type == JobType::ExtractMetadata + && let Some(asset_id) = job.target_asset_id + { + info!(asset_id = %asset_id, "event loop: ExtractMetadata done → enqueue GenerateDerivative"); + let cmd = EnqueueJobCommand { + job_type: JobType::GenerateDerivative, + priority: 5, + payload: StructuredData::new(), + target_asset_id: Some(asset_id), + batch_id: None, + }; + if let Err(e) = enqueue.execute(cmd).await { + error!(error = %e, "event loop: failed to enqueue GenerateDerivative"); + } + } + } DomainEvent::JobEnqueued { job_id, job_type, .. } => {