feat: enhance album and media management with update and delete functionalities

This commit is contained in:
2025-11-02 18:46:26 +01:00
parent a36b59a5fb
commit 13bb9e6b3e
14 changed files with 334 additions and 43 deletions

View File

@@ -2,12 +2,13 @@ use axum::{
Json, Router, Json, Router,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
routing::{get, post},
}; };
use libertas_core::{ use libertas_core::{
models::AlbumPermission, models::{Album, AlbumPermission},
schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData}, schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData, UpdateAlbumData},
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::{error::ApiError, middleware::auth::UserId, state::AppState}; use crate::{error::ApiError, middleware::auth::UserId, state::AppState};
@@ -68,7 +69,7 @@ pub struct ShareAlbumRequest {
async fn share_album( async fn share_album(
State(state): State<AppState>, State(state): State<AppState>,
UserId(owner_id): UserId, // The person sharing must be authenticated UserId(owner_id): UserId,
Path(album_id): Path<Uuid>, Path(album_id): Path<Uuid>,
Json(payload): Json<ShareAlbumRequest>, Json(payload): Json<ShareAlbumRequest>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
@@ -83,9 +84,95 @@ async fn share_album(
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
#[derive(Serialize)]
pub struct AlbumResponse {
id: Uuid,
owner_id: Uuid,
name: String,
description: Option<String>,
is_public: bool,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<Album> 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<String>,
description: Option<Option<String>>,
is_public: Option<bool>,
}
async fn list_user_albums(
State(state): State<AppState>,
UserId(user_id): UserId,
) -> Result<Json<Vec<AlbumResponse>>, 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<AppState>,
UserId(user_id): UserId,
Path(album_id): Path<Uuid>,
) -> Result<Json<AlbumResponse>, 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<AppState>,
UserId(user_id): UserId,
Path(album_id): Path<Uuid>,
Json(payload): Json<UpdateAlbumRequest>,
) -> Result<Json<AlbumResponse>, 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<AppState>,
UserId(user_id): UserId,
Path(album_id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
state.album_service.delete_album(album_id, user_id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn album_routes() -> Router<AppState> { pub fn album_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", axum::routing::post(create_album)) .route("/", post(create_album).get(list_user_albums))
.route("/{album_id}/media", axum::routing::post(add_media_to_album)) .route(
.route("/{album_id}/share", axum::routing::post(share_album)) "/{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))
} }

View File

@@ -1,9 +1,8 @@
use axum::{Json, extract::State, http::StatusCode}; 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 serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{error::ApiError, middleware::auth::UserId, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct RegisterRequest { pub struct RegisterRequest {
@@ -12,13 +11,6 @@ pub struct RegisterRequest {
pub password: String, pub password: String,
} }
#[derive(Serialize)]
pub struct UserResponse {
id: Uuid,
username: String,
email: String,
}
pub async fn register( pub async fn register(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<RegisterRequest>, Json(payload): Json<RegisterRequest>,
@@ -64,16 +56,8 @@ pub async fn login(
Ok(Json(LoginResponse { token })) Ok(Json(LoginResponse { token }))
} }
pub async fn get_me( pub fn auth_routes() -> axum::Router<AppState> {
State(state): State<AppState>, axum::Router::new()
UserId(user_id): UserId, .route("/register", axum::routing::post(register))
) -> Result<Json<UserResponse>, ApiError> { .route("/login", axum::routing::post(login))
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))
} }

View File

@@ -40,7 +40,8 @@ impl From<Media> for MediaResponse {
pub fn media_routes() -> Router<AppState> { pub fn media_routes() -> Router<AppState> {
Router::new() Router::new()
.route("/", post(upload_media)) .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)) .layer(DefaultBodyLimit::max(250 * 1024 * 1024))
} }
@@ -104,3 +105,21 @@ async fn get_media_file(
))) )))
}) })
} }
async fn get_media_details(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(id): Path<Uuid>,
) -> Result<Json<MediaResponse>, ApiError> {
let media = state.media_service.get_media_details(id, user_id).await?;
Ok(Json(media.into()))
}
async fn delete_media(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
state.media_service.delete_media(id, user_id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,3 +1,4 @@
pub mod album_handlers; pub mod album_handlers;
pub mod auth_handlers; pub mod auth_handlers;
pub mod media_handlers; pub mod media_handlers;
pub mod user_handlers;

View File

@@ -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<AppState>,
UserId(user_id): UserId,
) -> Result<Json<UserResponse>, 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<AppState> {
Router::new().route("/me", axum::routing::get(get_me))
}

View File

@@ -1,19 +1,13 @@
use axum::{ use axum::{Router, routing::get};
Router,
routing::{get, post},
};
use crate::{ use crate::{
handlers::{album_handlers, auth_handlers, media_handlers}, handlers::{album_handlers, auth_handlers, media_handlers, user_handlers},
state::AppState, state::AppState,
}; };
pub fn api_routes() -> Router<AppState> { pub fn api_routes() -> Router<AppState> {
let auth_routes = Router::new() let auth_routes = auth_handlers::auth_routes();
.route("/register", post(auth_handlers::register)) let user_routes = user_handlers::user_routes();
.route("/login", post(auth_handlers::login));
let user_routes = Router::new().route("/me", get(auth_handlers::get_me));
let media_routes = media_handlers::media_routes(); let media_routes = media_handlers::media_routes();
let album_routes = album_handlers::album_routes(); let album_routes = album_handlers::album_routes();

View File

@@ -7,7 +7,7 @@ use libertas_core::{
error::{CoreError, CoreResult}, error::{CoreError, CoreResult},
models::Album, models::Album,
repositories::{AlbumRepository, AlbumShareRepository, MediaRepository}, repositories::{AlbumRepository, AlbumShareRepository, MediaRepository},
schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData}, schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData, UpdateAlbumData},
services::AlbumService, services::AlbumService,
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -139,4 +139,70 @@ impl AlbumService for AlbumServiceImpl {
.create_or_update_share(data.album_id, data.target_user_id, data.permission) .create_or_update_share(data.album_id, data.target_user_id, data.permission)
.await .await
} }
async fn update_album(
&self,
album_id: Uuid,
user_id: Uuid,
data: UpdateAlbumData<'_>,
) -> CoreResult<Album> {
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
}
} }

