use axum::{ extract::{Json, Path, Query, State}, http::StatusCode, }; use diesel::{dsl::exists, prelude::*, PgConnection}; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; use crate::models::{Document, Folder, NewFolder}; use crate::schema::{documents, folders}; use crate::state::AppState; use crate::{ auth::TenantScopedConn, error::{AppError, AppResult}, }; use super::documents::{to_document_response, DocumentResponse}; use crate::documents::{ asset::load_primary_assets, correspondents::load_correspondents_for_documents, tags::load_tags_for_documents, }; use crate::utils::{ json::{classify_nullable, NullableValue}, time::to_iso, }; #[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(Serialize)] pub struct FolderResponse { pub folder: FolderInfo, } #[derive(Serialize)] pub struct FolderContentsResponse { pub folder: Option, pub subfolders: Vec, pub documents: Vec, } #[derive(Deserialize)] pub struct FolderContentsQuery { #[serde(default = "default_include_documents")] pub include_documents: bool, } const fn default_include_documents() -> bool { true } #[derive(Serialize)] pub struct FolderInfo { pub id: Uuid, pub name: String, pub parent_id: Option, pub created_at: String, pub updated_at: String, } pub async fn get_folder( Path(folder_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult> { let folder: Folder = folders::table .find(folder_id) .filter(folders::tenant_id.eq(tenant_id)) .first(&mut conn)?; Ok(Json(FolderResponse { folder: folder_to_info(folder), })) } pub async fn ensure_folder_path( TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { if payload.segments.is_empty() { return Err(AppError::bad_request("segments must not be empty")); } 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::tenant_id.eq(tenant_id)) .filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::name.eq(name)) .first(conn) .optional()? } else { folders::table .filter(folders::tenant_id.eq(tenant_id)) .filter(folders::parent_id.is_null()) .filter(folders::name.eq(name)) .first(conn) .optional()? }; let folder = if let Some(folder) = existing { folder } else { let new_folder = NewFolder { id: Uuid::new_v4(), name: name.to_string(), parent_id: current_parent, tenant_id, }; let inserted_id: Option = diesel::insert_into(folders::table) .values(&new_folder) .on_conflict_do_nothing() .returning(folders::id) .get_result(conn) .optional()?; if let Some(id) = inserted_id { folders::table .find(id) .filter(folders::tenant_id.eq(tenant_id)) .first(conn)? } else if let Some(parent_id) = current_parent { folders::table .filter(folders::tenant_id.eq(tenant_id)) .filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::name.eq(name)) .first(conn)? } else { folders::table .filter(folders::tenant_id.eq(tenant_id)) .filter(folders::parent_id.is_null()) .filter(folders::name.eq(name)) .first(conn)? } }; current_parent = Some(folder.id); last_folder = Some(folder); } last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path")) })?; Ok(Json(FolderResponse { folder: folder_to_info(target_folder), })) } pub async fn create_folder( TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult<(StatusCode, Json)> { if payload.name.trim().is_empty() { return Err(AppError::bad_request("name must not be empty")); } let name = payload.name.trim(); let existing: Option = if let Some(parent_id) = payload.parent_id { folders::table .filter(folders::tenant_id.eq(tenant_id)) .filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::name.eq(name)) .first(&mut conn) .optional()? } else { folders::table .filter(folders::tenant_id.eq(tenant_id)) .filter(folders::parent_id.is_null()) .filter(folders::name.eq(name)) .first(&mut conn) .optional()? }; let (folder, created): (Folder, bool) = if let Some(folder) = existing { (folder, false) } else { let new_folder = NewFolder { id: Uuid::new_v4(), name: name.to_string(), parent_id: payload.parent_id, tenant_id, }; let inserted_id: Option = diesel::insert_into(folders::table) .values(&new_folder) .on_conflict_do_nothing() .returning(folders::id) .get_result(&mut conn) .optional()?; if let Some(id) = inserted_id { ( folders::table .find(id) .filter(folders::tenant_id.eq(tenant_id)) .first(&mut conn)?, true, ) } else if let Some(parent_id) = payload.parent_id { ( folders::table .filter(folders::tenant_id.eq(tenant_id)) .filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::name.eq(name)) .first(&mut conn)?, false, ) } else { ( folders::table .filter(folders::tenant_id.eq(tenant_id)) .filter(folders::parent_id.is_null()) .filter(folders::name.eq(name)) .first(&mut conn)?, false, ) } }; let response = Json(FolderResponse { folder: folder_to_info(folder), }); if created { Ok((StatusCode::CREATED, response)) } else { Ok((StatusCode::OK, response)) } } pub async fn list_folder_contents( State(state): State, Path(folder_identifier): Path, Query(query): Query, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult> { 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) .filter(folders::tenant_id.eq(tenant_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)) .filter(folders::tenant_id.eq(tenant_id)) .order(folders::name.asc()) .load(&mut conn)? } else { folders::table .filter(folders::parent_id.is_null()) .filter(folders::tenant_id.eq(tenant_id)) .order(folders::name.asc()) .load(&mut conn)? }; let subfolders = child_folders.into_iter().map(folder_to_info).collect(); let documents = if query.include_documents { let docs_query = documents::table .filter(documents::deleted_at.is_null()) .filter(documents::tenant_id.eq(tenant_id)) .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, tenant_id, &docs)?; 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_id, doc, tags, correspondents, current_version, )?); } documents } else { Vec::new() }; Ok(Json(FolderContentsResponse { folder, subfolders, documents, })) } pub async fn delete_folder( Path(folder_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { conn.transaction::<_, AppError, _>(|conn| { folders::table .find(folder_id) .filter(folders::tenant_id.eq(tenant_id)) .first::(conn)?; let has_child_folders: bool = diesel::select(exists( folders::table .filter(folders::parent_id.eq(Some(folder_id))) .filter(folders::tenant_id.eq(tenant_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::tenant_id.eq(tenant_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 .filter(folders::id.eq(folder_id)) .filter(folders::tenant_id.eq(tenant_id)), ) .execute(conn)?; Ok(()) })?; Ok(StatusCode::NO_CONTENT) } pub async fn update_folder( Path(folder_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(body): Json, ) -> AppResult { if !body.is_object() { return Err(AppError::bad_request("request body must be a JSON object")); } let parent_class = classify_nullable(body.get("parent_id")).map_err(AppError::bad_request)?; let name_class = classify_nullable(body.get("name")).map_err(AppError::bad_request)?; conn.transaction::<(), AppError, _>(|conn| { let folder: Folder = folders::table .find(folder_id) .filter(folders::tenant_id.eq(tenant_id)) .first(conn)?; let mut next_parent = folder.parent_id; let mut parent_changed = false; match parent_class { NullableValue::Omitted => {} NullableValue::Null => { if folder.parent_id.is_some() { parent_changed = true; } next_parent = None; } NullableValue::String(value) => { let trimmed = value.trim(); if trimmed.is_empty() { return Err(AppError::bad_request("parent_id must not be empty")); } let parent_id = Uuid::parse_str(trimmed) .map_err(|_| AppError::bad_request("parent_id must be a valid UUID or null"))?; if parent_id == folder_id { return Err(AppError::bad_request("folder cannot be its own parent")); } let _parent: Folder = folders::table .find(parent_id) .filter(folders::tenant_id.eq(tenant_id)) .first(conn)?; if folder.parent_id != Some(parent_id) { let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?; if descendant_ids.contains(&parent_id) { return Err(AppError::bad_request( "cannot move folder into itself or a descendant", )); } parent_changed = true; } next_parent = Some(parent_id); } } let mut new_name = folder.name.clone(); let mut name_changed = false; match name_class { NullableValue::Omitted => {} NullableValue::Null => { return Err(AppError::bad_request("name cannot be null")); } NullableValue::String(value) => { let trimmed = value.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)) .filter(folders::tenant_id.eq(tenant_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)) .filter(folders::tenant_id.eq(tenant_id)) .first::(conn) .optional()? }; if conflict.is_some() { return Err(AppError::bad_request( "a folder with the same name already exists in the target", )); } diesel::update( folders::table .find(folder_id) .filter(folders::tenant_id.eq(tenant_id)), ) .set(( folders::parent_id.eq(next_parent), folders::name.eq(&new_name), )) .execute(conn)?; Ok(()) })?; Ok(StatusCode::NO_CONTENT) } fn folder_to_info(folder: Folder) -> FolderInfo { FolderInfo { id: folder.id, name: folder.name, parent_id: folder.parent_id, created_at: to_iso(folder.created_at), updated_at: to_iso(folder.updated_at), } } pub(super) fn gather_descendant_folder_ids( conn: &mut PgConnection, tenant_id: Uuid, 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))) .filter(folders::tenant_id.eq(tenant_id)) .select(folders::id) .load(conn)?; queue.extend(child_ids.iter().copied()); ids.extend(child_ids); } Ok(ids) }