feat(recent): add cursor + groupby support
This commit is contained in:
@@ -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 recent items, enriched with item metadata via SQL JOIN
|
||||
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
||||
@@ -84,3 +88,109 @@ impl RecentItemDto {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Cursor-paginated recent resources (GET /api/recent/resources)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Raw row returned by the UNION ALL query for `/api/recent/resources`.
|
||||
pub struct RecentResourceRow {
|
||||
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 accessed_at: DateTime<Utc>,
|
||||
/// Human-readable path. Always populated in the row; 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/recent/resources`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecentCursor {
|
||||
/// Sort dimension active when this cursor was produced.
|
||||
/// Values: `"accessed_at"` (default), `"name"`, `"type"`, `"modified_at"`, `"size"`, `"owner"`.
|
||||
#[serde(default = "RecentCursor::default_order")]
|
||||
pub order_by: String,
|
||||
/// UUID of the last item on the previous page (tie-breaker).
|
||||
pub resource_id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_str: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_int: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_ts: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
impl RecentCursor {
|
||||
fn default_order() -> String {
|
||||
"accessed_at".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl PageCursor for RecentCursor {}
|
||||
|
||||
/// Query parameters for `GET /api/recent/resources`.
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct RecentResourcesQuery {
|
||||
/// 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: `"accessed_at"` (default), `"name"`,
|
||||
/// `"type"`, `"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 RecentResourcesQuery {
|
||||
pub fn limit_clamped(&self) -> usize {
|
||||
self.limit.clamp(1, 200) as usize
|
||||
}
|
||||
|
||||
pub fn decode_cursor(&self) -> Option<RecentCursor> {
|
||||
self.cursor.as_deref().and_then(RecentCursor::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/recent/resources` page.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct RecentResourceItemDto {
|
||||
pub resource_type: ResourceTypeDto,
|
||||
/// When the resource was last accessed.
|
||||
pub accessed_at: DateTime<Utc>,
|
||||
/// Full resource details — shape determined by `resource_type`.
|
||||
pub resource: ResourceContentDto,
|
||||
}
|
||||
|
||||
/// Response envelope for `GET /api/recent/resources`.
|
||||
pub type RecentResourcesDto = CursorListResponse<RecentResourceItemDto>;
|
||||
|
||||
@@ -51,4 +51,15 @@ pub trait RecentItemsRepositoryPort: Send + Sync + 'static {
|
||||
|
||||
/// Removes items exceeding `max_items` (the oldest ones).
|
||||
async fn prune(&self, user_id: Uuid, max_items: i32) -> Result<()>;
|
||||
|
||||
/// List recent items with cursor pagination, sorting, and optional type filter.
|
||||
async fn list_resources_paged(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
limit: usize,
|
||||
cursor: Option<&crate::application::dtos::recent_dto::RecentCursor>,
|
||||
order_by: &str,
|
||||
kinds: Option<&[crate::domain::services::authorization::ResourceKind]>,
|
||||
reverse: bool,
|
||||
) -> Result<Vec<crate::application::dtos::recent_dto::RecentResourceRow>>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::application::dtos::recent_dto::RecentItemDto;
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow};
|
||||
use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
@@ -109,3 +111,99 @@ impl RecentItemsUseCase for RecentService {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RecentService {
|
||||
/// No authz needed — recent items 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<RecentCursor>,
|
||||
order_by: &str,
|
||||
kinds: Option<&[ResourceKind]>,
|
||||
reverse: bool,
|
||||
) -> Result<(Vec<RecentResourceRow>, 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_recent_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_recent_cursor(row: &RecentResourceRow, order_by: &str, reverse: bool) -> RecentCursor {
|
||||
match order_by {
|
||||
"name" => RecentCursor {
|
||||
order_by: "name".to_owned(),
|
||||
resource_id: row.resource_id,
|
||||
sort_str: row.sort_str.clone(), // LOWER(name)
|
||||
sort_int: row.sort_int, // folder_first
|
||||
sort_ts: None,
|
||||
reverse,
|
||||
},
|
||||
"type" => RecentCursor {
|
||||
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,
|
||||
},
|
||||
"modified_at" => RecentCursor {
|
||||
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" => RecentCursor {
|
||||
order_by: "size".to_owned(),
|
||||
resource_id: row.resource_id,
|
||||
sort_str: None,
|
||||
sort_int: row.sort_int, // size in bytes
|
||||
sort_ts: None,
|
||||
reverse,
|
||||
},
|
||||
"owner" => RecentCursor {
|
||||
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, // accessed_at timestamp (secondary)
|
||||
reverse,
|
||||
},
|
||||
_ => RecentCursor {
|
||||
// default: accessed_at DESC
|
||||
order_by: "accessed_at".to_owned(),
|
||||
resource_id: row.resource_id,
|
||||
sort_str: None,
|
||||
sort_int: None,
|
||||
sort_ts: row.sort_ts, // accessed_at timestamp
|
||||
reverse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ use std::sync::Arc;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::recent_dto::RecentItemDto;
|
||||
use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow};
|
||||
use crate::application::ports::recent_ports::RecentItemsRepositoryPort;
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
|
||||
/// PostgreSQL implementation of the recent items persistence port.
|
||||
pub struct RecentItemsPgRepository {
|
||||
@@ -188,4 +189,310 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_resources_paged(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
limit: usize,
|
||||
cursor: Option<&RecentCursor>,
|
||||
order_by: &str,
|
||||
kinds: Option<&[ResourceKind]>,
|
||||
reverse: bool,
|
||||
) -> Result<Vec<RecentResourceRow>> {
|
||||
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,
|
||||
ur.accessed_at AS accessed_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_recent_files ur
|
||||
INNER JOIN storage.folders fld
|
||||
ON fld.id = ur.item_id::UUID AND NOT fld.is_trashed
|
||||
WHERE ur.user_id = $1::uuid AND ur.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,
|
||||
ur.accessed_at AS accessed_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_recent_files ur
|
||||
INNER JOIN storage.files f
|
||||
ON f.id = ur.item_id::UUID AND NOT f.is_trashed
|
||||
LEFT JOIN storage.folders pfld
|
||||
ON pfld.id = f.folder_id
|
||||
WHERE ur.user_id = $1::uuid AND ur.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,
|
||||
),
|
||||
// ── accessed_at ──────────────────────────────────────────────────
|
||||
("accessed_at", false) => (
|
||||
"WHERE ($4::timestamptz IS NULL)
|
||||
OR (accessed_at < $4)
|
||||
OR (accessed_at = $4 AND resource_id < $5::uuid)",
|
||||
"ORDER BY accessed_at DESC, resource_id DESC",
|
||||
false,
|
||||
),
|
||||
("accessed_at", true) => (
|
||||
"WHERE ($4::timestamptz IS NULL)
|
||||
OR (accessed_at > $4)
|
||||
OR (accessed_at = $4 AND resource_id > $5::uuid)",
|
||||
"ORDER BY accessed_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 accessed_at < $4)
|
||||
OR (LOWER(u.username) = $2 AND accessed_at = $4 AND resource_id < $5::uuid)",
|
||||
"ORDER BY LOWER(u.username) ASC, accessed_at DESC, resource_id DESC",
|
||||
true,
|
||||
),
|
||||
("owner", true) => (
|
||||
"WHERE ($2::text IS NULL)
|
||||
OR (LOWER(u.username) < $2)
|
||||
OR (LOWER(u.username) = $2 AND accessed_at > $4)
|
||||
OR (LOWER(u.username) = $2 AND accessed_at = $4 AND resource_id > $5::uuid)",
|
||||
"ORDER BY LOWER(u.username) DESC, accessed_at ASC, resource_id ASC",
|
||||
true,
|
||||
),
|
||||
// ── default: accessed_at DESC ─────────────────────────────────────
|
||||
(_, false) => (
|
||||
"WHERE ($4::timestamptz IS NULL)
|
||||
OR (accessed_at < $4)
|
||||
OR (accessed_at = $4 AND resource_id < $5::uuid)",
|
||||
"ORDER BY accessed_at DESC, resource_id DESC",
|
||||
false,
|
||||
),
|
||||
(_, true) => (
|
||||
"WHERE ($4::timestamptz IS NULL)
|
||||
OR (accessed_at > $4)
|
||||
OR (accessed_at = $4 AND resource_id > $5::uuid)",
|
||||
"ORDER BY accessed_at ASC, resource_id ASC",
|
||||
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.accessed_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 recent resources: {e}");
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"RecentItems",
|
||||
format!("Failed to list recent 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),
|
||||
"accessed_at" => {
|
||||
let ts: Option<chrono::DateTime<chrono::Utc>> =
|
||||
row.try_get("accessed_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("accessed_at").ok();
|
||||
(username, None, ts)
|
||||
}
|
||||
_ => {
|
||||
let ts: Option<chrono::DateTime<chrono::Utc>> =
|
||||
row.try_get("accessed_at").ok();
|
||||
(None, None, ts)
|
||||
}
|
||||
};
|
||||
|
||||
RecentResourceRow {
|
||||
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),
|
||||
accessed_at: row.get("accessed_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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,18 @@ use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
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::dtos::recent_dto::{
|
||||
RecentResourceItemDto, RecentResourcesDto, RecentResourcesQuery,
|
||||
};
|
||||
use crate::application::ports::recent_ports::RecentItemsUseCase;
|
||||
use crate::application::services::recent_service::RecentService;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Query parameters for getting recent items
|
||||
@@ -19,7 +29,8 @@ pub struct GetRecentParams {
|
||||
limit: Option<i32>,
|
||||
}
|
||||
|
||||
/// Get user's recent items
|
||||
/// Get user's recent items (deprecated — use `GET /api/recent/resources` instead)
|
||||
#[deprecated = "Use GET /api/recent/resources instead"]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/recent",
|
||||
@@ -213,3 +224,121 @@ pub async fn clear_recent_items(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List recently accessed resources with cursor pagination.
|
||||
///
|
||||
/// Sorted by `accessed_at` DESC by default (most recently accessed first).
|
||||
/// `path` is cleared when the resource is not owned by the requesting user.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/recent/resources",
|
||||
params(RecentResourcesQuery),
|
||||
responses(
|
||||
(status = 200, description = "Paginated list of recently accessed resources",
|
||||
body = RecentResourcesDto),
|
||||
(status = 400, description = "Invalid cursor or query parameters"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "recent"
|
||||
)]
|
||||
pub async fn list_recent_resources(
|
||||
State(recent_service): State<Arc<RecentService>>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<RecentResourcesQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let user_id = auth_user.id;
|
||||
|
||||
let order_by = q.order_by.as_deref().unwrap_or("accessed_at").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 recent_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<RecentResourceItemDto> = 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"),
|
||||
};
|
||||
RecentResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
accessed_at: row.accessed_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(),
|
||||
};
|
||||
RecentResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
accessed_at: row.accessed_at,
|
||||
resource: ResourceContentDto::File(dto),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(RecentResourcesDto::with_cursor(items, next_cursor)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,10 +355,12 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
|
||||
// Create routes for recent items if the service is available
|
||||
let recent_router = if let Some(recent_service) = recent_service.clone() {
|
||||
#[allow(deprecated)]
|
||||
use crate::interfaces::api::handlers::recent_handler;
|
||||
|
||||
Router::new()
|
||||
.route("/", get(recent_handler::get_recent_items))
|
||||
.route("/resources", get(recent_handler::list_recent_resources))
|
||||
.route(
|
||||
"/{item_type}/{item_id}",
|
||||
post(recent_handler::record_item_access),
|
||||
|
||||
Reference in New Issue
Block a user