View File

@@ -192,4 +192,46 @@ impl MediaService for MediaServiceImpl {
Err(CoreError::Auth("Access denied".to_string())) 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(())
}
} }

View File

@@ -44,6 +44,7 @@ pub struct User {
pub storage_used: i64, // in bytes pub storage_used: i64, // in bytes
} }
#[derive(Clone, sqlx::FromRow)]
pub struct Album { pub struct Album {
pub id: uuid::Uuid, pub id: uuid::Uuid,
pub owner_id: uuid::Uuid, pub owner_id: uuid::Uuid,

View File

@@ -19,6 +19,7 @@ pub trait MediaRepository: Send + Sync {
height: Option<i32>, height: Option<i32>,
location: Option<String>, location: Option<String>,
) -> CoreResult<()>; ) -> CoreResult<()>;
async fn delete(&self, id: Uuid) -> CoreResult<()>;
} }
#[async_trait] #[async_trait]
@@ -36,6 +37,8 @@ pub trait AlbumRepository: Send + Sync {
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Album>>; async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Album>>;
async fn list_by_user(&self, user_id: Uuid) -> CoreResult<Vec<Album>>; async fn list_by_user(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
async fn add_media_to_album(&self, album_id: Uuid, media_ids: &[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] #[async_trait]

View File

@@ -1,3 +1,6 @@
use serde::Serialize;
use uuid::Uuid;
use crate::models::AlbumPermission; use crate::models::AlbumPermission;
pub struct UploadMediaData<'a> { pub struct UploadMediaData<'a> {
@@ -26,6 +29,12 @@ pub struct CreateAlbumData<'a> {
pub is_public: bool, pub is_public: bool,
} }
pub struct UpdateAlbumData<'a> {
pub name: Option<&'a str>,
pub description: Option<Option<&'a str>>,
pub is_public: Option<bool>,
}
pub struct AddMediaToAlbumData { pub struct AddMediaToAlbumData {
pub album_id: uuid::Uuid, pub album_id: uuid::Uuid,
pub media_ids: Vec<uuid::Uuid>, pub media_ids: Vec<uuid::Uuid>,
@@ -36,3 +45,10 @@ pub struct ShareAlbumData {
pub target_user_id: uuid::Uuid, pub target_user_id: uuid::Uuid,
pub permission: AlbumPermission, pub permission: AlbumPermission,
} }
#[derive(Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub username: String,
pub email: String,
}

View File

@@ -6,7 +6,7 @@ use crate::{
models::{Album, Media, User}, models::{Album, Media, User},
schema::{ schema::{
AddMediaToAlbumData, CreateAlbumData, CreateUserData, LoginUserData, ShareAlbumData, 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<Media>; async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult<Media>;
async fn list_user_media(&self, user_id: Uuid) -> CoreResult<Vec<Media>>; async fn list_user_media(&self, user_id: Uuid) -> CoreResult<Vec<Media>>;
async fn get_media_filepath(&self, id: Uuid, user_id: Uuid) -> CoreResult<String>; async fn get_media_filepath(&self, id: Uuid, user_id: Uuid) -> CoreResult<String>;
async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()>;
} }
#[async_trait] #[async_trait]
@@ -31,6 +32,12 @@ pub trait AlbumService: Send + Sync {
async fn get_album_details(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<Album>; async fn get_album_details(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<Album>;
async fn add_media_to_album(&self, data: AddMediaToAlbumData, 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<Vec<Album>>; async fn list_user_albums(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
async fn share_album(&self, data: ShareAlbumData, owner_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<Album>;
async fn delete_album(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<()>;
} }

View File

@@ -88,4 +88,38 @@ impl AlbumRepository for PostgresAlbumRepository {
Ok(()) 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(())
}
} }

View File

@@ -115,4 +115,19 @@ impl MediaRepository for PostgresMediaRepository {
Ok(()) 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(())
}
} }