//! PostgreSQL-backed trash repository. //! //! Implements `TrashRepository` using soft-delete columns in `storage.files` //! and `storage.folders`. There is no separate trash table — trashed items //! are files/folders with `is_trashed = TRUE`. use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row}; use std::sync::Arc; use tracing::error; use uuid::Uuid; 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; /// PostgreSQL-backed trash repository using soft-delete flags. pub struct TrashDbRepository { pool: Arc, retention_days: i64, } impl TrashDbRepository { pub fn new(pool: Arc, retention_days: u32) -> Self { Self { pool, retention_days: retention_days as i64, } } /// Creates a stub instance for testing — never hits PG. #[cfg(test)] pub fn new_stub() -> Self { Self { pool: Arc::new( sqlx::pool::PoolOptions::::new() .max_connections(1) .connect_lazy("postgres://invalid:5432/none") .unwrap(), ), retention_days: 30, } } /// Convert a trash_items view row into a TrashedItem entity. fn row_to_trashed_item( &self, id: Uuid, name: String, item_type: String, user_id: Uuid, trashed_at: Option>, original_path: String, ) -> TrashedItem { let trashed_at = trashed_at.unwrap_or_else(Utc::now); let deletion_date = trashed_at + chrono::Duration::days(self.retention_days); let item_type_enum = match item_type.as_str() { "folder" => TrashedItemType::Folder, _ => TrashedItemType::File, }; // In the soft-delete model, the trash entry ID is the same as the // original item ID since there is no separate trash table. TrashedItem::from_raw( id, // trash entry id (same as original) id, // original item id user_id, // owner item_type_enum, name.clone(), original_path, // parent folder path at time of trash trashed_at, deletion_date, ) } } impl TrashRepository for TrashDbRepository { async fn add_to_trash(&self, _item: &TrashedItem) -> Result<()> { // No-op: the actual flagging is done by FileWritePort::move_to_trash // or FolderRepository::move_to_trash. This method exists for interface // compatibility with the TrashService. Ok(()) } async fn get_trash_items(&self, user_id: &Uuid) -> Result> { let rows = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option>, String)>( r#" SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at, COALESCE(p.path || '/' || t.name, t.name) AS original_path FROM storage.trash_items t LEFT JOIN storage.folders p ON p.id = t.original_parent_id WHERE t.user_id = $1 ORDER BY t.trashed_at DESC "#, ) .bind(user_id) .fetch_all(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?; Ok(rows .into_iter() .map(|(id, name, item_type, uid, trashed_at, path)| { self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path) }) .collect()) } async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result> { let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option>, String)>( r#" SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at, COALESCE(p.path || '/' || t.name, t.name) AS original_path FROM storage.trash_items t LEFT JOIN storage.folders p ON p.id = t.original_parent_id WHERE t.id = $1 AND t.user_id = $2 "#, ) .bind(id) .bind(user_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?; Ok(row.map(|(id, name, item_type, uid, trashed_at, path)| { self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path) })) } async fn restore_from_trash(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> { // No-op: the actual restore is done by FileWritePort::restore_from_trash // or FolderRepository::restore_from_trash. The TrashService also removes // the index entry — which in the soft-delete model means the flag is // already cleared. Ok(()) } async fn delete_permanently(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> { // No-op: the actual delete is done by FileWritePort::delete_file_permanently // or FolderRepository::delete_folder_permanently. Ok(()) } async fn clear_trash(&self, user_id: &Uuid) -> Result<()> { // Delete all trashed files for this user sqlx::query("DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE") .bind(user_id) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("TrashDb", format!("clear files: {e}")))?; // Delete all trashed folders for this user sqlx::query("DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE") .bind(user_id) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("TrashDb", format!("clear folders: {e}")))?; Ok(()) } async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result> { let rows = sqlx::query_scalar::<_, String>( "SELECT id::text FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE", ) .bind(user_id) .fetch_all(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("TrashDb", format!("all_trashed_files: {e}")))?; Ok(rows) } async fn delete_expired_bulk(&self) -> Result<(u64, u64)> { let cutoff = Utc::now() - chrono::Duration::days(self.retention_days); let mut tx = self .pool .begin() .await .map_err(|e| DomainError::internal_error("TrashDb", format!("begin tx: {e}")))?; // 1. Bulk-delete expired trashed files. // The PG trigger `trg_files_decrement_blob_ref` automatically // decrements blob ref_count for every deleted row. let files_deleted = sqlx::query("DELETE FROM storage.files WHERE is_trashed = TRUE AND trashed_at < $1") .bind(cutoff) .execute(&mut *tx) .await .map_err(|e| { DomainError::internal_error("TrashDb", format!("bulk delete files: {e}")) })? .rows_affected(); // 2. Bulk-delete expired trashed folders. // FK ON DELETE CASCADE handles descendant folders and their files. let folders_deleted = sqlx::query("DELETE FROM storage.folders WHERE is_trashed = TRUE AND trashed_at < $1") .bind(cutoff) .execute(&mut *tx) .await .map_err(|e| { DomainError::internal_error("TrashDb", format!("bulk delete folders: {e}")) })? .rows_affected(); tx.commit() .await .map_err(|e| DomainError::internal_error("TrashDb", format!("commit tx: {e}")))?; 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> { 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 = cursor.and_then(|c| c.sort_int); let cur_ts: Option> = cursor.and_then(|c| c.sort_ts); let cur_id: Option = 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 = 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 = row.get("trashed_at"); let deletion_date: DateTime = 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) } }