feat: frontend-ready backend — pagination, auto-derivatives, list endpoints, bulk ops, OpenAPI
Pagination: count_by_owner + count_search on AssetRepository,
timeline/search return real total count (not page len).
Auto-derivatives: worker enqueues GenerateDerivative when
ExtractMetadata job completes, closing the upload→thumbnail gap.
List endpoints: GET /albums, GET /stacks with user scoping.
ListAlbumsHandler, ListStacksHandler, find_by_owner on AssetStackRepository.
Tag filtering: tag_name field on AssetFilters, JOIN asset_tags+tags
in postgres search/count queries.
Bulk operations: POST /assets/bulk-delete, POST /assets/bulk-tag.
Album update: PUT /albums/{id} with UpdateAlbumHandler (title, description).
OpenAPI: utoipa annotations on all 47 endpoints + all request/response
schemas registered. Scalar UI at /scalar covers full API.
This commit is contained in:
@@ -74,6 +74,84 @@ impl TryFrom<AssetRow> for Asset {
|
||||
|
||||
pg_repo!(PostgresAssetRepository);
|
||||
|
||||
fn build_search_where(filters: &AssetFilters) -> (String, bool) {
|
||||
let mut clause = String::new();
|
||||
let mut idx = 2u32;
|
||||
if filters.asset_type.is_some() {
|
||||
clause.push_str(&format!(" AND a.asset_type = ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
if filters.mime_type.is_some() {
|
||||
clause.push_str(&format!(" AND a.mime_type = ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
if filters.date_from.is_some() {
|
||||
clause.push_str(&format!(" AND a.created_at >= ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
if filters.date_to.is_some() {
|
||||
clause.push_str(&format!(" AND a.created_at <= ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
if filters.is_processed.is_some() {
|
||||
clause.push_str(&format!(" AND a.is_processed = ${idx}"));
|
||||
idx += 1;
|
||||
}
|
||||
let has_tag = filters.tag_name.is_some();
|
||||
if has_tag {
|
||||
clause.push_str(&format!(" AND t.name = ${idx}"));
|
||||
}
|
||||
(clause, has_tag)
|
||||
}
|
||||
|
||||
fn count_filter_params(filters: &AssetFilters) -> u32 {
|
||||
let mut n = 0u32;
|
||||
if filters.asset_type.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.mime_type.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.date_from.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.date_to.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.is_processed.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if filters.tag_name.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn bind_filters<'q, O>(
|
||||
mut query: sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments>,
|
||||
filters: &'q AssetFilters,
|
||||
) -> sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments> {
|
||||
if let Some(ref t) = filters.asset_type {
|
||||
query = query.bind(asset_type_to_str(t));
|
||||
}
|
||||
if let Some(ref m) = filters.mime_type {
|
||||
query = query.bind(m.as_str());
|
||||
}
|
||||
if let Some(ref d) = filters.date_from {
|
||||
query = query.bind(d.as_datetime());
|
||||
}
|
||||
if let Some(ref d) = filters.date_to {
|
||||
query = query.bind(d.as_datetime());
|
||||
}
|
||||
if let Some(p) = filters.is_processed {
|
||||
query = query.bind(p);
|
||||
}
|
||||
if let Some(ref tag) = filters.tag_name {
|
||||
query = query.bind(tag.as_str());
|
||||
}
|
||||
query
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AssetRepository for PostgresAssetRepository {
|
||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> {
|
||||
@@ -134,41 +212,66 @@ impl AssetRepository for PostgresAssetRepository {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Asset>, DomainError> {
|
||||
let mut sql = String::from(
|
||||
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
|
||||
file_size, is_processed, owner_user_id, created_at
|
||||
FROM assets WHERE owner_user_id = $1",
|
||||
let (where_clause, has_tag) = build_search_where(filters);
|
||||
let mut sql = format!(
|
||||
"SELECT a.asset_id, a.volume_id, a.relative_path, a.checksum, a.asset_type, a.mime_type,
|
||||
a.file_size, a.is_processed, a.owner_user_id, a.created_at
|
||||
FROM assets a{} WHERE a.owner_user_id = $1{}",
|
||||
if has_tag {
|
||||
" JOIN asset_tags at ON at.asset_id = a.asset_id JOIN tags t ON t.tag_id = at.tag_id"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
where_clause
|
||||
);
|
||||
let mut param_idx = 2u32;
|
||||
|
||||
if filters.asset_type.is_some() {
|
||||
sql.push_str(&format!(" AND asset_type = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.mime_type.is_some() {
|
||||
sql.push_str(&format!(" AND mime_type = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.date_from.is_some() {
|
||||
sql.push_str(&format!(" AND created_at >= ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.date_to.is_some() {
|
||||
sql.push_str(&format!(" AND created_at <= ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if filters.is_processed.is_some() {
|
||||
sql.push_str(&format!(" AND is_processed = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
let param_count = count_filter_params(filters);
|
||||
sql.push_str(&format!(
|
||||
" ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
|
||||
param_idx,
|
||||
param_idx + 1
|
||||
" ORDER BY a.created_at DESC LIMIT ${} OFFSET ${}",
|
||||
param_count + 2,
|
||||
param_count + 3
|
||||
));
|
||||
|
||||
let mut query = sqlx::query_as::<_, AssetRow>(&sql).bind(*owner_id.as_uuid());
|
||||
query = bind_filters(query, filters);
|
||||
|
||||
let rows = query
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
|
||||
let (count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE owner_user_id = $1")
|
||||
.bind(*owner_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn count_search(
|
||||
&self,
|
||||
owner_id: &SystemId,
|
||||
filters: &AssetFilters,
|
||||
) -> Result<u64, DomainError> {
|
||||
let (where_clause, has_tag) = build_search_where(filters);
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) FROM assets a{} WHERE a.owner_user_id = $1{}",
|
||||
if has_tag {
|
||||
" JOIN asset_tags at ON at.asset_id = a.asset_id JOIN tags t ON t.tag_id = at.tag_id"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
where_clause
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, (i64,)>(&sql).bind(*owner_id.as_uuid());
|
||||
|
||||
if let Some(ref t) = filters.asset_type {
|
||||
query = query.bind(asset_type_to_str(t));
|
||||
@@ -185,15 +288,12 @@ impl AssetRepository for PostgresAssetRepository {
|
||||
if let Some(p) = filters.is_processed {
|
||||
query = query.bind(p);
|
||||
}
|
||||
if let Some(ref tag) = filters.tag_name {
|
||||
query = query.bind(tag.as_str());
|
||||
}
|
||||
|
||||
let rows = query
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
let (count,) = query.fetch_one(&self.pool).await.map_pg()?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||
@@ -805,6 +905,19 @@ impl AssetStackRepository for PostgresAssetStackRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn find_by_owner(&self, owner_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, StackRow>(
|
||||
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
|
||||
FROM asset_stacks WHERE owner_user_id = $1",
|
||||
)
|
||||
.bind(*owner_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_pg()?;
|
||||
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, StackRow>(
|
||||
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
|
||||
|
||||
@@ -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<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 ---
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -87,7 +87,7 @@ impl AssetResponse {
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct TimelineResponse {
|
||||
pub assets: Vec<AssetResponse>,
|
||||
pub total: usize,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<dyn AssetRepository>,
|
||||
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
||||
@@ -51,13 +56,11 @@ impl GetTimelineHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
query: GetTimelineQuery,
|
||||
) -> Result<Vec<(Asset, StructuredData)>, DomainError> {
|
||||
pub async fn execute(&self, query: GetTimelineQuery) -> Result<TimelineResult, DomainError> {
|
||||
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<SystemId> = 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 })
|
||||
}
|
||||
}
|
||||
|
||||
22
crates/application/src/catalog/queries/list_stacks.rs
Normal file
22
crates/application/src/catalog/queries/list_stacks.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,11 @@ pub struct SearchAssetsQuery {
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
pub struct SearchResult {
|
||||
pub items: Vec<Asset>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
pub struct SearchAssetsHandler {
|
||||
asset_repo: Arc<dyn AssetRepository>,
|
||||
}
|
||||
@@ -23,9 +28,15 @@ impl SearchAssetsHandler {
|
||||
Self { asset_repo }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, query: SearchAssetsQuery) -> Result<Vec<Asset>, DomainError> {
|
||||
self.asset_repo
|
||||
pub async fn execute(&self, query: SearchAssetsQuery) -> Result<SearchResult, DomainError> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,18 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
|
||||
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> {
|
||||
self.inner.save(asset).await
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
44
crates/application/src/organization/commands/update_album.rs
Normal file
44
crates/application/src/organization/commands/update_album.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
22
crates/application/src/organization/queries/list_albums.rs
Normal file
22
crates/application/src/organization/queries/list_albums.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod get_album;
|
||||
pub mod list_albums;
|
||||
|
||||
@@ -145,6 +145,16 @@ impl AssetRepository for InMemoryAssetRepository {
|
||||
.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(
|
||||
&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<u64, DomainError> {
|
||||
self.count_by_owner(owner_id).await
|
||||
}
|
||||
|
||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||
self.data
|
||||
.lock()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct AssetFilters {
|
||||
pub date_from: Option<DateTimeStamp>,
|
||||
pub date_to: Option<DateTimeStamp>,
|
||||
pub is_processed: Option<bool>,
|
||||
pub tag_name: Option<String>,
|
||||
}
|
||||
|
||||
// --- AssetMetadata ---
|
||||
|
||||
@@ -19,6 +19,7 @@ pub trait AssetRepository: Send + Sync {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<Asset>, DomainError>;
|
||||
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError>;
|
||||
async fn search(
|
||||
&self,
|
||||
owner_id: &SystemId,
|
||||
@@ -26,6 +27,11 @@ pub trait AssetRepository: Send + Sync {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> 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 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<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 save(&self, stack: &AssetStack) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||
|
||||
@@ -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<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(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
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<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(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -44,10 +44,29 @@ pub struct SearchParams {
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
pub is_processed: Option<bool>,
|
||||
pub tag: Option<String>,
|
||||
pub limit: 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(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<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(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<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 })))
|
||||
}
|
||||
|
||||
@@ -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<AppState>,
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -19,6 +19,18 @@ pub struct ListDuplicatesParams {
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -40,6 +40,19 @@ pub struct ListJobsParams {
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
Path((token,)): Path<(String,)>,
|
||||
|
||||
@@ -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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -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<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(
|
||||
State(state): State<AppState>,
|
||||
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<AppState>,
|
||||
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<AppState>,
|
||||
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<StackResponse>),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn detect_live_photos(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -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<AppState>,
|
||||
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<AppState>,
|
||||
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<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(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -26,8 +26,14 @@ fn protected_routes(state: &AppState) -> Router<AppState> {
|
||||
.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<AppState> {
|
||||
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),
|
||||
|
||||
@@ -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<GetStackHandler>,
|
||||
pub delete_stack: Arc<DeleteStackHandler>,
|
||||
pub detect_live_photos: Arc<DetectLivePhotosHandler>,
|
||||
pub list_stacks: Arc<ListStacksHandler>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OrganizationHandlers {
|
||||
pub create_album: Arc<CreateAlbumHandler>,
|
||||
pub get_album: Arc<GetAlbumHandler>,
|
||||
pub list_albums: Arc<ListAlbumsHandler>,
|
||||
pub manage_album_entries: Arc<ManageAlbumEntriesHandler>,
|
||||
pub update_album: Arc<UpdateAlbumHandler>,
|
||||
pub tag_asset: Arc<TagAssetHandler>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<dyn JobRepository> = 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, ..
|
||||
} => {
|
||||
|
||||
Reference in New Issue
Block a user