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

@@ -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, ..
} => {