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); 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] #[async_trait]
impl AssetRepository for PostgresAssetRepository { impl AssetRepository for PostgresAssetRepository {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> { async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> {
@@ -134,41 +212,66 @@ impl AssetRepository for PostgresAssetRepository {
limit: u32, limit: u32,
offset: u32, offset: u32,
) -> Result<Vec<Asset>, DomainError> { ) -> Result<Vec<Asset>, DomainError> {
let mut sql = String::from( let (where_clause, has_tag) = build_search_where(filters);
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type, let mut sql = format!(
file_size, is_processed, owner_user_id, created_at "SELECT a.asset_id, a.volume_id, a.relative_path, a.checksum, a.asset_type, a.mime_type,
FROM assets WHERE owner_user_id = $1", 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!( sql.push_str(&format!(
" ORDER BY created_at DESC LIMIT ${} OFFSET ${}", " ORDER BY a.created_at DESC LIMIT ${} OFFSET ${}",
param_idx, param_count + 2,
param_idx + 1 param_count + 3
)); ));
let mut query = sqlx::query_as::<_, AssetRow>(&sql).bind(*owner_id.as_uuid()); 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 { if let Some(ref t) = filters.asset_type {
query = query.bind(asset_type_to_str(t)); query = query.bind(asset_type_to_str(t));
@@ -185,15 +288,12 @@ impl AssetRepository for PostgresAssetRepository {
if let Some(p) = filters.is_processed { if let Some(p) = filters.is_processed {
query = query.bind(p); query = query.bind(p);
} }
if let Some(ref tag) = filters.tag_name {
query = query.bind(tag.as_str());
}
let rows = query let (count,) = query.fetch_one(&self.pool).await.map_pg()?;
.bind(limit as i64) Ok(count as u64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter().map(TryInto::try_into).collect()
} }
async fn save(&self, asset: &Asset) -> Result<(), DomainError> { async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
@@ -805,6 +905,19 @@ impl AssetStackRepository for PostgresAssetStackRepository {
Ok(row.map(Into::into)) 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> { async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
let rows = sqlx::query_as::<_, StackRow>( let rows = sqlx::query_as::<_, StackRow>(
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members "SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members

View File

@@ -91,6 +91,34 @@ pub struct RegisterAssetRequest {
pub file_size: u64, pub file_size: u64,
} }
// --- Bulk ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct BulkDeleteRequest {
pub asset_ids: Vec<uuid::Uuid>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct BulkTagRequest {
pub asset_ids: Vec<uuid::Uuid>,
pub tag_name: String,
}
// --- Album update ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateAlbumRequest {
pub title: Option<String>,
pub description: Option<String>,
}
// --- Stack reorder ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct ReorderStackRequest {
pub member_order: Vec<uuid::Uuid>,
}
// --- Stacks --- // --- Stacks ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] #[derive(Debug, serde::Deserialize, utoipa::ToSchema)]

View File

@@ -87,7 +87,7 @@ impl AssetResponse {
#[derive(Debug, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct TimelineResponse { pub struct TimelineResponse {
pub assets: Vec<AssetResponse>, pub assets: Vec<AssetResponse>,
pub total: usize, pub total: u64,
} }
#[derive(Debug, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, serde::Serialize, utoipa::ToSchema)]

View File

@@ -14,10 +14,11 @@ pub use commands::resolve_duplicate::{
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler}; pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
pub use queries::get_asset::{GetAssetHandler, GetAssetQuery}; pub use queries::get_asset::{GetAssetHandler, GetAssetQuery};
pub use queries::get_stack::{GetStackHandler, GetStackQuery}; 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_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery};
pub use queries::read_derivative::{ pub use queries::read_derivative::{
DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery, DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery,
}; };
pub use queries::search_assets::{SearchAssetsHandler, SearchAssetsQuery}; pub use queries::search_assets::{SearchAssetsHandler, SearchAssetsQuery, SearchResult};
pub use visibility::VisibilityFilteredAssetRepository; pub use visibility::VisibilityFilteredAssetRepository;

View File

@@ -16,6 +16,11 @@ pub struct GetTimelineQuery {
pub offset: u32, pub offset: u32,
} }
pub struct TimelineResult {
pub items: Vec<(Asset, StructuredData)>,
pub total: u64,
}
pub struct GetTimelineHandler { pub struct GetTimelineHandler {
asset_repo: Arc<dyn AssetRepository>, asset_repo: Arc<dyn AssetRepository>,
metadata_repo: Arc<dyn AssetMetadataRepository>, metadata_repo: Arc<dyn AssetMetadataRepository>,
@@ -51,13 +56,11 @@ impl GetTimelineHandler {
} }
} }
pub async fn execute( pub async fn execute(&self, query: GetTimelineQuery) -> Result<TimelineResult, DomainError> {
&self,
query: GetTimelineQuery,
) -> Result<Vec<(Asset, StructuredData)>, DomainError> {
let caller_id = query.caller_id.unwrap_or(query.owner_id); let caller_id = query.caller_id.unwrap_or(query.owner_id);
let repo = self.effective_repo(caller_id); let repo = self.effective_repo(caller_id);
let total = repo.count_by_owner(&query.owner_id).await?;
let assets = repo let assets = repo
.find_by_owner(&query.owner_id, query.limit, query.offset) .find_by_owner(&query.owner_id, query.limit, query.offset)
.await?; .await?;
@@ -65,7 +68,7 @@ impl GetTimelineHandler {
let asset_ids: Vec<SystemId> = assets.iter().map(|a| a.asset_id).collect(); let asset_ids: Vec<SystemId> = assets.iter().map(|a| a.asset_id).collect();
let all_layers = self.metadata_repo.find_by_assets(&asset_ids).await?; let all_layers = self.metadata_repo.find_by_assets(&asset_ids).await?;
let results = assets let items = assets
.into_iter() .into_iter()
.map(|asset| { .map(|asset| {
let layers: Vec<_> = all_layers let layers: Vec<_> = all_layers
@@ -78,6 +81,6 @@ impl GetTimelineHandler {
}) })
.collect(); .collect();
Ok(results) Ok(TimelineResult { items, total })
} }
} }

View File

@@ -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<dyn AssetStackRepository>,
}
impl ListStacksHandler {
pub fn new(stack_repo: Arc<dyn AssetStackRepository>) -> Self {
Self { stack_repo }
}
pub async fn execute(&self, query: ListStacksQuery) -> Result<Vec<AssetStack>, DomainError> {
self.stack_repo.find_by_owner(&query.owner_id).await
}
}

View File

@@ -1,6 +1,7 @@
pub mod get_asset; pub mod get_asset;
pub mod get_stack; pub mod get_stack;
pub mod get_timeline; pub mod get_timeline;
pub mod list_stacks;
pub mod read_asset_file; pub mod read_asset_file;
pub mod read_derivative; pub mod read_derivative;
pub mod search_assets; pub mod search_assets;

View File

@@ -14,6 +14,11 @@ pub struct SearchAssetsQuery {
pub offset: u32, pub offset: u32,
} }
pub struct SearchResult {
pub items: Vec<Asset>,
pub total: u64,
}
pub struct SearchAssetsHandler { pub struct SearchAssetsHandler {
asset_repo: Arc<dyn AssetRepository>, asset_repo: Arc<dyn AssetRepository>,
} }
@@ -23,9 +28,15 @@ impl SearchAssetsHandler {
Self { asset_repo } Self { asset_repo }
} }
pub async fn execute(&self, query: SearchAssetsQuery) -> Result<Vec<Asset>, DomainError> { pub async fn execute(&self, query: SearchAssetsQuery) -> Result<SearchResult, DomainError> {
self.asset_repo 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) .search(&query.owner_id, &query.filters, query.limit, query.offset)
.await .await?;
Ok(SearchResult { items, total })
} }
} }

