feat(resources): add cursor, group by on /api/favorites/resources

This commit is contained in:
Edouard Vanbelle
2026-05-28 10:08:38 +02:00
parent 5790a1459f
commit 607ae5e6df
15 changed files with 1265 additions and 209 deletions
+119 -1
View File
@@ -1,10 +1,14 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use super::cursor::{CursorListResponse, CursorQuery, PageCursor};
use super::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use super::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::domain::services::authorization::ResourceKind;
/// DTO for favorites item, enriched with item metadata via SQL JOIN
/// so the frontend does not need N+1 requests to resolve names/sizes.
@@ -104,6 +108,120 @@ pub struct BatchFavoritesResult {
pub favorites: Vec<FavoriteItemDto>,
}
// ════════════════════════════════════════════════════════════════════════════
// Cursor-paginated favorites resources (GET /api/favorites/resources)
// ════════════════════════════════════════════════════════════════════════════
/// Raw row returned by the UNION ALL query that joins `auth.user_favorites`
/// with `storage.files` / `storage.folders`. Never serialised directly.
pub struct FavoriteResourceRow {
pub resource_type: String, // "file" | "folder"
pub resource_id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub mime_type: Option<String>,
/// `-1` for folders, actual byte-count for files.
pub size: i64,
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub favorited_at: DateTime<Utc>,
/// Human-readable path (e.g. `Documents/Work` for a folder,
/// `Documents/Work/report.pdf` for a file). Always populated; the
/// handler clears it to `""` when `is_owner` is false.
pub path: Option<String>,
// Pre-computed sort fields for cursor construction.
pub sort_str: Option<String>,
pub sort_int: Option<i64>,
pub sort_ts: Option<DateTime<Utc>>,
}
/// Opaque keyset-pagination cursor for `GET /api/favorites/resources`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FavoritesCursor {
/// Sort dimension active when this cursor was produced.
/// Values: `"name"` (default), `"type"`, `"favorited_at"`, `"modified_at"`,
/// `"size"`, `"owner"`.
#[serde(default = "FavoritesCursor::default_order")]
pub order_by: String,
/// UUID of the last item on the previous page (tie-breaker).
pub resource_id: Uuid,
/// `LOWER(name)` for `name`/`type` sorts; `LOWER(username)` for `owner`.
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_str: Option<String>,
/// Multipurpose integer: `folder_first` for `name`, `type_order` for `type`,
/// size in bytes for `size`.
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_int: Option<i64>,
/// Timestamp for `favorited_at` and `modified_at` sorts.
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_ts: Option<DateTime<Utc>>,
/// Whether the result set was reversed — must match on every page.
#[serde(default)]
pub reverse: bool,
}
impl FavoritesCursor {
fn default_order() -> String {
"name".to_owned()
}
}
impl PageCursor for FavoritesCursor {}
/// Query parameters for `GET /api/favorites/resources`.
#[derive(Debug, Deserialize, IntoParams)]
pub struct FavoritesResourcesQuery {
/// Maximum items per page (1–200, default 50).
#[serde(default = "CursorQuery::default_limit")]
pub limit: u32,
/// Opaque cursor from a previous response. Omit to start from the first page.
pub cursor: Option<String>,
/// Sort / group-by dimension. Supported: `"name"` (default), `"type"`,
/// `"favorited_at"`, `"modified_at"`, `"size"`, `"owner"`.
pub order_by: Option<String>,
/// Comma-separated resource types to include, e.g. `"file,folder"`.
/// Omit to include both.
pub resource_types: Option<String>,
/// Reverse the sort order. Default `false`.
#[serde(default)]
pub reverse: bool,
}
impl FavoritesResourcesQuery {
pub fn limit_clamped(&self) -> usize {
self.limit.clamp(1, 200) as usize
}
pub fn decode_cursor(&self) -> Option<FavoritesCursor> {
self.cursor.as_deref().and_then(FavoritesCursor::decode)
}
/// Returns `None` when `resource_types` is absent (= include all).
pub fn resource_kinds(&self) -> Option<Vec<ResourceKind>> {
self.resource_types.as_deref().map(|s| {
s.split(',')
.filter_map(|t| ResourceKind::parse(t.trim()))
.collect()
})
}
}
/// One item in a `GET /api/favorites/resources` page.
#[derive(Debug, Serialize, ToSchema)]
pub struct FavoritesResourceItemDto {
pub resource_type: ResourceTypeDto,
/// When the resource was added to the user's favorites.
pub favorited_at: DateTime<Utc>,
/// Full resource details — shape determined by `resource_type`.
pub resource: ResourceContentDto,
}
/// Response envelope for `GET /api/favorites/resources`.
pub type FavoritesResourcesDto = CursorListResponse<FavoritesResourceItemDto>;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchFavoritesStats {
/// How many items were requested
+17 -1
View File
@@ -2,8 +2,11 @@ use std::collections::HashSet;
use uuid::Uuid;
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto};
use crate::application::dtos::favorites_dto::{
BatchFavoritesResult, FavoriteItemDto, FavoriteResourceRow, FavoritesCursor,
};
use crate::common::errors::Result;
use crate::domain::services::authorization::ResourceKind;
/// Defines operations for managing user favorites
pub trait FavoritesUseCase: Send + Sync {
@@ -74,4 +77,17 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
user_id: Uuid,
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
) -> Result<HashSet<String>>;
/// Cursor-paginated list of a user's favorited resources.
/// Items that no longer exist (deleted/trashed) are silently excluded.
/// `kinds = None` → both files and folders.
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>>;
}
+106 -1
View File
@@ -4,11 +4,14 @@ 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,
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.
@@ -155,3 +158,105 @@ impl FavoritesUseCase for FavoritesService {
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,
},
}
}
@@ -4,9 +4,12 @@ use std::sync::Arc;
use tracing::error;
use uuid::Uuid;
use crate::application::dtos::favorites_dto::FavoriteItemDto;
use crate::application::dtos::favorites_dto::{
FavoriteItemDto, FavoriteResourceRow, FavoritesCursor,
};
use crate::application::ports::favorites_ports::FavoritesRepositoryPort;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::services::authorization::ResourceKind;
/// PostgreSQL implementation of the favorites persistence port.
pub struct FavoritesPgRepository {
@@ -279,4 +282,308 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
}
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>> {
let include_folders =
kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::Folder)));
let include_files = kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::File)));
// ── Build the UNION ALL CTE ─────────────────────────────────────────
let mut cte_branches: Vec<&str> = Vec::new();
let folder_branch = r#"
SELECT
'folder'::text AS resource_type,
fld.id AS resource_id,
fld.name,
fld.parent_id,
NULL::text AS mime_type,
-1::bigint AS size,
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
(fld.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at,
fld.path::text AS resource_path,
LOWER(fld.name) AS sort_str,
0::bigint AS type_order,
0::int AS folder_first
FROM auth.user_favorites uf
INNER JOIN storage.folders fld
ON fld.id = uf.item_id::UUID AND NOT fld.is_trashed
WHERE uf.user_id = $1::uuid AND uf.item_type = 'folder'"#;
let file_branch = r#"
SELECT
'file'::text AS resource_type,
f.id AS resource_id,
f.name,
f.folder_id AS parent_id,
f.mime_type,
f.size::bigint,
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
(f.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
LOWER(f.name) AS sort_str,
f.category_order::bigint AS type_order,
1::int AS folder_first
FROM auth.user_favorites uf
INNER JOIN storage.files f
ON f.id = uf.item_id::UUID AND NOT f.is_trashed
LEFT JOIN storage.folders pfld
ON pfld.id = f.folder_id
WHERE uf.user_id = $1::uuid AND uf.item_type = 'file'"#;
if include_folders {
cte_branches.push(folder_branch);
}
if include_files {
cte_branches.push(file_branch);
}
if cte_branches.is_empty() {
return Ok(Vec::new());
}
let union_sql = cte_branches.join("\n UNION ALL\n");
let cte = format!("WITH resources AS ({union_sql}\n)");
// ── Cursor values ───────────────────────────────────────────────────
let cur_str: Option<&str> = cursor.and_then(|c| c.sort_str.as_deref());
let cur_int: Option<i64> = cursor.and_then(|c| c.sort_int);
let cur_ts: Option<chrono::DateTime<chrono::Utc>> = cursor.and_then(|c| c.sort_ts);
let cur_id: Option<Uuid> = cursor.map(|c| c.resource_id);
// ── Per-dimension keyset WHERE + ORDER BY ───────────────────────────
// Binds: $1=user_id (in CTE), $2=cur_str, $3=cur_int, $4=cur_ts,
// $5=cur_id, $6=limit (for "owner" sort: JOIN uses no extra binds)
let (keyset, order_by_clause, need_user_join) = match (order_by, reverse) {
// ── name ────────────────────────────────────────────────────────
("name", false) => (
"WHERE ($3::bigint IS NULL)
OR (folder_first::bigint > $3)
OR (folder_first::bigint = $3 AND sort_str > $2)
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)",
"ORDER BY folder_first ASC, sort_str ASC, resource_id ASC",
false,
),
("name", true) => (
"WHERE ($3::bigint IS NULL)
OR (folder_first::bigint > $3)
OR (folder_first::bigint = $3 AND sort_str < $2)
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)",
"ORDER BY folder_first ASC, sort_str DESC, resource_id DESC",
false,
),
// ── type ────────────────────────────────────────────────────────
("type", false) => (
"WHERE ($3::bigint IS NULL)
OR (type_order > $3)
OR (type_order = $3 AND sort_str > $2)
OR (type_order = $3 AND sort_str = $2 AND resource_id > $5::uuid)",
"ORDER BY type_order ASC, sort_str ASC, resource_id ASC",
false,
),
("type", true) => (
"WHERE ($3::bigint IS NULL)
OR (type_order < $3)
OR (type_order = $3 AND sort_str < $2)
OR (type_order = $3 AND sort_str = $2 AND resource_id < $5::uuid)",
"ORDER BY type_order DESC, sort_str DESC, resource_id DESC",
false,
),
// ── favorited_at ─────────────────────────────────────────────────
("favorited_at", false) => (
"WHERE ($4::timestamptz IS NULL)
OR (favorited_at < $4)
OR (favorited_at = $4 AND resource_id < $5::uuid)",
"ORDER BY favorited_at DESC, resource_id DESC",
false,
),
("favorited_at", true) => (
"WHERE ($4::timestamptz IS NULL)
OR (favorited_at > $4)
OR (favorited_at = $4 AND resource_id > $5::uuid)",
"ORDER BY favorited_at ASC, resource_id ASC",
false,
),
// ── modified_at ──────────────────────────────────────────────────
("modified_at", false) => (
"WHERE ($4::timestamptz IS NULL)
OR (modified_at < $4)
OR (modified_at = $4 AND resource_id < $5::uuid)",
"ORDER BY modified_at DESC, resource_id DESC",
false,
),
("modified_at", true) => (
"WHERE ($4::timestamptz IS NULL)
OR (modified_at > $4)
OR (modified_at = $4 AND resource_id > $5::uuid)",
"ORDER BY modified_at ASC, resource_id ASC",
false,
),
// ── size ─────────────────────────────────────────────────────────
("size", false) => (
"WHERE ($3::bigint IS NULL)
OR (size > $3)
OR (size = $3 AND resource_id > $5::uuid)",
"ORDER BY size ASC, resource_id ASC",
false,
),
("size", true) => (
"WHERE ($3::bigint IS NULL)
OR (size < $3)
OR (size = $3 AND resource_id < $5::uuid)",
"ORDER BY size DESC, resource_id DESC",
false,
),
// ── owner ────────────────────────────────────────────────────────
("owner", false) => (
"WHERE ($2::text IS NULL)
OR (LOWER(u.username) > $2)
OR (LOWER(u.username) = $2 AND favorited_at < $4)
OR (LOWER(u.username) = $2 AND favorited_at = $4 AND resource_id < $5::uuid)",
"ORDER BY LOWER(u.username) ASC, favorited_at DESC, resource_id DESC",
true,
),
("owner", true) => (
"WHERE ($2::text IS NULL)
OR (LOWER(u.username) < $2)
OR (LOWER(u.username) = $2 AND favorited_at > $4)
OR (LOWER(u.username) = $2 AND favorited_at = $4 AND resource_id > $5::uuid)",
"ORDER BY LOWER(u.username) DESC, favorited_at ASC, resource_id ASC",
true,
),
// ── default: same as name, ascending ─────────────────────────────
(_, false) => (
"WHERE ($3::bigint IS NULL)
OR (folder_first::bigint > $3)
OR (folder_first::bigint = $3 AND sort_str > $2)
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id > $5::uuid)",
"ORDER BY folder_first ASC, sort_str ASC, resource_id ASC",
false,
),
(_, true) => (
"WHERE ($3::bigint IS NULL)
OR (folder_first::bigint > $3)
OR (folder_first::bigint = $3 AND sort_str < $2)
OR (folder_first::bigint = $3 AND sort_str = $2 AND resource_id < $5::uuid)",
"ORDER BY folder_first ASC, sort_str DESC, resource_id DESC",
false,
),
};
let user_join = if need_user_join {
"LEFT JOIN auth.users u ON u.id = r.owner_id"
} else {
""
};
// For "owner" sort the JOIN makes LOWER(u.username) available; add it to SELECT
// so the cursor can carry the correct sort key.
let username_col = if need_user_join {
",\n LOWER(u.username) AS username_lower"
} else {
""
};
let sql = format!(
"{cte}
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.is_owner, r.favorited_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
{user_join}
{keyset}
{order_by_clause}
LIMIT $6"
);
let rows = sqlx::query(&sql)
.bind(user_id) // $1 (in CTE + outer)
.bind(cur_str) // $2
.bind(cur_int) // $3
.bind(cur_ts) // $4
.bind(cur_id) // $5
.bind(limit as i64) // $6
.fetch_all(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error listing favorite resources: {e}");
DomainError::new(
ErrorKind::InternalError,
"Favorites",
format!("Failed to list favorite resources: {e}"),
)
})?;
let result = rows
.iter()
.map(|row| {
let resource_type: String = row.get("resource_type");
let sort_str_val: Option<String> = row.try_get("sort_str").ok();
let type_order: i64 = row.try_get("type_order").unwrap_or(0);
let folder_first: i32 = row.try_get("folder_first").unwrap_or(0);
let size: i64 = row.get("size");
// Pre-compute the cursor sort fields based on order_by
let (c_sort_str, c_sort_int, c_sort_ts) = match order_by {
"name" => (sort_str_val, Some(folder_first as i64), None),
"type" => (sort_str_val, Some(type_order), None),
"size" => (None, Some(size), None),
"favorited_at" => {
let ts: Option<chrono::DateTime<chrono::Utc>> =
row.try_get("favorited_at").ok();
(None, None, ts)
}
"modified_at" => {
let ts: Option<chrono::DateTime<chrono::Utc>> =
row.try_get("modified_at").ok();
(None, None, ts)
}
"owner" => {
// For "owner" sort the JOIN added LOWER(u.username) AS username_lower.
// The cursor's sort_str must carry the username (not the file name).
let username: Option<String> = row.try_get("username_lower").ok();
let ts: Option<chrono::DateTime<chrono::Utc>> =
row.try_get("favorited_at").ok();
(username, None, ts)
}
_ => (sort_str_val, Some(folder_first as i64), None),
};
FavoriteResourceRow {
resource_type,
resource_id: row.get("resource_id"),
name: row.get("name"),
parent_id: row.try_get("parent_id").ok(),
mime_type: row.try_get("mime_type").ok(),
size,
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"),
path: row.try_get("resource_path").ok(),
sort_str: c_sort_str,
sort_int: c_sort_int,
sort_ts: c_sort_ts,
}
})
.collect();
Ok(result)
}
}
@@ -1,6 +1,6 @@
use axum::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
};
@@ -9,8 +9,18 @@ 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.
@@ -27,11 +37,16 @@ pub struct BatchFavoritesRequest {
}
/// 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", body = Vec<crate::application::dtos::favorites_dto::FavoriteItemDto>)
(status = 200, description = "List of favorites (deprecated — use /api/favorites/resources)", body = Vec<crate::application::dtos::favorites_dto::FavoriteItemDto>)
),
security(("bearerAuth" = [])),
tag = "favorites"
@@ -178,6 +193,125 @@ pub async fn remove_favorite(
}
}
/// 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" {
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(
+6 -2
View File
@@ -331,10 +331,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Create a router without the i18n routes
// Create routes for favorites if the service is available
let favorites_router = if let Some(favorites_service) = favorites_service.clone() {
use crate::interfaces::api::handlers::favorites_handler;
#[allow(deprecated)]
use crate::interfaces::api::handlers::favorites_handler::{
self, get_favorites, list_favorites_resources,
};
Router::new()
.route("/", get(favorites_handler::get_favorites))
.route("/", get(get_favorites)) // deprecated, kept for compat
.route("/resources", get(list_favorites_resources)) // new cursor-paginated endpoint
.route("/batch", post(favorites_handler::batch_add_favorites))
.route(
"/{item_type}/{item_id}",