use std::collections::HashSet; use std::sync::Arc; use tracing::info; use uuid::Uuid; use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::favorites_dto::{ BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoriteResourceRow, FavoritesCursor, }; use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase}; use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::domain::services::authorization::ResourceKind; use crate::infrastructure::repositories::pg::FavoritesPgRepository; /// Implementation of the FavoritesUseCase for managing user favorites. /// /// Depends on `FavoritesRepositoryPort` (outbound port) instead of /// accessing the database directly, following hexagonal architecture. pub struct FavoritesService { repo: Arc, } impl FavoritesService { /// Create a new FavoritesService with the given repository port pub fn new(repo: Arc) -> Self { Self { repo } } } impl FavoritesUseCase for FavoritesService { /// Get all favorites for a user async fn get_favorites(&self, user_id: Uuid) -> Result> { info!("Getting favorites for user: {}", user_id); let favorites = self.repo.get_favorites(user_id).await?; info!( "Retrieved {} favorites for user {}", favorites.len(), user_id ); Ok(favorites) } /// Add an item to user's favorites async fn add_to_favorites(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<()> { info!( "Adding {} '{}' to favorites for user {}", item_type, item_id, user_id ); if item_type != "file" && item_type != "folder" { return Err(DomainError::new( ErrorKind::InvalidInput, "Favorites", "Item type must be 'file' or 'folder'", )); } self.repo.add_favorite(user_id, item_id, item_type).await?; info!( "Successfully added {} '{}' to favorites for user {}", item_type, item_id, user_id ); Ok(()) } /// Remove an item from user's favorites async fn remove_from_favorites( &self, user_id: Uuid, item_id: &str, item_type: &str, ) -> Result { info!( "Removing {} '{}' from favorites for user {}", item_type, item_id, user_id ); let removed = self .repo .remove_favorite(user_id, item_id, item_type) .await?; info!( "{} {} '{}' from favorites for user {}", if removed { "Successfully removed" } else { "Did not find" }, item_type, item_id, user_id ); Ok(removed) } /// Check if an item is in user's favorites async fn is_favorite(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result { info!( "Checking if {} '{}' is favorite for user {}", item_type, item_id, user_id ); self.repo.is_favorite(user_id, item_id, item_type).await } async fn batch_add_to_favorites( &self, user_id: Uuid, items: &[(String, String)], ) -> Result { info!( "Batch adding {} items to favorites for user {}", items.len(), user_id ); // Validate all item types for (item_id, item_type) in items { if item_type != "file" && item_type != "folder" { return Err(DomainError::new( ErrorKind::InvalidInput, "Favorites", format!( "Item type must be 'file' or 'folder' for item '{}'", item_id ), )); } } let requested = items.len(); let inserted = self.repo.add_favorites_batch(user_id, items).await?; let already_existed = requested as u64 - inserted; info!( "Batch favorites for user {}: {} requested, {} inserted, {} already existed", user_id, requested, inserted, already_existed ); // Return the full enriched list so the client can replace its cache let favorites = self.repo.get_favorites(user_id).await?; Ok(BatchFavoritesResult { stats: BatchFavoritesStats { requested, inserted, already_existed, }, favorites, }) } async fn batch_check_favorites( &self, user_id: Uuid, item_ids: &[(&str, &str)], ) -> Result> { self.repo.batch_check_favorites(user_id, item_ids).await } } impl FavoritesService { /// Cursor-paginated list of the user's favorited resources. /// /// No authz needed — favorites are strictly user-scoped; the repository /// enforces `WHERE user_id = $1` so users can only see their own entries. /// /// Returns `(rows, next_cursor_encoded)`. pub async fn list_resources_paged( &self, user_id: Uuid, limit: usize, cursor: Option, order_by: &str, kinds: Option<&[ResourceKind]>, reverse: bool, ) -> Result<(Vec, Option)> { // Fetch one extra row to detect whether a next page exists. let mut rows = self .repo .list_resources_paged( user_id, limit + 1, cursor.as_ref(), order_by, kinds, reverse, ) .await?; let next_cursor = if rows.len() > limit { let last = &rows[limit - 1]; let c = build_favorites_cursor(last, order_by, reverse); rows.truncate(limit); Some(c.encode()) } else { None }; Ok((rows, next_cursor)) } } /// Build the next-page cursor from the last row of the current page. /// `reverse` is stored in the cursor so subsequent pages use the same direction. fn build_favorites_cursor( row: &FavoriteResourceRow, order_by: &str, reverse: bool, ) -> FavoritesCursor { match order_by { "type" => FavoritesCursor { order_by: "type".to_owned(), resource_id: row.resource_id, sort_str: row.sort_str.clone(), // LOWER(name) sort_int: row.sort_int, // type_order sort_ts: None, reverse, }, "favorited_at" => FavoritesCursor { order_by: "favorited_at".to_owned(), resource_id: row.resource_id, sort_str: None, sort_int: None, sort_ts: row.sort_ts, // favorited_at timestamp reverse, }, "modified_at" => FavoritesCursor { order_by: "modified_at".to_owned(), resource_id: row.resource_id, sort_str: None, sort_int: None, sort_ts: row.sort_ts, // modified_at timestamp reverse, }, "size" => FavoritesCursor { order_by: "size".to_owned(), resource_id: row.resource_id, sort_str: None, sort_int: row.sort_int, // file size in bytes sort_ts: None, reverse, }, "owner" => FavoritesCursor { order_by: "owner".to_owned(), resource_id: row.resource_id, sort_str: row.sort_str.clone(), // LOWER(username) sort_int: None, sort_ts: row.sort_ts, // favorited_at (secondary sort) reverse, }, _ => FavoritesCursor { // "name" (default): sort_str = LOWER(name), sort_int = folder_first (0 = folder, 1 = file) order_by: "name".to_owned(), resource_id: row.resource_id, sort_str: row.sort_str.clone(), sort_int: row.sort_int, // folder_first sort_ts: None, reverse, }, } }