View File

@@ -122,6 +122,18 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
self.filter_visible(assets).await self.filter_visible(assets).await
} }
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
self.inner.count_by_owner(owner_id).await
}
async fn count_search(
&self,
owner_id: &SystemId,
filters: &AssetFilters,
) -> Result<u64, DomainError> {
self.inner.count_search(owner_id, filters).await
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> { async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
self.inner.save(asset).await self.inner.save(asset).await
} }

View File

@@ -1,7 +1,9 @@
pub mod create_album; pub mod create_album;
pub mod manage_album_entries; pub mod manage_album_entries;
pub mod tag_asset; pub mod tag_asset;
pub mod update_album;
pub use create_album::{CreateAlbumCommand, CreateAlbumHandler}; pub use create_album::{CreateAlbumCommand, CreateAlbumHandler};
pub use manage_album_entries::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler}; pub use manage_album_entries::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler};
pub use tag_asset::{TagAssetCommand, TagAssetHandler}; pub use tag_asset::{TagAssetCommand, TagAssetHandler};
pub use update_album::{UpdateAlbumCommand, UpdateAlbumHandler};

View File

@@ -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<String>,
pub description: Option<String>,
}
pub struct UpdateAlbumHandler {
album_repo: Arc<dyn AlbumRepository>,
}
impl UpdateAlbumHandler {
pub fn new(album_repo: Arc<dyn AlbumRepository>) -> Self {
Self { album_repo }
}
pub async fn execute(
&self,
cmd: UpdateAlbumCommand,
) -> Result<domain::entities::Album, DomainError> {
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)
}
}

