use axum::{ extract::{Json, Path, State}, http::StatusCode, }; use diesel::{dsl::exists, prelude::*, PgConnection}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::models::{Document, Folder, NewFolder}; use crate::schema::{documents, folders}; use crate::state::AppState; use crate::{ auth::AuthenticatedUser, error::{AppError, AppResult}, }; use super::documents::{ load_correspondents_for_documents, load_primary_assets, load_tags_for_documents, to_document_response, to_iso, DocumentResponse, }; #[derive(Deserialize)] pub struct CreateFolderRequest { pub name: String, pub parent_id: Option, } #[derive(Deserialize)] pub struct EnsureFolderPathRequest { pub parent_id: Option, pub segments: Vec, } #[derive(Deserialize)] pub struct UpdateFolderRequest { #[serde(default)] pub parent_id: Option>, pub name: Option, } #[derive(Serialize)] pub struct FolderResponse { pub folder: FolderInfo, } #[derive(Serialize)] pub struct FolderContentsResponse { pub folder: Option, pub subfolders: Vec, pub documents: Vec, } #[derive(Serialize)] pub struct FolderInfo { pub id: Uuid, pub name: String, pub parent_id: Option, pub path_cache: Option, pub created_at: String, pub updated_at: String, } pub async fn ensure_folder_path( State(state): State, Json(payload): Json, ) -> AppResult> { if payload.segments.is_empty() { return Err(AppError::bad_request("segments must not be empty")); } let mut conn = state.db()?; let target_folder = conn.transaction::(|conn| { let mut current_parent = payload.parent_id; let mut last_folder: Option = None; for raw_name in &payload.segments { let name = raw_name.trim(); if name.is_empty() { return Err(AppError::bad_request("folder names must not be empty")); } let existing: Option = if let Some(parent_id) = current_parent { folders::table .filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::name.eq(name)) .first(conn) .optional()? } else { folders::table .filter(folders::parent_id.is_null()) .filter(folders::name.eq(name)) .first(conn) .optional()? }; let folder = if let Some(folder) = existing { folder } else { let path_cache = build_path_cache(conn, current_parent, name)?; let new_folder = NewFolder { id: Uuid::new_v4(), name: name.to_string(), parent_id: current_parent, path_cache, }; diesel::insert_into(folders::table) .values(&new_folder) .execute(conn)?; folders::table.find(new_folder.id).first(conn)? }; current_parent = Some(folder.id); last_folder = Some(folder); } last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string())) })?; Ok(Json(FolderResponse { folder: folder_to_info(target_folder), })) } pub async fn create_folder( State(state): State, Json(payload): Json, ) -> AppResult> { if payload.name.trim().is_empty() { return Err(AppError::bad_request("name must not be empty")); } let mut conn = state.db()?; let path_cache = build_path_cache(&mut conn, payload.parent_id, &payload.name)?; let new_folder = NewFolder { id: Uuid::new_v4(), name: payload.name.trim().to_string(), parent_id: payload.parent_id, path_cache, }; diesel::insert_into(folders::table) .values(&new_folder) .execute(&mut conn)?; let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?; Ok(Json(FolderResponse { folder: folder_to_info(folder), })) } pub async fn list_folder_contents( State(state): State, Path(folder_identifier): Path, user: AuthenticatedUser, ) -> AppResult> { let mut conn = state.db()?; let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { None } else { Some( Uuid::parse_str(&folder_identifier) .map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?, ) }; let folder = match folder_id { Some(id) => Some(folder_to_info( folders::table.find(id).first::(&mut conn)?, )), None => None, }; let child_folders: Vec = if let Some(parent_id) = folder_id { folders::table .filter(folders::parent_id.eq(parent_id)) .order(folders::name.asc()) .load(&mut conn)? } else { folders::table .filter(folders::parent_id.is_null()) .order(folders::name.asc()) .load(&mut conn)? }; let subfolders = child_folders.into_iter().map(folder_to_info).collect(); let docs_query = documents::table .filter(documents::deleted_at.is_null()) .order(documents::uploaded_at.desc()); let docs: Vec = if let Some(current_folder) = folder_id { docs_query .filter(documents::folder_id.eq(current_folder)) .load(&mut conn)? } else { docs_query .filter(documents::folder_id.is_null()) .load(&mut conn)? }; let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; drop(conn); let primary_versions = load_primary_assets(&state, &docs).await?; let mut documents = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); let current_version = primary_versions.get(&doc.id).cloned(); documents.push(to_document_response( &state, user.user_id, doc, tags, correspondents, current_version, )?); } Ok(Json(FolderContentsResponse { folder, subfolders, documents, })) } pub async fn delete_folder( State(state): State, Path(folder_id): Path, ) -> AppResult { let mut conn = state.db()?; conn.transaction::<_, AppError, _>(|conn| { folders::table.find(folder_id).first::(conn)?; let has_child_folders: bool = diesel::select(exists( folders::table.filter(folders::parent_id.eq(Some(folder_id))), )) .get_result(conn)?; if has_child_folders { return Err(AppError::bad_request( "folder must be empty before deletion", )); } let has_documents: bool = diesel::select(exists( documents::table .filter(documents::folder_id.eq(Some(folder_id))) .filter(documents::deleted_at.is_null()), )) .get_result(conn)?; if has_documents { return Err(AppError::bad_request( "folder must be empty before deletion", )); } diesel::delete(folders::table.find(folder_id)).execute(conn)?; Ok(()) })?; Ok(StatusCode::NO_CONTENT) } pub async fn update_folder( State(state): State, Path(folder_id): Path, Json(payload): Json, ) -> AppResult { let mut conn = state.db()?; conn.transaction::<(), AppError, _>(|conn| { let folder: Folder = folders::table.find(folder_id).first(conn)?; let mut next_parent = folder.parent_id; let mut parent_changed = false; if let Some(parent_request) = payload.parent_id { if parent_request == Some(folder_id) { return Err(AppError::bad_request("folder cannot be its own parent")); } if let Some(parent_id) = parent_request { let _parent: Folder = folders::table.find(parent_id).first(conn)?; let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?; if descendant_ids.contains(&parent_id) { return Err(AppError::bad_request( "cannot move folder into itself or a descendant", )); } } parent_changed = parent_request != folder.parent_id; next_parent = parent_request; } let mut new_name = folder.name.clone(); let mut name_changed = false; if let Some(name) = payload.name { let trimmed = name.trim(); if trimmed.is_empty() { return Err(AppError::bad_request("name must not be empty")); } if trimmed != folder.name { new_name = trimmed.to_string(); name_changed = true; } } if !parent_changed && !name_changed { return Ok(()); } let conflict = if let Some(parent_id) = next_parent { folders::table .filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::name.eq(&new_name)) .filter(folders::id.ne(folder_id)) .first::(conn) .optional()? } else { folders::table .filter(folders::parent_id.is_null()) .filter(folders::name.eq(&new_name)) .filter(folders::id.ne(folder_id)) .first::(conn) .optional()? }; if conflict.is_some() { return Err(AppError::bad_request( "a folder with the same name already exists in the target", )); } let new_path = build_path_cache(conn, next_parent, &new_name)?; diesel::update(folders::table.find(folder_id)) .set(( folders::parent_id.eq(next_parent), folders::name.eq(&new_name), folders::path_cache.eq(new_path), )) .execute(conn)?; refresh_descendant_paths(conn, folder_id)?; Ok(()) })?; Ok(StatusCode::NO_CONTENT) } fn build_path_cache( conn: &mut PgConnection, parent_id: Option, name: &str, ) -> AppResult> { let path = if let Some(parent_id) = parent_id { let parent: Folder = folders::table.find(parent_id).first(conn)?; let base = parent .path_cache .unwrap_or_else(|| "/".to_string()) .trim_end_matches('/') .to_string(); format!("{}/{}", base, name) } else { format!("/{}", name) }; Ok(Some(path)) } fn folder_to_info(folder: Folder) -> FolderInfo { FolderInfo { id: folder.id, name: folder.name, parent_id: folder.parent_id, path_cache: folder.path_cache, created_at: to_iso(folder.created_at), updated_at: to_iso(folder.updated_at), } } pub(super) fn gather_descendant_folder_ids( conn: &mut PgConnection, folder_id: Uuid, ) -> AppResult> { let mut ids = vec![folder_id]; let mut queue = vec![folder_id]; while let Some(current) = queue.pop() { let child_ids: Vec = folders::table .filter(folders::parent_id.eq(Some(current))) .select(folders::id) .load(conn)?; queue.extend(child_ids.iter().copied()); ids.extend(child_ids); } Ok(ids) } fn refresh_descendant_paths(conn: &mut PgConnection, parent_id: Uuid) -> AppResult<()> { let children: Vec = folders::table .filter(folders::parent_id.eq(Some(parent_id))) .load(conn)?; for child in children { let path_cache = build_path_cache(conn, Some(parent_id), &child.name)?; diesel::update(folders::table.find(child.id)) .set(folders::path_cache.eq(path_cache)) .execute(conn)?; refresh_descendant_paths(conn, child.id)?; } Ok(()) }