feat(recent): add cursor + groupby support

This commit is contained in:
Edouard Vanbelle
2026-05-28 11:50:32 +02:00
parent bd474aa67e
commit 73e78904e7
15 changed files with 1205 additions and 181 deletions
+111 -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 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>;
+11
View File
@@ -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>>;
}
+99 -1
View File
@@ -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)
}
}
+130 -1
View File
@@ -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(),
}
}
+2
View File
@@ -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),