feat(trash): move trash API to normalized version (with cursor, orderBy) + normalize Trash section to existing components

normalize also component to format badges (expiry, role, etc)
This commit is contained in:
Edouard Vanbelle
2026-05-30 00:15:07 +02:00
parent 6dab878919
commit ea83891a61
34 changed files with 2043 additions and 418 deletions
+125 -1
View File
@@ -1,6 +1,11 @@
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::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::domain::services::authorization::ResourceKind;
/// DTO representing an item in the trash
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -38,3 +43,122 @@ pub struct RestoreFromTrashRequest {
pub struct DeletePermanentlyRequest {
pub trash_id: String,
}
// ════════════════════════════════════════════════════════════════════════════
// Cursor-paginated trash resources (GET /api/trash/resources)
// ════════════════════════════════════════════════════════════════════════════
/// Raw row returned by the UNION-ALL query over `storage.files`/`storage.folders`
/// where `is_trashed = TRUE`. Never serialised directly.
pub struct TrashResourceRow {
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 (sentinel), actual byte-count for files.
pub size: i64,
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
pub trashed_at: DateTime<Utc>,
pub deletion_date: DateTime<Utc>,
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
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/trash/resources`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashCursor {
/// Sort dimension active when this cursor was produced.
/// Values: `"deletion_date"` (default), `"trashed_at"`, `"name"`, `"type"`, `"size"`.
#[serde(default = "TrashCursor::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.
#[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 `deletion_date` and `trashed_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 TrashCursor {
fn default_order() -> String {
"deletion_date".to_owned()
}
}
impl PageCursor for TrashCursor {}
/// Query parameters for `GET /api/trash/resources`.
#[derive(Debug, Deserialize, IntoParams)]
pub struct TrashResourcesQuery {
/// 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: `"deletion_date"` (default — soonest
/// expiry first), `"trashed_at"` (most recently trashed first), `"name"`,
/// `"type"`, `"size"`.
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 TrashResourcesQuery {
pub fn limit_clamped(&self) -> usize {
self.limit.clamp(1, 200) as usize
}
pub fn decode_cursor(&self) -> Option<TrashCursor> {
self.cursor.as_deref().and_then(TrashCursor::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/trash/resources` page.
///
/// `deletion_date` is the real timestamp at which the item will be permanently
/// deleted (= `trashed_at + retention_days`). The client derives "days until
/// deletion" itself from this + the current clock — the wire format does not
/// duplicate that derivation. `resource.path` carries the original location
/// (soft-delete preserves the row's `path` column).
#[derive(Debug, Serialize, ToSchema)]
pub struct TrashResourceItemDto {
pub resource_type: ResourceTypeDto,
/// When the user moved the item to trash.
pub trashed_at: DateTime<Utc>,
/// When the item will be permanently deleted by the retention sweeper.
pub deletion_date: DateTime<Utc>,
/// Full resource details — shape determined by `resource_type`.
pub resource: ResourceContentDto,
}
/// Response envelope for `GET /api/trash/resources`.
pub type TrashResourcesDto = CursorListResponse<TrashResourceItemDto>;
+130 -2
View File
@@ -2,10 +2,16 @@ use std::sync::Arc;
use tracing::{debug, error, info, instrument, warn};
use uuid::Uuid;
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::display_helpers::{
category_for, icon_class_for, icon_special_class_for,
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::trash_dto::{
TrashCursor, TrashResourceItemDto, TrashResourceRow, TrashedItemDto,
};
use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
@@ -14,6 +20,7 @@ use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository;
use crate::domain::services::authorization::ResourceKind;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
@@ -731,3 +738,124 @@ impl TrashUseCase for TrashService {
Ok(())
}
}
// ════════════════════════════════════════════════════════════════════════════
// Cursor-paginated trash listing (GET /api/trash/resources)
// ════════════════════════════════════════════════════════════════════════════
impl TrashService {
/// Cursor-paginated list of the user's trashed resources.
///
/// No `authz.require()` here — trashed items are strictly user-scoped and
/// the repository enforces `WHERE user_id = $1`. This matches the pattern
/// used by favorites and recent listing endpoints. Mutations (restore,
/// delete permanently, move to trash) keep their per-item authz checks.
///
/// Returns `(page items, next_cursor_encoded)`.
pub async fn list_resources_paged(
&self,
user_id: Uuid,
limit: usize,
cursor: Option<TrashCursor>,
order_by: &str,
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<(Vec<TrashResourceItemDto>, Option<String>)> {
// Fetch one extra row to detect whether a next page exists.
let mut rows = self
.trash_repository
.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_trash_cursor(last, order_by, reverse);
rows.truncate(limit);
Some(c.encode())
} else {
None
};
let items: Vec<TrashResourceItemDto> = rows.into_iter().map(row_to_item_dto).collect();
Ok((items, 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_trash_cursor(row: &TrashResourceRow, order_by: &str, reverse: bool) -> TrashCursor {
let order_by_owned = match order_by {
"deletion_date" | "trashed_at" | "name" | "type" | "size" => order_by.to_owned(),
_ => "deletion_date".to_owned(),
};
TrashCursor {
order_by: order_by_owned,
resource_id: row.resource_id,
sort_str: row.sort_str.clone(),
sort_int: row.sort_int,
sort_ts: row.sort_ts,
reverse,
}
}
/// Convert a raw repository row into the API DTO.
fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
let path = row.path.clone().unwrap_or_default();
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"),
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::Folder,
trashed_at: row.trashed_at,
deletion_date: row.deletion_date,
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(),
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::File,
trashed_at: row.trashed_at,
deletion_date: row.deletion_date,
resource: ResourceContentDto::File(dto),
}
}
}
@@ -5,13 +5,16 @@
//! are files/folders with `is_trashed = TRUE`.
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use sqlx::{PgPool, Row};
use std::sync::Arc;
use tracing::error;
use uuid::Uuid;
use crate::common::errors::{DomainError, Result};
use crate::application::dtos::trash_dto::{TrashCursor, TrashResourceRow};
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::trash_repository::TrashRepository;
use crate::domain::services::authorization::ResourceKind;
/// Default retention period (days) used when computing deletion_date.
const _DEFAULT_RETENTION_DAYS: i64 = 30;
@@ -215,3 +218,271 @@ impl TrashRepository for TrashDbRepository {
Ok((files_deleted, folders_deleted))
}
}
// ════════════════════════════════════════════════════════════════════════════
// Cursor-paginated trash listing (used by GET /api/trash/resources)
// ════════════════════════════════════════════════════════════════════════════
impl TrashDbRepository {
/// Cursor-paginated list of the user's trashed resources.
///
/// Mirrors the favorites/grants pattern: a UNION-ALL CTE over folder and
/// file branches (each pre-computing sort columns), then a per-dimension
/// keyset WHERE + ORDER BY.
///
/// Returns rows in caller-requested sort order. The caller is expected to
/// fetch `limit + 1` to detect end-of-results.
pub async fn list_resources_paged(
&self,
user_id: Uuid,
limit: usize,
cursor: Option<&TrashCursor>,
order_by: &str,
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<Vec<TrashResourceRow>> {
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 ─────────────────────────────────────────
// Only top-level trashed items: a file/folder whose parent is itself
// trashed is implicitly in trash as a descendant, mirroring the
// `storage.trash_items` view's filter.
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.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
fld.path::text AS resource_path,
LOWER(fld.name) AS sort_str,
0::bigint AS type_order,
0::int AS folder_first
FROM storage.folders fld
WHERE fld.user_id = $1::uuid
AND fld.is_trashed = TRUE
AND (fld.parent_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders p
WHERE p.id = fld.parent_id AND p.is_trashed = TRUE))"#;
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 AS size,
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
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 storage.files f
LEFT JOIN storage.folders pfld
ON pfld.id = f.folder_id
WHERE f.user_id = $1::uuid
AND f.is_trashed = TRUE
AND (f.folder_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders p
WHERE p.id = f.folder_id AND p.is_trashed = TRUE))"#;
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, $2=cur_str, $3=cur_int, $4=cur_ts,
// $5=cur_id, $6=limit, $7=retention_days.
let (keyset, order_by_clause) = match (order_by, reverse) {
// ── deletion_date (DEFAULT) — ASC = expiring soonest first ───────
("deletion_date", false) => (
"WHERE ($4::timestamptz IS NULL)
OR (deletion_date > $4)
OR (deletion_date = $4 AND resource_id > $5::uuid)",
"ORDER BY deletion_date ASC, resource_id ASC",
),
("deletion_date", true) => (
"WHERE ($4::timestamptz IS NULL)
OR (deletion_date < $4)
OR (deletion_date = $4 AND resource_id < $5::uuid)",
"ORDER BY deletion_date DESC, resource_id DESC",
),
// ── trashed_at — DESC = most recently trashed first ──────────────
("trashed_at", false) => (
"WHERE ($4::timestamptz IS NULL)
OR (trashed_at < $4)
OR (trashed_at = $4 AND resource_id < $5::uuid)",
"ORDER BY trashed_at DESC, resource_id DESC",
),
("trashed_at", true) => (
"WHERE ($4::timestamptz IS NULL)
OR (trashed_at > $4)
OR (trashed_at = $4 AND resource_id > $5::uuid)",
"ORDER BY trashed_at ASC, resource_id ASC",
),
// ── name — folders first ─────────────────────────────────────────
("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",
),
("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",
),
// ── type — folders get type_order=0 so they sort first naturally ─
("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",
),
("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",
),
// ── size — folders first (via -1 sentinel grouping at top) ──────
("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",
),
("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",
),
// ── default = deletion_date ASC ─────────────────────────────────
(_, false) => (
"WHERE ($4::timestamptz IS NULL)
OR (deletion_date > $4)
OR (deletion_date = $4 AND resource_id > $5::uuid)",
"ORDER BY deletion_date ASC, resource_id ASC",
),
(_, true) => (
"WHERE ($4::timestamptz IS NULL)
OR (deletion_date < $4)
OR (deletion_date = $4 AND resource_id < $5::uuid)",
"ORDER BY deletion_date DESC, resource_id DESC",
),
};
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.trashed_at, r.deletion_date, r.resource_path,
r.sort_str, r.type_order, r.folder_first
FROM resources r
{keyset}
{order_by_clause}
LIMIT $6"
);
let rows = sqlx::query(&sql)
.bind(user_id) // $1
.bind(cur_str) // $2
.bind(cur_int) // $3
.bind(cur_ts) // $4
.bind(cur_id) // $5
.bind(limit as i64) // $6
.bind(self.retention_days as i32) // $7
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
error!("Database error listing trash resources: {e}");
DomainError::new(
ErrorKind::InternalError,
"Trash",
format!("Failed to list trash 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");
let trashed_at: DateTime<Utc> = row.get("trashed_at");
let deletion_date: DateTime<Utc> = row.get("deletion_date");
// Pre-compute the cursor sort fields based on order_by.
let (c_sort_str, c_sort_int, c_sort_ts) = match order_by {
"deletion_date" => (None, None, Some(deletion_date)),
"trashed_at" => (None, None, Some(trashed_at)),
"name" => (sort_str_val, Some(folder_first as i64), None),
"type" => (sort_str_val, Some(type_order), None),
"size" => (None, Some(size), None),
_ => (None, None, Some(deletion_date)),
};
TrashResourceRow {
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"),
trashed_at,
deletion_date,
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)
}
}
+83 -3
View File
@@ -1,20 +1,28 @@
use axum::Json;
use axum::extract::{Path, State};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::json;
use tracing::{debug, error, instrument, warn};
use crate::application::dtos::trash_dto::{TrashResourcesDto, TrashResourcesQuery};
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc;
/// Gets all items in the trash for the current user
/// Gets all items in the trash for the current user.
///
/// # Deprecated
/// Use `GET /api/trash/resources` instead. This endpoint is kept for
/// backwards compatibility but will be removed in a future release.
#[deprecated = "Use GET /api/trash/resources instead"]
#[utoipa::path(
get,
path = "/api/trash",
responses(
(status = 200, description = "List of trashed items"),
(status = 200, description = "List of trashed items (deprecated — use /api/trash/resources)"),
(status = 501, description = "Trash feature not enabled")
),
security(("bearerAuth" = [])),
@@ -30,6 +38,10 @@ pub async fn get_trash_items(
// privilege escalation attacks.
let effective_user = auth_user.id;
warn!(
"Deprecated endpoint called: GET /api/trash — use GET /api/trash/resources instead (user {effective_user})"
);
debug!("Request to list trash items for user {}", effective_user);
let trash_service = match state.trash_service.as_ref() {
@@ -63,6 +75,74 @@ pub async fn get_trash_items(
}
}
/// Cursor-paginated list of a user's trashed resources.
///
/// Sorts by `deletion_date` (default — soonest expiry first), `trashed_at`
/// (most recently trashed first), `name`, `type`, or `size`. Filter on
/// `resource_types=file` or `resource_types=folder` to narrow to one kind.
/// Items implicitly trashed as descendants of a trashed parent are excluded
/// (only top-level trashed items appear).
#[utoipa::path(
get,
path = "/api/trash/resources",
params(TrashResourcesQuery),
responses(
(status = 200, description = "Paginated list of trashed resources",
body = crate::application::dtos::trash_dto::TrashResourcesDto),
(status = 400, description = "Invalid cursor or query parameters"),
(status = 501, description = "Trash feature not enabled"),
),
security(("bearerAuth" = [])),
tag = "trash"
)]
#[instrument(skip_all)]
pub async fn get_trash_resources(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(q): Query<TrashResourcesQuery>,
) -> axum::response::Response {
let user_id = auth_user.id;
let trash_service = match state.trash_service.as_ref() {
Some(service) => service,
None => {
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({ "error": "Trash feature is not enabled" })),
)
.into_response();
}
};
let order_by = q.order_by.as_deref().unwrap_or("deletion_date").to_owned();
// Discard cursor if sort dimension or direction changed between pages.
let cursor = q
.decode_cursor()
.filter(|c| c.order_by == order_by && c.reverse == q.reverse);
let kinds = q.resource_kinds();
match trash_service
.list_resources_paged(
user_id,
q.limit_clamped(),
cursor,
&order_by,
kinds.as_deref(),
q.reverse,
)
.await
{
Ok((items, next_cursor)) => (
StatusCode::OK,
Json(TrashResourcesDto::with_cursor(items, next_cursor)),
)
.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// Moves a file to the trash
#[utoipa::path(
delete,
+5 -1
View File
@@ -36,7 +36,8 @@ use crate::application::dtos::search_dto::{
};
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
use crate::application::dtos::trash_dto::{
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto,
DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashResourceItemDto,
TrashResourcesDto, TrashedItemDto,
};
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto,
@@ -121,6 +122,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::dedup_handler::recalculate_stats,
// Trash handlers (free functions)
handlers::trash_handler::get_trash_items,
handlers::trash_handler::get_trash_resources,
handlers::trash_handler::move_file_to_trash,
handlers::trash_handler::move_folder_to_trash,
handlers::trash_handler::restore_from_trash,
@@ -259,6 +261,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
UpdateShareDto,
// Trash schemas
TrashedItemDto,
TrashResourceItemDto,
TrashResourcesDto,
MoveToTrashRequest,
RestoreFromTrashRequest,
DeletePermanentlyRequest,
+6 -2
View File
@@ -431,13 +431,17 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
if let Some(_trash_service_ref) = trash_service.clone() {
tracing::info!("Setting up trash routes for trash view");
#[allow(deprecated)]
let trash_router = Router::new()
.route("/", get(trash_handler::get_trash_items))
// Literal paths first — order matters for axum overlap handling
// when a wildcard like /{id} could otherwise capture them.
.route("/", get(trash_handler::get_trash_items)) // deprecated — kept for external compat
.route("/resources", get(trash_handler::get_trash_resources))
.route("/empty", delete(trash_handler::empty_trash))
.route("/files/{id}", delete(trash_handler::move_file_to_trash))
.route("/folders/{id}", delete(trash_handler::move_folder_to_trash))
.route("/{id}/restore", post(trash_handler::restore_from_trash))
.route("/{id}", delete(trash_handler::delete_permanently))
.route("/empty", delete(trash_handler::empty_trash))
.with_state(app_state.clone());
router = router.nest("/trash", trash_router);