From 13bb9e6b3ea22eaab0f29d1340878b81ce51daaa Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 2 Nov 2025 18:46:26 +0100 Subject: [PATCH] feat: enhance album and media management with update and delete functionalities --- libertas_api/src/handlers/album_handlers.rs | 101 ++++++++++++++++-- libertas_api/src/handlers/auth_handlers.rs | 28 ++--- libertas_api/src/handlers/media_handlers.rs | 21 +++- libertas_api/src/handlers/mod.rs | 1 + libertas_api/src/handlers/user_handlers.rs | 22 ++++ libertas_api/src/routes.rs | 14 +-- libertas_api/src/services/album_service.rs | 68 +++++++++++- libertas_api/src/services/media_service.rs | 42 ++++++++ libertas_core/src/models.rs | 1 + libertas_core/src/repositories.rs | 3 + libertas_core/src/schema.rs | 16 +++ libertas_core/src/services.rs | 11 +- .../src/repositories/album_repository.rs | 34 ++++++ .../src/repositories/media_repository.rs | 15 +++ 14 files changed, 334 insertions(+), 43 deletions(-) create mode 100644 libertas_api/src/handlers/user_handlers.rs diff --git a/libertas_api/src/handlers/album_handlers.rs b/libertas_api/src/handlers/album_handlers.rs index 5ca1c05..4155c83 100644 --- a/libertas_api/src/handlers/album_handlers.rs +++ b/libertas_api/src/handlers/album_handlers.rs @@ -2,12 +2,13 @@ use axum::{ Json, Router, extract::{Path, State}, http::StatusCode, + routing::{get, post}, }; use libertas_core::{ - models::AlbumPermission, - schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData}, + models::{Album, AlbumPermission}, + schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData, UpdateAlbumData}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{error::ApiError, middleware::auth::UserId, state::AppState}; @@ -68,7 +69,7 @@ pub struct ShareAlbumRequest { async fn share_album( State(state): State, - UserId(owner_id): UserId, // The person sharing must be authenticated + UserId(owner_id): UserId, Path(album_id): Path, Json(payload): Json, ) -> Result { @@ -83,9 +84,95 @@ async fn share_album( Ok(StatusCode::OK) } +#[derive(Serialize)] +pub struct AlbumResponse { + id: Uuid, + owner_id: Uuid, + name: String, + description: Option, + is_public: bool, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +impl From for AlbumResponse { + fn from(album: Album) -> Self { + Self { + id: album.id, + owner_id: album.owner_id, + name: album.name, + description: album.description, + is_public: album.is_public, + created_at: album.created_at, + updated_at: album.updated_at, + } + } +} + +#[derive(Deserialize)] +pub struct UpdateAlbumRequest { + name: Option, + description: Option>, + is_public: Option, +} + +async fn list_user_albums( + State(state): State, + UserId(user_id): UserId, +) -> Result>, ApiError> { + let albums = state.album_service.list_user_albums(user_id).await?; + let response = albums.into_iter().map(AlbumResponse::from).collect(); + Ok(Json(response)) +} + +async fn get_album_details( + State(state): State, + UserId(user_id): UserId, + Path(album_id): Path, +) -> Result, ApiError> { + let album = state + .album_service + .get_album_details(album_id, user_id) + .await?; + Ok(Json(album.into())) +} + +async fn update_album( + State(state): State, + UserId(user_id): UserId, + Path(album_id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let data = UpdateAlbumData { + name: payload.name.as_deref(), + description: payload.description.as_ref().map(|opt_s| opt_s.as_deref()), + is_public: payload.is_public, + }; + let album = state + .album_service + .update_album(album_id, user_id, data) + .await?; + Ok(Json(album.into())) +} + +async fn delete_album( + State(state): State, + UserId(user_id): UserId, + Path(album_id): Path, +) -> Result { + state.album_service.delete_album(album_id, user_id).await?; + Ok(StatusCode::NO_CONTENT) +} + pub fn album_routes() -> Router { Router::new() - .route("/", axum::routing::post(create_album)) - .route("/{album_id}/media", axum::routing::post(add_media_to_album)) - .route("/{album_id}/share", axum::routing::post(share_album)) + .route("/", post(create_album).get(list_user_albums)) + .route( + "/{id}", + get(get_album_details) + .put(update_album) + .delete(delete_album), + ) + .route("/{id}/media", post(add_media_to_album)) + .route("/{id}/share", post(share_album)) } diff --git a/libertas_api/src/handlers/auth_handlers.rs b/libertas_api/src/handlers/auth_handlers.rs index 46893ce..8d811a5 100644 --- a/libertas_api/src/handlers/auth_handlers.rs +++ b/libertas_api/src/handlers/auth_handlers.rs @@ -1,9 +1,8 @@ use axum::{Json, extract::State, http::StatusCode}; -use libertas_core::schema::{CreateUserData, LoginUserData}; +use libertas_core::schema::{CreateUserData, LoginUserData, UserResponse}; use serde::{Deserialize, Serialize}; -use uuid::Uuid; -use crate::{error::ApiError, middleware::auth::UserId, state::AppState}; +use crate::{error::ApiError, state::AppState}; #[derive(Deserialize)] pub struct RegisterRequest { @@ -12,13 +11,6 @@ pub struct RegisterRequest { pub password: String, } -#[derive(Serialize)] -pub struct UserResponse { - id: Uuid, - username: String, - email: String, -} - pub async fn register( State(state): State, Json(payload): Json, @@ -64,16 +56,8 @@ pub async fn login( Ok(Json(LoginResponse { token })) } -pub async fn get_me( - State(state): State, - UserId(user_id): UserId, -) -> Result, ApiError> { - let user = state.user_service.get_user_details(user_id).await?; - - let response = UserResponse { - id: user.id, - username: user.username, - email: user.email, - }; - Ok(Json(response)) +pub fn auth_routes() -> axum::Router { + axum::Router::new() + .route("/register", axum::routing::post(register)) + .route("/login", axum::routing::post(login)) } diff --git a/libertas_api/src/handlers/media_handlers.rs b/libertas_api/src/handlers/media_handlers.rs index 9981067..13d8adc 100644 --- a/libertas_api/src/handlers/media_handlers.rs +++ b/libertas_api/src/handlers/media_handlers.rs @@ -40,7 +40,8 @@ impl From for MediaResponse { pub fn media_routes() -> Router { Router::new() .route("/", post(upload_media)) - .route("/{media_id}/file", get(get_media_file)) + .route("/{id}", get(get_media_details).delete(delete_media)) + .route("/{id}/file", get(get_media_file)) .layer(DefaultBodyLimit::max(250 * 1024 * 1024)) } @@ -104,3 +105,21 @@ async fn get_media_file( ))) }) } + +async fn get_media_details( + State(state): State, + UserId(user_id): UserId, + Path(id): Path, +) -> Result, ApiError> { + let media = state.media_service.get_media_details(id, user_id).await?; + Ok(Json(media.into())) +} + +async fn delete_media( + State(state): State, + UserId(user_id): UserId, + Path(id): Path, +) -> Result { + state.media_service.delete_media(id, user_id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/libertas_api/src/handlers/mod.rs b/libertas_api/src/handlers/mod.rs index 435e7ac..814e2fe 100644 --- a/libertas_api/src/handlers/mod.rs +++ b/libertas_api/src/handlers/mod.rs @@ -1,3 +1,4 @@ pub mod album_handlers; pub mod auth_handlers; pub mod media_handlers; +pub mod user_handlers; diff --git a/libertas_api/src/handlers/user_handlers.rs b/libertas_api/src/handlers/user_handlers.rs new file mode 100644 index 0000000..ac132b0 --- /dev/null +++ b/libertas_api/src/handlers/user_handlers.rs @@ -0,0 +1,22 @@ +use axum::{Json, Router, extract::State}; +use libertas_core::schema::UserResponse; + +use crate::{error::ApiError, middleware::auth::UserId, state::AppState}; + +pub async fn get_me( + State(state): State, + UserId(user_id): UserId, +) -> Result, ApiError> { + let user = state.user_service.get_user_details(user_id).await?; + + let response = UserResponse { + id: user.id, + username: user.username, + email: user.email, + }; + Ok(Json(response)) +} + +pub fn user_routes() -> Router { + Router::new().route("/me", axum::routing::get(get_me)) +} diff --git a/libertas_api/src/routes.rs b/libertas_api/src/routes.rs index f1d46aa..072aa73 100644 --- a/libertas_api/src/routes.rs +++ b/libertas_api/src/routes.rs @@ -1,19 +1,13 @@ -use axum::{ - Router, - routing::{get, post}, -}; +use axum::{Router, routing::get}; use crate::{ - handlers::{album_handlers, auth_handlers, media_handlers}, + handlers::{album_handlers, auth_handlers, media_handlers, user_handlers}, state::AppState, }; pub fn api_routes() -> Router { - let auth_routes = Router::new() - .route("/register", post(auth_handlers::register)) - .route("/login", post(auth_handlers::login)); - - let user_routes = Router::new().route("/me", get(auth_handlers::get_me)); + let auth_routes = auth_handlers::auth_routes(); + let user_routes = user_handlers::user_routes(); let media_routes = media_handlers::media_routes(); let album_routes = album_handlers::album_routes(); diff --git a/libertas_api/src/services/album_service.rs b/libertas_api/src/services/album_service.rs index 9ebe9d3..519e0bc 100644 --- a/libertas_api/src/services/album_service.rs +++ b/libertas_api/src/services/album_service.rs @@ -7,7 +7,7 @@ use libertas_core::{ error::{CoreError, CoreResult}, models::Album, repositories::{AlbumRepository, AlbumShareRepository, MediaRepository}, - schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData}, + schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData, UpdateAlbumData}, services::AlbumService, }; use uuid::Uuid; @@ -139,4 +139,70 @@ impl AlbumService for AlbumServiceImpl { .create_or_update_share(data.album_id, data.target_user_id, data.permission) .await } + + async fn update_album( + &self, + album_id: Uuid, + user_id: Uuid, + data: UpdateAlbumData<'_>, + ) -> CoreResult { + let mut album = self + .album_repo + .find_by_id(album_id) + .await? + .ok_or(CoreError::NotFound("Album".to_string(), album_id))?; + + let share_permission = self + .album_share_repo + .get_user_permission(album_id, user_id) + .await?; + + if !authz::can_contribute_to_album(user_id, &album, share_permission) { + return Err(CoreError::Auth( + "User does not have permission to update this album".to_string(), + )); + } + + if let Some(name) = data.name { + if name.is_empty() { + return Err(CoreError::Validation( + "Album name cannot be empty".to_string(), + )); + } + album.name = name.to_string(); + } + + if let Some(description) = data.description { + album.description = description.map(|s| s.to_string()); + } + + if let Some(is_public) = data.is_public { + if !authz::is_owner(user_id, &album) && is_public { + return Err(CoreError::Auth( + "Only the owner can make an album public".to_string(), + )); + } + album.is_public = is_public; + } + + self.album_repo.update(album.clone()).await?; + + Ok(album) + } + + async fn delete_album(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<()> { + let album = self + .album_repo + .find_by_id(album_id) + .await? + .ok_or(CoreError::NotFound("Album".to_string(), album_id))?; + + if !authz::is_owner(user_id, &album) { + return Err(CoreError::Auth( + "Only the album owner can delete the album".to_string(), + )); + } + + self.album_repo.delete(album_id).await + } } diff --git a/libertas_api/src/services/media_service.rs b/libertas_api/src/services/media_service.rs index 7395886..2628cde 100644 --- a/libertas_api/src/services/media_service.rs +++ b/libertas_api/src/services/media_service.rs @@ -192,4 +192,46 @@ impl MediaService for MediaServiceImpl { Err(CoreError::Auth("Access denied".to_string())) } + + async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()> { + let media = self + .repo + .find_by_id(id) + .await? + .ok_or(CoreError::NotFound("Media".to_string(), id))?; + + let user = self + .user_repo + .find_by_id(user_id) + .await? + .ok_or(CoreError::NotFound("User".to_string(), user_id))?; + + if !authz::is_owner(user_id, &media) && !authz::is_admin(&user) { + return Err(CoreError::Auth("Access denied".to_string())); + } + + let full_path = PathBuf::from(&self.config.media_library_path).join(&media.storage_path); + self.repo.delete(id).await?; + + let file_size = match fs::metadata(&full_path).await { + Ok(metadata) => metadata.len() as i64, + Err(_) => 0, + }; + + if let Err(e) = fs::remove_file(full_path).await { + tracing::error!("Failed to delete media file from disk: {}", e); + } + + self.user_repo + .update_storage_used(user.id, -file_size) + .await?; + + let job_payload = json!({ "media_id": id }); + self.nats_client + .publish("media.deleted".to_string(), job_payload.to_string().into()) + .await + .map_err(|e| CoreError::Unknown(format!("Failed to publish NATS job: {}", e)))?; + + Ok(()) + } } diff --git a/libertas_core/src/models.rs b/libertas_core/src/models.rs index efb8fe7..70c1ba3 100644 --- a/libertas_core/src/models.rs +++ b/libertas_core/src/models.rs @@ -44,6 +44,7 @@ pub struct User { pub storage_used: i64, // in bytes } +#[derive(Clone, sqlx::FromRow)] pub struct Album { pub id: uuid::Uuid, pub owner_id: uuid::Uuid, diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index 6d7bbc7..b7b9cf5 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -19,6 +19,7 @@ pub trait MediaRepository: Send + Sync { height: Option, location: Option, ) -> CoreResult<()>; + async fn delete(&self, id: Uuid) -> CoreResult<()>; } #[async_trait] @@ -36,6 +37,8 @@ pub trait AlbumRepository: Send + Sync { async fn find_by_id(&self, id: Uuid) -> CoreResult>; async fn list_by_user(&self, user_id: Uuid) -> CoreResult>; async fn add_media_to_album(&self, album_id: Uuid, media_ids: &[Uuid]) -> CoreResult<()>; + async fn update(&self, album: Album) -> CoreResult<()>; + async fn delete(&self, id: Uuid) -> CoreResult<()>; } #[async_trait] diff --git a/libertas_core/src/schema.rs b/libertas_core/src/schema.rs index e33f2ab..63561d4 100644 --- a/libertas_core/src/schema.rs +++ b/libertas_core/src/schema.rs @@ -1,3 +1,6 @@ +use serde::Serialize; +use uuid::Uuid; + use crate::models::AlbumPermission; pub struct UploadMediaData<'a> { @@ -26,6 +29,12 @@ pub struct CreateAlbumData<'a> { pub is_public: bool, } +pub struct UpdateAlbumData<'a> { + pub name: Option<&'a str>, + pub description: Option>, + pub is_public: Option, +} + pub struct AddMediaToAlbumData { pub album_id: uuid::Uuid, pub media_ids: Vec, @@ -36,3 +45,10 @@ pub struct ShareAlbumData { pub target_user_id: uuid::Uuid, pub permission: AlbumPermission, } + +#[derive(Serialize)] +pub struct UserResponse { + pub id: Uuid, + pub username: String, + pub email: String, +} diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index 1ff6753..f9c2b15 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -6,7 +6,7 @@ use crate::{ models::{Album, Media, User}, schema::{ AddMediaToAlbumData, CreateAlbumData, CreateUserData, LoginUserData, ShareAlbumData, - UploadMediaData, + UpdateAlbumData, UploadMediaData, }, }; @@ -16,6 +16,7 @@ pub trait MediaService: Send + Sync { async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult; async fn list_user_media(&self, user_id: Uuid) -> CoreResult>; async fn get_media_filepath(&self, id: Uuid, user_id: Uuid) -> CoreResult; + async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()>; } #[async_trait] @@ -31,6 +32,12 @@ pub trait AlbumService: Send + Sync { async fn get_album_details(&self, album_id: Uuid, user_id: Uuid) -> CoreResult; async fn add_media_to_album(&self, data: AddMediaToAlbumData, user_id: Uuid) -> CoreResult<()>; async fn list_user_albums(&self, user_id: Uuid) -> CoreResult>; - async fn share_album(&self, data: ShareAlbumData, owner_id: Uuid) -> CoreResult<()>; + async fn update_album( + &self, + album_id: Uuid, + user_id: Uuid, + data: UpdateAlbumData<'_>, + ) -> CoreResult; + async fn delete_album(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<()>; } diff --git a/libertas_infra/src/repositories/album_repository.rs b/libertas_infra/src/repositories/album_repository.rs index 46fd502..da3c6fc 100644 --- a/libertas_infra/src/repositories/album_repository.rs +++ b/libertas_infra/src/repositories/album_repository.rs @@ -88,4 +88,38 @@ impl AlbumRepository for PostgresAlbumRepository { Ok(()) } + + async fn update(&self, album: Album) -> CoreResult<()> { + sqlx::query!( + r#" + UPDATE albums + SET name = $1, description = $2, is_public = $3, updated_at = NOW() + WHERE id = $4 + "#, + album.name, + album.description, + album.is_public, + album.id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(()) + } + + async fn delete(&self, id: Uuid) -> CoreResult<()> { + sqlx::query!( + r#" + DELETE FROM albums + WHERE id = $1 + "#, + id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(()) + } } diff --git a/libertas_infra/src/repositories/media_repository.rs b/libertas_infra/src/repositories/media_repository.rs index 8fc03ab..1ed7710 100644 --- a/libertas_infra/src/repositories/media_repository.rs +++ b/libertas_infra/src/repositories/media_repository.rs @@ -115,4 +115,19 @@ impl MediaRepository for PostgresMediaRepository { Ok(()) } + + async fn delete(&self, id: Uuid) -> CoreResult<()> { + sqlx::query!( + r#" + DELETE FROM media + WHERE id = $1 + "#, + id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(()) + } }