//! 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; use std::sync::Arc; use uuid::Uuid; use crate::common::errors::{DomainError, Result}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::repositories::trash_repository::TrashRepository; /// 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>, ) -> 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(), String::new(), // original_path — not stored separately in soft-delete model 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>)>( r#" SELECT id, name, item_type, user_id, trashed_at FROM storage.trash_items WHERE user_id = $1 ORDER BY 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)| { self.row_to_trashed_item(id, name, item_type, uid, trashed_at) }) .collect()) } async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result> { let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option>)>( r#" SELECT id, name, item_type, user_id, trashed_at FROM storage.trash_items WHERE id = $1 AND 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)| { self.row_to_trashed_item(id, name, item_type, uid, trashed_at) })) } 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 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)) } }