Files
Oxicloud/src/application/services/favorites_service.rs
T
Claude 9ccaeef0ab perf(listing): return per-item is_favorite/is_shared, drop client badge fetches
The folder listing now carries the favorite/share badge state for exactly the
items it returns, so the files browser stops fetching favorites and outgoing
shares separately. This removes the last per-navigation badge round-trips AND
fixes the correctness hole of the previous approaches: badges were derived from
only the first 200 global favorites / shares, so a favorited or shared item
outside that window showed no badge. Now every listed item is correct, and the
work is scoped to the items on screen.

Backend (`GET /api/folders/{id}/listing`):
- `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing-
  level metadata, so no churn to the many FileDto/FolderDto constructors.
- The handler computes both with two batched, index-backed queries run
  concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and
  `PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY,
  which already covers public links as 'token' grants — same membership the
  /grants/outgoing/resources endpoint exposes). Both fold into the ETag.
- Public-share browsing passes empty sets (anonymous, read-only context).

Frontend:
- `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local
  badge sets straight from the listing and updates them optimistically on
  favorite toggle / batch / share creation (via ShareDialog's `onshared`).
- Removes the session `badges` store + its fetches entirely — the listing is now
  the single, authoritative, fetch-free source.

Net: favorite/share badges cost zero extra client requests per navigation and
are correct regardless of how many favorites/shares the user has. Validated:
cargo check + clippy -D warnings (backend; integration tests need Postgres,
unavailable here), frontend npm run check + unit tests, and a headless render of
the real files route (list + grid) with the new flags present — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 15:21:01 +00:00

274 lines
8.5 KiB
Rust

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<FavoritesPgRepository>,
}
impl FavoritesService {
/// Create a new FavoritesService with the given repository port
pub fn new(repo: Arc<FavoritesPgRepository>) -> Self {
Self { repo }
}
/// Subset of `(item_id, item_type)` pairs the user has favorited — used to
/// stamp star badges onto a folder listing in one batched query (no N+1, no
/// global page fetch).
pub async fn favorited_ids(
&self,
user_id: Uuid,
items: &[(&str, &str)],
) -> Result<HashSet<String>> {
self.repo.batch_check_favorites(user_id, items).await
}
}
impl FavoritesUseCase for FavoritesService {
/// Get all favorites for a user
async fn get_favorites(&self, user_id: Uuid) -> Result<Vec<FavoriteItemDto>> {
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<bool> {
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<bool> {
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<BatchFavoritesResult> {
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<HashSet<String>> {
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<FavoritesCursor>,
order_by: &str,
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<(Vec<FavoriteResourceRow>, Option<String>)> {
// 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,
},
}
}