View File

@@ -4,4 +4,6 @@ pub mod queries;
pub use commands::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler}; pub use commands::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler};
pub use commands::{CreateAlbumCommand, CreateAlbumHandler}; pub use commands::{CreateAlbumCommand, CreateAlbumHandler};
pub use commands::{TagAssetCommand, TagAssetHandler}; pub use commands::{TagAssetCommand, TagAssetHandler};
pub use commands::{UpdateAlbumCommand, UpdateAlbumHandler};
pub use queries::get_album::{GetAlbumHandler, GetAlbumQuery}; pub use queries::get_album::{GetAlbumHandler, GetAlbumQuery};
pub use queries::list_albums::{ListAlbumsHandler, ListAlbumsQuery};

View File

@@ -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<dyn AlbumRepository>,
}
impl ListAlbumsHandler {
pub fn new(album_repo: Arc<dyn AlbumRepository>) -> Self {
Self { album_repo }
}
pub async fn execute(&self, query: ListAlbumsQuery) -> Result<Vec<Album>, DomainError> {
self.album_repo.find_by_creator(&query.user_id).await
}
}

View File

@@ -1 +1,2 @@
pub mod get_album; pub mod get_album;
pub mod list_albums;

View File

@@ -145,6 +145,16 @@ impl AssetRepository for InMemoryAssetRepository {
.collect()) .collect())
} }
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
Ok(self
.data
.lock()
.await
.values()
.filter(|a| &a.owner_user_id == owner_id)
.count() as u64)
}
async fn search( async fn search(
&self, &self,
owner_id: &SystemId, owner_id: &SystemId,
@@ -155,6 +165,14 @@ impl AssetRepository for InMemoryAssetRepository {
self.find_by_owner(owner_id, limit, offset).await self.find_by_owner(owner_id, limit, offset).await
} }
async fn count_search(
&self,
owner_id: &SystemId,
_filters: &AssetFilters,
) -> Result<u64, DomainError> {
self.count_by_owner(owner_id).await
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> { async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
self.data self.data
.lock() .lock()

View File

@@ -28,7 +28,7 @@ async fn returns_paginated_assets() {
let handler = GetTimelineHandler::new(asset_repo, meta_repo); let handler = GetTimelineHandler::new(asset_repo, meta_repo);
let page = handler let result = handler
.execute(GetTimelineQuery { .execute(GetTimelineQuery {
owner_id: owner, owner_id: owner,
caller_id: None, caller_id: None,
@@ -38,7 +38,8 @@ async fn returns_paginated_assets() {
.await .await
.unwrap(); .unwrap();
assert_eq!(page.len(), 3); assert_eq!(result.items.len(), 3);
assert_eq!(result.total, 5);
} }
#[tokio::test] #[tokio::test]
@@ -48,7 +49,7 @@ async fn returns_empty_for_no_assets() {
let handler = GetTimelineHandler::new(asset_repo, meta_repo); let handler = GetTimelineHandler::new(asset_repo, meta_repo);
let page = handler let result = handler
.execute(GetTimelineQuery { .execute(GetTimelineQuery {
owner_id: SystemId::new(), owner_id: SystemId::new(),
caller_id: None, caller_id: None,
@@ -58,5 +59,6 @@ async fn returns_empty_for_no_assets() {
.await .await
.unwrap(); .unwrap();
assert!(page.is_empty()); assert!(result.items.is_empty());
assert_eq!(result.total, 0);
} }

View File

@@ -88,6 +88,9 @@ pub fn build(
)); ));
let get_stack = Arc::new(GetStackHandler::new(stack_repo.clone())); let get_stack = Arc::new(GetStackHandler::new(stack_repo.clone()));
let delete_stack = Arc::new(DeleteStackHandler::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 detect_live_photos = Arc::new(DetectLivePhotosHandler::new(asset_repo.clone(), stack_repo));
let register_asset = Arc::new(RegisterAssetHandler::new( let register_asset = Arc::new(RegisterAssetHandler::new(
@@ -112,5 +115,6 @@ pub fn build(
get_stack, get_stack,
delete_stack, delete_stack,
detect_live_photos, detect_live_photos,
list_stacks,
} }
} }

View File

@@ -4,7 +4,8 @@ use adapters_postgres::{
PgPool, PostgresAlbumRepository, PostgresAssetRepository, PostgresTagRepository, PgPool, PostgresAlbumRepository, PostgresAssetRepository, PostgresTagRepository,
}; };
use application::organization::{ use application::organization::{
CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler, TagAssetHandler, CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler,
TagAssetHandler, UpdateAlbumHandler,
}; };
use presentation::state::OrganizationHandlers; 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 create_album = Arc::new(CreateAlbumHandler::new(album_repo.clone()));
let get_album = Arc::new(GetAlbumHandler::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 manage_album_entries = Arc::new(ManageAlbumEntriesHandler::new(album_repo));
let tag_asset = Arc::new(TagAssetHandler::new(asset_repo, tag_repo)); let tag_asset = Arc::new(TagAssetHandler::new(asset_repo, tag_repo));
OrganizationHandlers { OrganizationHandlers {
create_album, create_album,
get_album, get_album,
list_albums,
manage_album_entries, manage_album_entries,
update_album,
tag_asset, tag_asset,
} }
} }

View File

@@ -63,6 +63,7 @@ pub struct AssetFilters {
pub date_from: Option<DateTimeStamp>, pub date_from: Option<DateTimeStamp>,
pub date_to: Option<DateTimeStamp>, pub date_to: Option<DateTimeStamp>,
pub is_processed: Option<bool>, pub is_processed: Option<bool>,
pub tag_name: Option<String>,
} }
// --- AssetMetadata --- // --- AssetMetadata ---

View File

@@ -19,6 +19,7 @@ pub trait AssetRepository: Send + Sync {
limit: u32, limit: u32,
offset: u32, offset: u32,
) -> Result<Vec<Asset>, DomainError>; ) -> Result<Vec<Asset>, DomainError>;
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError>;
async fn search( async fn search(
&self, &self,
owner_id: &SystemId, owner_id: &SystemId,
@@ -26,6 +27,11 @@ pub trait AssetRepository: Send + Sync {
limit: u32, limit: u32,
offset: u32, offset: u32,
) -> Result<Vec<Asset>, DomainError>; ) -> Result<Vec<Asset>, DomainError>;
async fn count_search(
&self,
owner_id: &SystemId,
filters: &AssetFilters,
) -> Result<u64, DomainError>;
async fn save(&self, asset: &Asset) -> Result<(), DomainError>; async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
} }
@@ -57,6 +63,7 @@ pub trait AssetMetadataRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait AssetStackRepository: Send + Sync { pub trait AssetStackRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError>; async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError>;
async fn find_by_owner(&self, owner_id: &SystemId) -> Result<Vec<AssetStack>, DomainError>;
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError>; async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError>;
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>; async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;

View File

@@ -1,10 +1,12 @@
use crate::{errors::AppError, extractors::JwtClaims, state::AppState}; use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
use api_types::requests::UpdateAlbumRequest;
use api_types::{ use api_types::{
requests::{AlbumEntryRequest, CreateAlbumRequest}, requests::{AlbumEntryRequest, CreateAlbumRequest},
responses::AlbumResponse, responses::AlbumResponse,
}; };
use application::organization::{ use application::organization::{
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ManageAlbumEntriesCommand, AlbumAction, CreateAlbumCommand, GetAlbumQuery, ListAlbumsQuery, ManageAlbumEntriesCommand,
UpdateAlbumCommand,
}; };
use axum::{ use axum::{
Json, Json,
@@ -13,6 +15,35 @@ use axum::{
}; };
use domain::value_objects::SystemId; use domain::value_objects::SystemId;
#[utoipa::path(
get, path = "/api/v1/albums",
security(("bearer_token" = [])),
responses(
(status = 200, description = "List of albums", body = Vec<AlbumResponse>),
(status = 401, description = "Unauthorized")
)
)]
pub async fn list_albums(
State(state): State<AppState>,
claims: JwtClaims,
) -> Result<Json<Vec<AlbumResponse>>, 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( pub async fn create_album(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn get_album(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -42,6 +82,42 @@ pub async fn get_album(
Ok(Json(AlbumResponse::from_domain(&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<AppState>,
claims: JwtClaims,
Path((album_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<UpdateAlbumRequest>,
) -> Result<Json<AlbumResponse>, 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( pub async fn add_entry(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn remove_entry(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,

View File

@@ -44,10 +44,29 @@ pub struct SearchParams {
pub date_from: Option<String>, pub date_from: Option<String>,
pub date_to: Option<String>, pub date_to: Option<String>,
pub is_processed: Option<bool>, pub is_processed: Option<bool>,
pub tag: Option<String>,
pub limit: Option<u32>, pub limit: Option<u32>,
pub offset: Option<u32>, pub offset: Option<u32>,
} }
#[utoipa::path(
get, path = "/api/v1/assets",
security(("bearer_token" = [])),
params(
("type" = Option<String>, Query, description = "Asset type filter"),
("mime_type" = Option<String>, Query, description = "MIME type filter"),
("date_from" = Option<String>, Query, description = "Start date (YYYY-MM-DD)"),
("date_to" = Option<String>, Query, description = "End date (YYYY-MM-DD)"),
("is_processed" = Option<bool>, Query, description = "Processed filter"),
("tag" = Option<String>, Query, description = "Tag name filter"),
("limit" = Option<u32>, Query, description = "Page size"),
("offset" = Option<u32>, Query, description = "Page offset")
),
responses(
(status = 200, description = "Search results", body = TimelineResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn search_assets( pub async fn search_assets(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -89,6 +108,7 @@ pub async fn search_assets(
date_from, date_from,
date_to, date_to,
is_processed: params.is_processed, is_processed: params.is_processed,
tag_name: params.tag,
}; };
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE); let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
@@ -100,15 +120,27 @@ pub async fn search_assets(
limit, limit,
offset, offset,
}; };
let results = state.catalog.search_assets.execute(query).await?; let result = state.catalog.search_assets.execute(query).await?;
let total = results.len(); let assets = result
let assets = results .items
.iter() .iter()
.map(|a| AssetResponse::from_domain(a, &StructuredData::new())) .map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
.collect(); .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( pub async fn ingest(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn get_asset(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -149,6 +190,18 @@ pub async fn get_asset(
Ok(Json(AssetResponse::from_domain(&asset, &metadata))) Ok(Json(AssetResponse::from_domain(&asset, &metadata)))
} }
#[utoipa::path(
get, path = "/api/v1/assets/timeline",
security(("bearer_token" = [])),
params(
("limit" = Option<u32>, Query, description = "Page size"),
("offset" = Option<u32>, Query, description = "Page offset")
),
responses(
(status = 200, description = "Timeline view", body = TimelineResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn timeline( pub async fn timeline(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -160,15 +213,28 @@ pub async fn timeline(
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE), limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
offset: params.offset.unwrap_or(0), offset: params.offset.unwrap_or(0),
}; };
let results = state.catalog.get_timeline.execute(query).await?; let result = state.catalog.get_timeline.execute(query).await?;
let total = results.len(); let assets = result
let assets = results .items
.iter() .iter()
.map(|(asset, meta)| AssetResponse::from_domain(asset, meta)) .map(|(asset, meta)| AssetResponse::from_domain(asset, meta))
.collect(); .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( pub async fn update_metadata(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -189,6 +255,15 @@ pub async fn update_metadata(
Ok(Json(serde_json::json!({ "status": "updated" }))) 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( pub async fn serve_file(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -212,6 +287,16 @@ pub async fn serve_file(
.map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string()))) .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( pub async fn tag_asset(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -227,6 +312,15 @@ pub async fn tag_asset(
Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag)))) 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( pub async fn delete_asset(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -240,6 +334,18 @@ pub async fn delete_asset(
Ok(StatusCode::NO_CONTENT) 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( pub async fn serve_derivative(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -262,6 +368,15 @@ pub async fn serve_derivative(
.map_err(|e| AppError::from(DomainError::Internal(e.to_string()))) .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( pub async fn register_asset(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -283,3 +398,56 @@ pub async fn register_asset(
Json(AssetResponse::from_domain(&asset, &StructuredData::new())), 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<AppState>,
claims: JwtClaims,
Json(req): Json<api_types::requests::BulkDeleteRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
claims: JwtClaims,
Json(req): Json<api_types::requests::BulkTagRequest>,
) -> Result<Json<serde_json::Value>, 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 })))
}

View File

@@ -92,6 +92,14 @@ pub async fn me(
Ok(Json(UserResponse::from_domain(&user))) 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( pub async fn refresh(
State(state): State<AppState>, State(state): State<AppState>,
ValidatedJson(req): ValidatedJson<RefreshTokenRequest>, ValidatedJson(req): ValidatedJson<RefreshTokenRequest>,
@@ -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( pub async fn logout(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,

View File

@@ -19,6 +19,18 @@ pub struct ListDuplicatesParams {
pub offset: Option<u32>, pub offset: Option<u32>,
} }
#[utoipa::path(
get, path = "/api/v1/duplicates",
security(("bearer_token" = [])),
params(
("limit" = Option<u32>, Query, description = "Page size"),
("offset" = Option<u32>, Query, description = "Page offset")
),
responses(
(status = 200, description = "Duplicate groups", body = Vec<DuplicateGroupResponse>),
(status = 401, description = "Unauthorized")
)
)]
pub async fn list_duplicates( pub async fn list_duplicates(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -37,6 +49,16 @@ pub async fn list_duplicates(
Ok(Json(resp)) 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( pub async fn resolve_duplicate(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,

View File

@@ -40,6 +40,19 @@ pub struct ListJobsParams {
pub offset: Option<u32>, pub offset: Option<u32>,
} }
#[utoipa::path(
get, path = "/api/v1/jobs",
security(("bearer_token" = [])),
params(
("status" = Option<String>, Query, description = "Status filter"),
("limit" = Option<u32>, Query, description = "Page size"),
("offset" = Option<u32>, Query, description = "Page offset")
),
responses(
(status = 200, description = "Job list", body = JobListResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn list_jobs( pub async fn list_jobs(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn enqueue_job(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -82,6 +104,15 @@ pub async fn enqueue_job(
Ok((StatusCode::CREATED, Json(JobResponse::from_domain(&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( pub async fn start_job(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -95,6 +126,16 @@ pub async fn start_job(
Ok(Json(JobResponse::from_domain(&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( pub async fn complete_job(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -110,6 +151,16 @@ pub async fn complete_job(
Ok(Json(JobResponse::from_domain(&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( pub async fn fail_job(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -125,6 +176,15 @@ pub async fn fail_job(
Ok(Json(JobResponse::from_domain(&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( pub async fn batch_progress(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -138,6 +198,15 @@ pub async fn batch_progress(
Ok(Json(BatchProgressResponse::from_domain(&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( pub async fn manage_plugin(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn configure_pipeline(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,

View File

@@ -15,6 +15,15 @@ use domain::value_objects::{DateTimeStamp, SystemId};
const DEFAULT_ACCESS_LEVEL: &str = "view_only"; 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( pub async fn share_resource(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn generate_link(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn revoke(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -78,6 +105,14 @@ pub async fn revoke(
Ok(StatusCode::NO_CONTENT) 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( pub async fn access_by_token(
State(state): State<AppState>, State(state): State<AppState>,
Path((token,)): Path<(String,)>, Path((token,)): Path<(String,)>,

View File

@@ -10,6 +10,15 @@ use axum::{
}; };
use domain::value_objects::SystemId; 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( pub async fn export_sidecar(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -23,6 +32,14 @@ pub async fn export_sidecar(
Ok(Json(SidecarExportResponse::from_domain(&record))) 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( pub async fn detect_changes(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn import_sidecar(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn resolve_conflict(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -70,6 +106,14 @@ pub async fn resolve_conflict(
Ok(Json(SidecarExportResponse::from_domain(&record))) 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( pub async fn full_export(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn full_import(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,

View File

@@ -1,7 +1,7 @@
use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState}; use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState};
use api_types::{requests::CreateStackRequest, responses::StackResponse}; use api_types::{requests::CreateStackRequest, responses::StackResponse};
use application::catalog::{ use application::catalog::{
CreateStackCommand, DeleteStackCommand, DetectLivePhotosCommand, GetStackQuery, CreateStackCommand, DeleteStackCommand, DetectLivePhotosCommand, GetStackQuery, ListStacksQuery,
}; };
use axum::{ use axum::{
Json, Json,
@@ -10,6 +10,35 @@ use axum::{
}; };
use domain::value_objects::SystemId; use domain::value_objects::SystemId;
#[utoipa::path(
get, path = "/api/v1/stacks",
security(("bearer_token" = [])),
responses(
(status = 200, description = "List of stacks", body = Vec<StackResponse>),
(status = 401, description = "Unauthorized")
)
)]
pub async fn list_stacks(
State(state): State<AppState>,
claims: JwtClaims,
) -> Result<Json<Vec<StackResponse>>, 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( pub async fn create_stack(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn get_stack(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -51,6 +89,15 @@ pub async fn get_stack(
Ok(Json(StackResponse::from_domain(&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( pub async fn delete_stack(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -64,6 +111,14 @@ pub async fn delete_stack(
Ok(StatusCode::NO_CONTENT) 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<StackResponse>),
(status = 401, description = "Unauthorized")
)
)]
pub async fn detect_live_photos( pub async fn detect_live_photos(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,

View File

@@ -11,6 +11,15 @@ use axum::{
}; };
use domain::value_objects::SystemId; 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( pub async fn register_volume(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, 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( pub async fn register_library_path(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,
@@ -51,6 +69,18 @@ pub async fn register_library_path(
const DEFAULT_QUOTA_USAGE_TYPE: &str = "storage_bytes"; const DEFAULT_QUOTA_USAGE_TYPE: &str = "storage_bytes";
const DEFAULT_QUOTA_AMOUNT: u64 = 0; const DEFAULT_QUOTA_AMOUNT: u64 = 0;
#[utoipa::path(
get, path = "/api/v1/storage/quota",
security(("bearer_token" = [])),
params(
("usage_type" = Option<String>, Query, description = "Usage type"),
("amount" = Option<u64>, Query, description = "Requested amount")
),
responses(
(status = 200, description = "Quota check result", body = QuotaCheckResponse),
(status = 401, description = "Unauthorized")
)
)]
pub async fn check_quota( pub async fn check_quota(
State(state): State<AppState>, State(state): State<AppState>,
claims: JwtClaims, claims: JwtClaims,

View File

@@ -13,15 +13,111 @@ use utoipa_scalar::{Scalar, Servable};
crate::handlers::auth::register, crate::handlers::auth::register,
crate::handlers::auth::login, crate::handlers::auth::login,
crate::handlers::auth::me, 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( components(schemas(
api_types::requests::RegisterRequest, api_types::requests::RegisterRequest,
api_types::requests::LoginRequest, 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::AuthResponse,
api_types::responses::UserResponse, 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), 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; pub struct ApiDoc;

View File

@@ -26,8 +26,14 @@ fn protected_routes(state: &AppState) -> Router<AppState> {
.route("/auth/me", get(auth::me)) .route("/auth/me", get(auth::me))
.route("/auth/logout", post(auth::logout)) .route("/auth/logout", post(auth::logout))
// albums // albums
.route("/albums", post(albums::create_album)) .route(
.route("/albums/{id}", get(albums::get_album)) "/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", post(albums::add_entry))
.route( .route(
"/albums/{id}/entries/{asset_id}", "/albums/{id}/entries/{asset_id}",
@@ -49,8 +55,13 @@ fn protected_routes(state: &AppState) -> Router<AppState> {
get(assets::serve_derivative), get(assets::serve_derivative),
) )
.route("/assets/{id}/tags", post(assets::tag_asset)) .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 // stacks
.route("/stacks", post(stacks::create_stack)) .route(
"/stacks",
get(stacks::list_stacks).post(stacks::create_stack),
)
.route( .route(
"/stacks/detect-live-photos", "/stacks/detect-live-photos",
post(stacks::detect_live_photos), post(stacks::detect_live_photos),

View File

@@ -4,15 +4,16 @@ use application::{
catalog::{ catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler, CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler, GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler, ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler,
SearchAssetsHandler, UpdateMetadataHandler, ResolveDuplicateHandler, SearchAssetsHandler, UpdateMetadataHandler,
}, },
identity::{ identity::{
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler, GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
RegisterUserHandler, RegisterUserHandler,
}, },
organization::{ organization::{
CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler, TagAssetHandler, CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler,
TagAssetHandler, UpdateAlbumHandler,
}, },
processing::{ processing::{
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler, CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
@@ -59,13 +60,16 @@ pub struct CatalogHandlers {
pub get_stack: Arc<GetStackHandler>, pub get_stack: Arc<GetStackHandler>,
pub delete_stack: Arc<DeleteStackHandler>, pub delete_stack: Arc<DeleteStackHandler>,
pub detect_live_photos: Arc<DetectLivePhotosHandler>, pub detect_live_photos: Arc<DetectLivePhotosHandler>,
pub list_stacks: Arc<ListStacksHandler>,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct OrganizationHandlers { pub struct OrganizationHandlers {
pub create_album: Arc<CreateAlbumHandler>, pub create_album: Arc<CreateAlbumHandler>,
pub get_album: Arc<GetAlbumHandler>, pub get_album: Arc<GetAlbumHandler>,
pub list_albums: Arc<ListAlbumsHandler>,
pub manage_album_entries: Arc<ManageAlbumEntriesHandler>, pub manage_album_entries: Arc<ManageAlbumEntriesHandler>,
pub update_album: Arc<UpdateAlbumHandler>,
pub tag_asset: Arc<TagAssetHandler>, pub tag_asset: Arc<TagAssetHandler>,
} }

View File

@@ -8,7 +8,7 @@ use tracing::{error, info, warn};
use application::processing::{EnqueueJobCommand, ProcessNextJobCommand}; use application::processing::{EnqueueJobCommand, ProcessNextJobCommand};
use domain::entities::JobType; use domain::entities::JobType;
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ports::EventConsumer; use domain::ports::{EventConsumer, JobRepository};
use domain::value_objects::StructuredData; use domain::value_objects::StructuredData;
mod config; mod config;
@@ -70,6 +70,7 @@ async fn main() -> anyhow::Result<()> {
registry, registry,
event_pub.clone(), event_pub.clone(),
)); ));
let job_repo: Arc<dyn JobRepository> = repos.job.clone();
let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub)); let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub));
// ── Shutdown signal ─────────────────────────────────────────────── // ── Shutdown signal ───────────────────────────────────────────────
@@ -180,6 +181,27 @@ async fn main() -> anyhow::Result<()> {
error!(error = %e, "event loop: failed to enqueue SyncSidecar"); 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 { DomainEvent::JobEnqueued {
job_id, job_type, .. job_id, job_type, ..
} => { } => {