//! PostgreSQL-backed folder repository. //! //! Implements `FolderRepository` (and thus `FolderStoragePort`) using the //! `storage.folders` table. Folders are purely virtual — no physical //! directories are created on the filesystem. use async_trait::async_trait; use sqlx::PgPool; use std::sync::Arc; use crate::common::errors::DomainError; use crate::domain::entities::folder::Folder; use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::services::path_service::StoragePath; /// PostgreSQL-backed folder repository. /// /// All folder metadata lives in the `storage.folders` table. The physical /// filesystem is never touched for folder operations. pub struct FolderDbRepository { pool: Option>, } impl FolderDbRepository { pub fn new(pool: Arc) -> Self { Self { pool: Some(pool) } } /// Creates a stub instance for `AppState::default()`. /// This is never called in production — only used for route scaffolding. pub fn new_stub() -> Self { Self { pool: None } } /// Get the pool, panicking if stub. fn pool(&self) -> &PgPool { self.pool .as_deref() .expect("FolderDbRepository: pool not available (stub instance)") } // ── helpers ────────────────────────────────────────────────── /// Build the full virtual path for a folder by walking up the `parent_id` chain. async fn build_folder_path(&self, folder_id: &str) -> Result { let rows = sqlx::query_as::<_, (String, i32)>( r#" WITH RECURSIVE ancestors AS ( SELECT id, name, parent_id, 0 AS depth FROM storage.folders WHERE id = $1::uuid UNION ALL SELECT f.id, f.name, f.parent_id, a.depth + 1 FROM storage.folders f JOIN ancestors a ON f.id = a.parent_id ) SELECT name, depth FROM ancestors ORDER BY depth DESC "#, ) .bind(folder_id) .fetch_all(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("path query: {e}")))?; let path_parts: Vec<&str> = rows.iter().map(|(name, _)| name.as_str()).collect(); let path_str = path_parts.join("/"); Ok(StoragePath::from_string(&path_str)) } /// Convert a database row into a `Folder` domain entity. async fn row_to_folder( &self, id: String, name: String, parent_id: Option, created_at: i64, modified_at: i64, ) -> Result { let storage_path = self.build_folder_path(&id).await?; Folder::with_timestamps( id, name, storage_path, parent_id, created_at as u64, modified_at as u64, ) .map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}"))) } } #[async_trait] impl FolderRepository for FolderDbRepository { async fn create_folder( &self, name: String, parent_id: Option, ) -> Result { // Derive user_id from parent folder. Root-level folders require the // caller to have set up the home folder beforehand (done during user // registration). let user_id: String = if let Some(ref pid) = parent_id { sqlx::query_scalar::<_, String>( "SELECT user_id FROM storage.folders WHERE id = $1::uuid", ) .bind(pid) .fetch_optional(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("parent lookup: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", pid))? } else { return Err(DomainError::internal_error( "FolderDb", "Cannot create root folder without user_id — use create_home_folder instead", )); }; let row = sqlx::query_as::<_, (String, i64, i64)>( r#" INSERT INTO storage.folders (name, parent_id, user_id) VALUES ($1, $2::uuid, $3) RETURNING id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint "#, ) .bind(&name) .bind(&parent_id) .bind(&user_id) .fetch_one(self.pool()) .await .map_err(|e| { if let sqlx::Error::Database(ref db_err) = e && db_err.code().as_deref() == Some("23505") { return DomainError::already_exists( "Folder", format!("{name} already exists in parent"), ); } DomainError::internal_error("FolderDb", format!("insert: {e}")) })?; self.row_to_folder(row.0, name, parent_id, row.1, row.2) .await } async fn get_folder(&self, id: &str) -> Result { let row = sqlx::query_as::<_, (String, String, Option, i64, i64)>( r#" SELECT id::text, name, parent_id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders WHERE id = $1::uuid AND NOT is_trashed "#, ) .bind(id) .fetch_optional(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", id))?; self.row_to_folder(row.0, row.1, row.2, row.3, row.4).await } async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result { // Walk the path segments to find the folder. let path_str = storage_path.to_string(); let segments: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect(); if segments.is_empty() { return Err(DomainError::not_found("Folder", "empty path")); } let mut current_parent: Option = None; let mut current_id = String::new(); for segment in &segments { let row = if let Some(ref pid) = current_parent { sqlx::query_as::<_, (String,)>( r#" SELECT id::text FROM storage.folders WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed "#, ) .bind(segment) .bind(pid) .fetch_optional(self.pool()) .await } else { sqlx::query_as::<_, (String,)>( r#" SELECT id::text FROM storage.folders WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed "#, ) .bind(segment) .fetch_optional(self.pool()) .await } .map_err(|e| DomainError::internal_error("FolderDb", format!("path walk: {e}")))? .ok_or_else(|| { DomainError::not_found("Folder", format!("segment '{segment}' in path")) })?; current_id = row.0; current_parent = Some(current_id.clone()); } self.get_folder(¤t_id).await } async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { let rows: Vec<(String, String, Option, i64, i64)> = if let Some(pid) = parent_id { sqlx::query_as( r#" SELECT id::text, name, parent_id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed ORDER BY name "#, ) .bind(pid) .fetch_all(self.pool()) .await } else { sqlx::query_as( r#" SELECT id::text, name, parent_id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed ORDER BY name "#, ) .fetch_all(self.pool()) .await } .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; let mut folders = Vec::with_capacity(rows.len()); for (id, name, pid, ca, ma) in rows { folders.push(self.row_to_folder(id, name, pid, ca, ma).await?); } Ok(folders) } async fn list_folders_paginated( &self, parent_id: Option<&str>, offset: usize, limit: usize, include_total: bool, ) -> Result<(Vec, Option), DomainError> { let total = if include_total { let count: i64 = if let Some(pid) = parent_id { sqlx::query_scalar( "SELECT COUNT(*) FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed", ) .bind(pid) .fetch_one(self.pool()) .await } else { sqlx::query_scalar( "SELECT COUNT(*) FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed", ) .fetch_one(self.pool()) .await } .map_err(|e| DomainError::internal_error("FolderDb", format!("count: {e}")))?; Some(count as usize) } else { None }; let rows: Vec<(String, String, Option, i64, i64)> = if let Some(pid) = parent_id { sqlx::query_as( r#" SELECT id::text, name, parent_id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed ORDER BY name LIMIT $2 OFFSET $3 "#, ) .bind(pid) .bind(limit as i64) .bind(offset as i64) .fetch_all(self.pool()) .await } else { sqlx::query_as( r#" SELECT id::text, name, parent_id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed ORDER BY name LIMIT $1 OFFSET $2 "#, ) .bind(limit as i64) .bind(offset as i64) .fetch_all(self.pool()) .await } .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; let mut folders = Vec::with_capacity(rows.len()); for (id, name, pid, ca, ma) in rows { folders.push(self.row_to_folder(id, name, pid, ca, ma).await?); } Ok((folders, total)) } async fn rename_folder(&self, id: &str, new_name: String) -> Result { sqlx::query( r#" UPDATE storage.folders SET name = $1, updated_at = NOW() WHERE id = $2::uuid AND NOT is_trashed "#, ) .bind(&new_name) .bind(id) .execute(self.pool()) .await .map_err(|e| { if let sqlx::Error::Database(ref db_err) = e && db_err.code().as_deref() == Some("23505") { return DomainError::already_exists("Folder", format!("{new_name} already exists")); } DomainError::internal_error("FolderDb", format!("rename: {e}")) })?; self.get_folder(id).await } async fn move_folder( &self, id: &str, new_parent_id: Option<&str>, ) -> Result { sqlx::query( r#" UPDATE storage.folders SET parent_id = $1::uuid, updated_at = NOW() WHERE id = $2::uuid AND NOT is_trashed "#, ) .bind(new_parent_id) .bind(id) .execute(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?; self.get_folder(id).await } async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { // Hard delete folder and all descendants (CASCADE handles children) let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid") .bind(id) .execute(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("delete: {e}")))?; if result.rows_affected() == 0 { return Err(DomainError::not_found("Folder", id)); } Ok(()) } async fn folder_exists(&self, storage_path: &StoragePath) -> Result { // Try to find by walking the path match self.get_folder_by_path(storage_path).await { Ok(_) => Ok(true), Err(e) if e.to_string().contains("not found") => Ok(false), Err(e) => Err(e), } } async fn get_folder_path(&self, id: &str) -> Result { self.build_folder_path(id).await } // ── Trash operations ── async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> { // Atomic CTE: trash folder + all descendant files in a single statement. // PostgreSQL executes the entire CTE as one atomic operation — no // intermediate state where the folder is trashed but files are not. let result = sqlx::query_scalar::<_, i64>( r#" WITH trash_folder AS ( UPDATE storage.folders SET is_trashed = TRUE, trashed_at = NOW(), original_parent_id = parent_id, updated_at = NOW() WHERE id = $1::uuid AND NOT is_trashed RETURNING id ), descendants AS ( SELECT id FROM trash_folder UNION ALL SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id ), trash_files AS ( UPDATE storage.files SET is_trashed = TRUE, trashed_at = NOW(), original_folder_id = folder_id WHERE folder_id IN (SELECT id FROM descendants) AND NOT is_trashed RETURNING 1 ) SELECT COUNT(*) FROM trash_folder "#, ) .bind(folder_id) .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("trash: {e}")))?; if result == 0 { return Err(DomainError::not_found("Folder", folder_id)); } Ok(()) } async fn restore_from_trash( &self, folder_id: &str, _original_path: &str, ) -> Result<(), DomainError> { // Atomic CTE: restore folder + all descendant files in a single statement. let result = sqlx::query_scalar::<_, i64>( r#" WITH restore_folder AS ( UPDATE storage.folders SET is_trashed = FALSE, trashed_at = NULL, parent_id = COALESCE(original_parent_id, parent_id), original_parent_id = NULL, updated_at = NOW() WHERE id = $1::uuid AND is_trashed RETURNING id ), descendants AS ( SELECT id FROM restore_folder UNION ALL SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id ), restore_files AS ( UPDATE storage.files SET is_trashed = FALSE, trashed_at = NULL, folder_id = COALESCE(original_folder_id, folder_id), original_folder_id = NULL WHERE folder_id IN (SELECT id FROM descendants) AND is_trashed RETURNING 1 ) SELECT COUNT(*) FROM restore_folder "#, ) .bind(folder_id) .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("restore: {e}")))?; if result == 0 { return Err(DomainError::not_found("Folder", folder_id)); } Ok(()) } async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> { // Permanently delete — CASCADE handles children let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid") .bind(folder_id) .execute(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("perm delete: {e}")))?; if result.rows_affected() == 0 { return Err(DomainError::not_found("Folder", folder_id)); } Ok(()) } } // ── Extra helpers for blob-storage bootstrap ── impl FolderDbRepository { /// Creates a root-level home folder for a user. /// This is called during user registration. pub async fn create_home_folder( &self, user_id: &str, name: &str, ) -> Result { let row = sqlx::query_as::<_, (String, i64, i64)>( r#" INSERT INTO storage.folders (name, parent_id, user_id) VALUES ($1, NULL, $2) ON CONFLICT DO NOTHING RETURNING id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint "#, ) .bind(name) .bind(user_id) .fetch_optional(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; match row { Some((id, ca, ma)) => self.row_to_folder(id, name.to_string(), None, ca, ma).await, None => { // Already exists — fetch it let existing = sqlx::query_as::<_, (String, i64, i64)>( r#" SELECT id::text, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint FROM storage.folders WHERE name = $1 AND user_id = $2 AND parent_id IS NULL "#, ) .bind(name) .bind(user_id) .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?; self.row_to_folder(existing.0, name.to_string(), None, existing.1, existing.2) .await } } } /// Returns user_id for a given folder. Used by file repositories. pub async fn get_folder_user_id(&self, folder_id: &str) -> Result { sqlx::query_scalar::<_, String>("SELECT user_id FROM storage.folders WHERE id = $1::uuid") .bind(folder_id) .fetch_optional(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("user_id lookup: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", folder_id)) } }