use std::collections::HashMap; use axum::extract::{Path, State}; use axum::Json; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use crate::models::{Album, JobKind, Photo, PhotoStatus}; use crate::state::AppState; #[derive(Serialize, sqlx::FromRow)] pub struct AlbumListItem { pub id: Uuid, pub name: String, pub description: String, pub created_at: DateTime, pub photo_count: i64, pub cover_photo_id: Option, pub cover_processed_at: Option>, } pub async fn list( State(state): State, ) -> ApiResult>> { let albums: Vec = sqlx::query_as( "select a.id, a.name, a.description, a.created_at, (select count(*) from photos p where p.album_id = a.id) as photo_count, c.id as cover_photo_id, c.processed_at as cover_processed_at from albums a left join lateral ( select p.id, p.processed_at from photos p where p.album_id = a.id and p.status = $1 order by coalesce(p.taken_at, p.created_at), p.filename limit 1 ) c on true order by a.created_at desc", ) .bind(PhotoStatus::Ready.as_str()) .fetch_all(&state.db) .await?; Ok(Json(albums)) } #[derive(Deserialize)] pub struct CreateAlbum { name: String, #[serde(default)] description: String, } pub async fn create( State(state): State, Json(body): Json, ) -> ApiResult> { let name = body.name.trim(); if name.is_empty() { return Err(ApiError::bad_request("album name is required")); } let album: Album = sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *") .bind(name) .bind(body.description.trim()) .fetch_one(&state.db) .await?; Ok(Json(album)) } #[derive(Serialize)] pub struct ShareRating { pub share_label: String, pub rating: i32, } #[derive(Serialize)] pub struct ShareTag { pub share_label: String, pub tag: String, } #[derive(Serialize, Default)] pub struct PhotoFeedback { pub ratings: Vec, pub tags: Vec, } #[derive(Serialize)] pub struct AlbumDetail { pub album: Album, pub photos: Vec, pub feedback: HashMap, } pub async fn get_one( State(state): State, Path(album_id): Path, ) -> ApiResult> { let album: Album = sqlx::query_as("select * from albums where id = $1") .bind(album_id) .fetch_one(&state.db) .await?; let photos: Vec = sqlx::query_as( "select * from photos where album_id = $1 order by coalesce(taken_at, created_at), filename", ) .bind(album_id) .fetch_all(&state.db) .await?; let mut feedback: HashMap = HashMap::new(); let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as( "select r.photo_id, s.label, r.rating from ratings r join shares s on s.id = r.share_id where s.album_id = $1", ) .bind(album_id) .fetch_all(&state.db) .await?; for (photo_id, share_label, rating) in ratings { feedback .entry(photo_id) .or_default() .ratings .push(ShareRating { share_label, rating, }); } let tags: Vec<(Uuid, String, String)> = sqlx::query_as( "select t.photo_id, s.label, t.tag from tags t join shares s on s.id = t.share_id where s.album_id = $1 order by t.created_at", ) .bind(album_id) .fetch_all(&state.db) .await?; for (photo_id, share_label, tag) in tags { feedback .entry(photo_id) .or_default() .tags .push(ShareTag { share_label, tag }); } Ok(Json(AlbumDetail { album, photos, feedback, })) } #[derive(Deserialize)] pub struct UpdateAlbum { name: Option, description: Option, } pub async fn update( State(state): State, Path(album_id): Path, Json(body): Json, ) -> ApiResult> { if let Some(name) = &body.name { if name.trim().is_empty() { return Err(ApiError::bad_request("album name cannot be empty")); } } let album: Album = sqlx::query_as( "update albums set name = coalesce($2, name), description = coalesce($3, description) where id = $1 returning *", ) .bind(album_id) .bind(body.name.as_deref().map(str::trim)) .bind(body.description.as_deref().map(str::trim)) .fetch_one(&state.db) .await?; Ok(Json(album)) } pub async fn delete( State(state): State, Path(album_id): Path, ) -> ApiResult> { let mut tx = state.db.begin().await?; // Lock the album row: concurrent uploads block on their FK check against // it, then fail once it's gone and clean up their own S3 objects — so no // photo can slip in between the cleanup enqueue and the cascade delete. let locked: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1 for update") .bind(album_id) .fetch_optional(&mut *tx) .await?; if locked.is_none() { return Err(ApiError::not_found()); } // Delete photos and enqueue their S3 cleanup atomically, in one statement. sqlx::query( "with deleted as (delete from photos where album_id = $1 returning id) insert into jobs (kind, payload) select $2, jsonb_build_object('prefix', 'photos/' || id || '/') from deleted", ) .bind(album_id) .bind(JobKind::DeleteS3Prefix.as_str()) .execute(&mut *tx) .await?; sqlx::query("delete from albums where id = $1") .bind(album_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Json(serde_json::json!({ "ok": true }))) }