use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, }; use serde::Deserialize; use std::sync::Arc; use tracing::{error, info}; use utoipa::ToSchema; use crate::application::dtos::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, }; use crate::application::dtos::favorites_dto::{ FavoritesResourceItemDto, FavoritesResourcesDto, FavoritesResourcesQuery, }; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto}; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::services::favorites_service::FavoritesService; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; /// Single item in a batch-add-favorites request. #[derive(Debug, Deserialize, ToSchema)] pub struct BatchFavoriteItem { pub item_id: String, pub item_type: String, } /// Request body for POST /api/favorites/batch #[derive(Debug, Deserialize, ToSchema)] pub struct BatchFavoritesRequest { pub items: Vec, } /// Handler for favorite-related API endpoints /// /// # Deprecated /// Use `GET /api/favorites/resources` instead. This endpoint is kept for /// backwards compatibility but will be removed in a future release. #[deprecated = "Use GET /api/favorites/resources instead"] #[utoipa::path( get, path = "/api/favorites", responses( (status = 200, description = "List of favorites (deprecated — use /api/favorites/resources)", body = Vec) ), security(("bearerAuth" = [])), tag = "favorites" )] pub async fn get_favorites( State(favorites_service): State>, auth_user: AuthUser, ) -> impl IntoResponse { let user_id = auth_user.id; match favorites_service.get_favorites(user_id).await { Ok(favorites) => { info!( "Retrieved {} favorites for user {}", favorites.len(), auth_user.id ); (StatusCode::OK, Json(serde_json::json!(favorites))).into_response() } Err(err) => { error!("Error retrieving favorites: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to retrieve favorites" })), ) .into_response() } } } /// Add an item to user's favorites #[utoipa::path( post, path = "/api/favorites/{item_type}/{item_id}", params( ("item_type" = String, Path, description = "Item type (file or folder)"), ("item_id" = String, Path, description = "Item ID") ), responses( (status = 201, description = "Item added to favorites"), (status = 400, description = "Invalid item type") ), security(("bearerAuth" = [])), tag = "favorites" )] pub async fn add_favorite( State(favorites_service): State>, auth_user: AuthUser, Path((item_type, item_id)): Path<(String, String)>, ) -> impl IntoResponse { let user_id = auth_user.id; // Validate item_type if item_type != "file" && item_type != "folder" { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Item type must be 'file' or 'folder'" })), ); } match favorites_service .add_to_favorites(user_id, &item_id, &item_type) .await { Ok(_) => { info!("Added {} '{}' to favorites", item_type, item_id); ( StatusCode::CREATED, Json(serde_json::json!({ "message": "Item added to favorites" })), ) } Err(err) => { error!("Error adding to favorites: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to add to favorites" })), ) } } } /// Remove an item from user's favorites #[utoipa::path( delete, path = "/api/favorites/{item_type}/{item_id}", params( ("item_type" = String, Path, description = "Item type (file or folder)"), ("item_id" = String, Path, description = "Item ID") ), responses( (status = 200, description = "Item removed from favorites"), (status = 404, description = "Item not in favorites") ), security(("bearerAuth" = [])), tag = "favorites" )] pub async fn remove_favorite( State(favorites_service): State>, auth_user: AuthUser, Path((item_type, item_id)): Path<(String, String)>, ) -> impl IntoResponse { let user_id = auth_user.id; match favorites_service .remove_from_favorites(user_id, &item_id, &item_type) .await { Ok(removed) => { if removed { info!("Removed {} '{}' from favorites", item_type, item_id); ( StatusCode::OK, Json(serde_json::json!({ "message": "Item removed from favorites" })), ) } else { info!("Item {} '{}' was not in favorites", item_type, item_id); ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "message": "Item was not in favorites" })), ) } } Err(err) => { error!("Error removing from favorites: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to remove from favorites" })), ) } } } /// Cursor-paginated list of a user's favorited resources. /// /// Supports sorting by `name`, `type`, `favorited_at`, `modified_at`, `size`, or `owner`. /// Items that have been deleted/trashed are silently excluded. /// `path` is cleared when the resource is not owned by the requesting user. #[utoipa::path( get, path = "/api/favorites/resources", params(FavoritesResourcesQuery), responses( (status = 200, description = "Paginated list of favorited resources", body = crate::application::dtos::favorites_dto::FavoritesResourcesDto), (status = 400, description = "Invalid cursor or query parameters"), ), security(("bearerAuth" = [])), tag = "favorites" )] pub async fn list_favorites_resources( State(favorites_service): State>, auth_user: AuthUser, Query(q): Query, ) -> impl IntoResponse { let user_id = auth_user.id; let order_by = q.order_by.as_deref().unwrap_or("name").to_owned(); // If a cursor exists, validate that it matches the requested sort/direction. let cursor = q .decode_cursor() .filter(|c| c.order_by == order_by && c.reverse == q.reverse); let kinds = q.resource_kinds(); match favorites_service .list_resources_paged( user_id, q.limit_clamped(), cursor, &order_by, kinds.as_deref(), q.reverse, ) .await { Ok((rows, next_cursor)) => { let items: Vec = rows .into_iter() .map(|row| { // Path is only shown to the owner; non-owners see "" // to avoid leaking another user's folder hierarchy. let path = if row.is_owner { row.path.clone().unwrap_or_default() } else { String::new() }; if row.resource_type == "folder" { let dto = FolderDto { id: row.resource_id.to_string(), name: row.name.clone(), path, parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, icon_class: std::sync::Arc::from("fas fa-folder"), icon_special_class: std::sync::Arc::from("folder-icon"), category: std::sync::Arc::from("Folder"), }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::Folder, favorited_at: row.favorited_at, resource: ResourceContentDto::Folder(dto), } } else { let mime = row .mime_type .as_deref() .unwrap_or("application/octet-stream"); let size_bytes = row.size.max(0) as u64; let dto = FileDto { id: row.resource_id.to_string(), name: row.name.clone(), path, size: size_bytes, mime_type: std::sync::Arc::from(mime), folder_id: row.parent_id.map(|u| u.to_string()), created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)), icon_special_class: std::sync::Arc::from(icon_special_class_for( &row.name, mime, )), category: std::sync::Arc::from(category_for(&row.name, mime)), size_formatted: format_file_size(size_bytes), owner_id: Some(row.owner_id.to_string()), sort_date: None, etag: String::new(), }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::File, favorited_at: row.favorited_at, resource: ResourceContentDto::File(dto), } } }) .collect(); ( StatusCode::OK, Json(FavoritesResourcesDto::with_cursor(items, next_cursor)), ) .into_response() } Err(e) => AppError::from(e).into_response(), } } /// Add multiple items to favourites in a single transaction. /// POST /api/favorites/batch #[utoipa::path( post, path = "/api/favorites/batch", responses( (status = 200, description = "Batch add result", body = crate::application::dtos::favorites_dto::BatchFavoritesResult), (status = 400, description = "Invalid request") ), security(("bearerAuth" = [])), tag = "favorites" )] pub async fn batch_add_favorites( State(favorites_service): State>, auth_user: AuthUser, Json(body): Json, ) -> impl IntoResponse { let user_id = auth_user.id; if body.items.is_empty() { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "items array must not be empty" })), ) .into_response(); } // Validate item types for item in &body.items { if item.item_type != "file" && item.item_type != "folder" { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Item type must be 'file' or 'folder', got '{}'", item.item_type) })), ) .into_response(); } } let items: Vec<(String, String)> = body .items .into_iter() .map(|i| (i.item_id, i.item_type)) .collect(); match favorites_service .batch_add_to_favorites(user_id, &items) .await { Ok(result) => { info!( "Batch favourites: {} requested, {} inserted, {} already existed", result.stats.requested, result.stats.inserted, result.stats.already_existed ); (StatusCode::OK, Json(serde_json::json!(result))).into_response() } Err(err) => { error!("Error in batch add favorites: {}", err); ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to batch add favorites" })), ) .into_response() } } }