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:
@@ -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>;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user