2026-02-21 13:33:18 +01:00
use axum ::{
Json ,
2026-05-28 10:08:38 +02:00
extract ::{ Path , Query , State },
2026-02-21 13:33:18 +01:00
http ::StatusCode ,
response ::IntoResponse ,
};
2026-02-16 09:17:54 +01:00
use serde ::Deserialize ;
2026-02-14 01:29:34 +01:00
use std ::sync ::Arc ;
2026-05-29 12:54:09 +02:00
use tracing ::{ error , info , warn };
2026-03-29 18:49:10 +02:00
use utoipa ::ToSchema ;
2025-04-02 03:43:44 +02:00
2026-05-28 10:08:38 +02:00
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 };
2025-04-02 03:43:44 +02:00
use crate ::application ::ports ::favorites_ports ::FavoritesUseCase ;
2026-03-03 15:36:42 +00:00
use crate ::application ::services ::favorites_service ::FavoritesService ;
2026-06-06 19:51:51 +02:00
use crate ::domain ::entities ::file ::File ;
2026-05-28 10:08:38 +02:00
use crate ::interfaces ::errors ::AppError ;
2026-03-04 23:55:08 +01:00
use crate ::interfaces ::middleware ::auth ::AuthUser ;
2025-04-02 03:43:44 +02:00
2026-02-16 09:17:54 +01:00
/// Single item in a batch-add-favorites request.
2026-03-29 18:49:10 +02:00
#[derive(Debug, Deserialize, ToSchema)]
2026-02-16 09:17:54 +01:00
pub struct BatchFavoriteItem {
pub item_id : String ,
pub item_type : String ,
}
/// Request body for POST /api/favorites/batch
2026-03-29 18:49:10 +02:00
#[derive(Debug, Deserialize, ToSchema)]
2026-02-16 09:17:54 +01:00
pub struct BatchFavoritesRequest {
pub items : Vec < BatchFavoriteItem > ,
}
2025-04-02 03:43:44 +02:00
/// Handler for favorite-related API endpoints
2026-05-28 10:08:38 +02:00
///
/// # 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" ]
2026-03-29 18:49:10 +02:00
#[utoipa::path(
get,
path = "/api/favorites" ,
responses(
2026-05-28 10:08:38 +02:00
(status = 200, description = "List of favorites (deprecated — use /api/favorites/resources)" , body = Vec<crate::application::dtos::favorites_dto::FavoriteItemDto>)
2026-03-29 18:49:10 +02:00
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-03-29 18:49:10 +02:00
tag = "favorites"
)]
2025-04-02 03:43:44 +02:00
pub async fn get_favorites (
2026-03-03 15:36:42 +00:00
State ( favorites_service ) : State < Arc < FavoritesService >> ,
2026-02-07 04:02:38 +01:00
auth_user : AuthUser ,
2025-04-02 03:43:44 +02:00
) -> impl IntoResponse {
2026-03-07 14:59:32 +01:00
let user_id = auth_user . id ;
2026-05-29 13:10:13 +02:00
warn! (
"Deprecated endpoint called: GET /api/favorites — use GET /api/favorites/resources instead"
);
2026-02-14 01:29:34 +01:00
2025-04-02 03:43:44 +02:00
match favorites_service . get_favorites ( user_id ). await {
Ok ( favorites ) => {
2026-04-26 02:27:45 +02:00
info! (
"Retrieved {} favorites for user {}" ,
favorites . len (),
auth_user . id
);
2025-04-02 03:43:44 +02:00
( StatusCode ::OK , Json ( serde_json ::json! ( favorites ))). into_response ()
2026-02-14 01:29:34 +01:00
}
2025-04-02 03:43:44 +02:00
Err ( err ) => {
error! ( "Error retrieving favorites: {}" , err );
(
2026-02-14 01:29:34 +01:00
StatusCode ::INTERNAL_SERVER_ERROR ,
2025-04-02 03:43:44 +02:00
Json ( serde_json ::json! ({
2026-03-05 13:15:34 +01:00
"error" : "Failed to retrieve favorites"
2026-02-14 01:29:34 +01:00
})),
)
. into_response ()
2025-04-02 03:43:44 +02:00
}
}
}
/// Add an item to user's favorites
2026-03-29 18:49:10 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-03-29 18:49:10 +02:00
tag = "favorites"
)]
2025-04-02 03:43:44 +02:00
pub async fn add_favorite (
2026-03-03 15:36:42 +00:00
State ( favorites_service ) : State < Arc < FavoritesService >> ,
2026-02-07 04:02:38 +01:00
auth_user : AuthUser ,
2025-04-02 03:43:44 +02:00
Path (( item_type , item_id )) : Path < ( String , String ) > ,
) -> impl IntoResponse {
2026-03-07 14:59:32 +01:00
let user_id = auth_user . id ;
2026-02-14 01:29:34 +01:00
2025-04-02 03:43:44 +02:00
// Validate item_type
if item_type != "file" && item_type != "folder" {
return (
2026-02-14 01:29:34 +01:00
StatusCode ::BAD_REQUEST ,
2025-04-02 03:43:44 +02:00
Json ( serde_json ::json! ({
"error" : "Item type must be 'file' or 'folder'"
2026-02-14 01:29:34 +01:00
})),
2025-04-02 03:43:44 +02:00
);
}
2026-02-14 01:29:34 +01:00
match favorites_service
. add_to_favorites ( user_id , & item_id , & item_type )
. await
{
2025-04-02 03:43:44 +02:00
Ok ( _ ) => {
info! ( "Added {} '{}' to favorites" , item_type , item_id );
(
2026-02-14 01:29:34 +01:00
StatusCode ::CREATED ,
2025-04-02 03:43:44 +02:00
Json ( serde_json ::json! ({
"message" : "Item added to favorites"
2026-02-14 01:29:34 +01:00
})),
2025-04-02 03:43:44 +02:00
)
2026-02-14 01:29:34 +01:00
}
2025-04-02 03:43:44 +02:00
Err ( err ) => {
error! ( "Error adding to favorites: {}" , err );
(
2026-02-14 01:29:34 +01:00
StatusCode ::INTERNAL_SERVER_ERROR ,
2025-04-02 03:43:44 +02:00
Json ( serde_json ::json! ({
2026-03-05 13:15:34 +01:00
"error" : "Failed to add to favorites"
2026-02-14 01:29:34 +01:00
})),
2025-04-02 03:43:44 +02:00
)
}
}
}
/// Remove an item from user's favorites
2026-03-29 18:49:10 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-03-29 18:49:10 +02:00
tag = "favorites"
)]
2025-04-02 03:43:44 +02:00
pub async fn remove_favorite (
2026-03-03 15:36:42 +00:00
State ( favorites_service ) : State < Arc < FavoritesService >> ,
2026-02-07 04:02:38 +01:00
auth_user : AuthUser ,
2025-04-02 03:43:44 +02:00
Path (( item_type , item_id )) : Path < ( String , String ) > ,
) -> impl IntoResponse {
2026-03-07 14:59:32 +01:00
let user_id = auth_user . id ;
2026-02-14 01:29:34 +01:00
match favorites_service
. remove_from_favorites ( user_id , & item_id , & item_type )
. await
{
2025-04-02 03:43:44 +02:00
Ok ( removed ) => {
if removed {
info! ( "Removed {} '{}' from favorites" , item_type , item_id );
(
2026-02-14 01:29:34 +01:00
StatusCode ::OK ,
2025-04-02 03:43:44 +02:00
Json ( serde_json ::json! ({
"message" : "Item removed from favorites"
2026-02-14 01:29:34 +01:00
})),
2025-04-02 03:43:44 +02:00
)
} else {
info! ( "Item {} '{}' was not in favorites" , item_type , item_id );
(
2026-02-14 01:29:34 +01:00
StatusCode ::NOT_FOUND ,
2025-04-02 03:43:44 +02:00
Json ( serde_json ::json! ({
"message" : "Item was not in favorites"
2026-02-14 01:29:34 +01:00
})),
2025-04-02 03:43:44 +02:00
)
}
2026-02-14 01:29:34 +01:00
}
2025-04-02 03:43:44 +02:00
Err ( err ) => {
error! ( "Error removing from favorites: {}" , err );
(
2026-02-14 01:29:34 +01:00
StatusCode ::INTERNAL_SERVER_ERROR ,
2025-04-02 03:43:44 +02:00
Json ( serde_json ::json! ({
2026-03-05 13:15:34 +01:00
"error" : "Failed to remove from favorites"
2026-02-14 01:29:34 +01:00
})),
2025-04-02 03:43:44 +02:00
)
}
}
2026-02-14 01:29:34 +01:00
}
2026-02-16 09:17:54 +01:00
2026-05-28 10:08:38 +02:00
/// 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 < Arc < FavoritesService >> ,
auth_user : AuthUser ,
Query ( q ) : Query < FavoritesResourcesQuery > ,
) -> 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 < FavoritesResourceItemDto > = 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" {
2026-06-06 15:37:27 +02:00
let resource_id = row . resource_id . to_string ();
2026-05-28 10:08:38 +02:00
let dto = FolderDto {
2026-06-06 15:37:27 +02:00
etag : resource_id . clone (),
id : resource_id ,
2026-05-28 10:08:38 +02:00
name : row . name . clone (),
path ,
parent_id : row . parent_id . map ( | u | u . to_string ()),
owner_id : Some ( row . owner_id . to_string ()),
2026-06-19 07:49:33 +02:00
// Listing handler — drive_id is informational
// and the favorites row doesn't currently
// SELECT it. Path-based lookups never enter
// this code path.
drive_id : uuid ::Uuid ::nil (),
2026-05-28 10:08:38 +02:00
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 ;
2026-06-06 19:51:51 +02:00
// Route ETag through `File::compute_etag` so
// this listing's `etag` byte-equals what
// GET/HEAD/PROPFIND would return for the same
// file. `blob_hash` is `None` only for
// folder rows, which take the other branch.
let modified_at_u = row . modified_at . timestamp () as u64 ;
let content_hash = row . blob_hash . clone (). unwrap_or_default ();
let etag = if content_hash . is_empty () {
String ::new ()
} else {
File ::compute_etag ( & content_hash , modified_at_u )
};
2026-05-28 10:08:38 +02:00
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 ,
2026-06-06 19:51:51 +02:00
modified_at : modified_at_u ,
2026-05-28 10:08:38 +02:00
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 ,
2026-06-06 19:51:51 +02:00
content_hash ,
etag ,
2026-05-28 10:08:38 +02:00
};
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 (),
}
}
2026-02-16 09:17:54 +01:00
/// Add multiple items to favourites in a single transaction.
/// POST /api/favorites/batch
2026-03-29 18:49:10 +02:00
#[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" )
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-03-29 18:49:10 +02:00
tag = "favorites"
)]
2026-02-16 09:17:54 +01:00
pub async fn batch_add_favorites (
2026-03-03 15:36:42 +00:00
State ( favorites_service ) : State < Arc < FavoritesService >> ,
2026-02-16 09:17:54 +01:00
auth_user : AuthUser ,
Json ( body ) : Json < BatchFavoritesRequest > ,
) -> impl IntoResponse {
2026-03-07 14:59:32 +01:00
let user_id = auth_user . id ;
2026-02-16 09:17:54 +01:00
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! ({
2026-03-05 13:15:34 +01:00
"error" : "Failed to batch add favorites"
2026-02-16 09:17:54 +01:00
})),
)
. into_response ()
}
}
}