diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index fc7234a..d194977 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -4,7 +4,7 @@ use axum::extract::{Json, Multipart, Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use chrono::{DateTime, NaiveDateTime, Utc}; -use diesel::dsl::{exists, sql}; +use diesel::dsl::{exists, not, sql}; use diesel::{prelude::*, result::DatabaseErrorKind, select, sql_types::Text, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -467,44 +467,70 @@ pub async fn list_documents( } if let Some(tags_param) = tags_param.as_ref() { - let tag_ids: Result, _> = tags_param - .split(',') - .map(|s| Uuid::parse_str(s.trim())) - .collect(); + if tags_param.trim().eq_ignore_ascii_case("none") { + let docs_without_tags: Vec = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .filter(documents::deleted_at.is_null()) + .filter(not(exists( + document_tags::table.filter(document_tags::document_id.eq(documents::id)), + ))) + .select(documents::id) + .load(&mut conn)?; - if let Ok(ids) = tag_ids { - if !ids.is_empty() { - let mut doc_id_set: Option> = None; - for tag_id in &ids { - let docs_for_tag: Vec = document_tags::table - .filter(document_tags::tag_id.eq(*tag_id)) - .select(document_tags::document_id) - .load(&mut conn)?; - let docs_set: HashSet = docs_for_tag.into_iter().collect(); - doc_id_set = Some(match doc_id_set { - Some(existing) => existing.intersection(&docs_set).cloned().collect(), - None => docs_set, - }); + let docs_set: HashSet = docs_without_tags.into_iter().collect(); - if let Some(ref set) = doc_id_set { - if set.is_empty() { - break; + if docs_set.is_empty() { + return Ok(Json(vec![])); + } + + let new_filter = match &filter_ids { + Some(existing) => existing.intersection(&docs_set).copied().collect(), + None => docs_set, + }; + + filter_ids = Some(new_filter); + } else { + let tag_ids: Result, _> = tags_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = tag_ids { + if !ids.is_empty() { + let mut doc_id_set: Option> = None; + for tag_id in &ids { + let docs_for_tag: Vec = document_tags::table + .filter(document_tags::tag_id.eq(*tag_id)) + .select(document_tags::document_id) + .load(&mut conn)?; + let docs_set: HashSet = docs_for_tag.into_iter().collect(); + doc_id_set = Some(match doc_id_set { + Some(existing) => existing.intersection(&docs_set).cloned().collect(), + None => docs_set, + }); + + if let Some(ref set) = doc_id_set { + if set.is_empty() { + break; + } } } + + let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); + + if matching_doc_ids.is_empty() { + return Ok(Json(vec![])); + } + + let new_filter = match &filter_ids { + Some(existing) => { + existing.intersection(&matching_doc_ids).copied().collect() + } + None => matching_doc_ids.clone(), + }; + + filter_ids = Some(new_filter); } - - let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); - - if matching_doc_ids.is_empty() { - return Ok(Json(vec![])); - } - - let new_filter = match &filter_ids { - Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(), - None => matching_doc_ids.clone(), - }; - - filter_ids = Some(new_filter); } } } diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index bf590b4..dd613a2 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -2,7 +2,12 @@ use axum::{ extract::{Json, Path, Query, State}, http::StatusCode, }; -use diesel::{dsl::exists, prelude::*, PgConnection}; +use diesel::{ + dsl::{exists, sql}, + prelude::*, + sql_types::Text, + PgConnection, +}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; @@ -18,8 +23,7 @@ use crate::{ use super::documents::{hydrate_documents, DocumentResponse}; use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection}; use crate::utils::{json::deserialize_patch_field, time::to_iso}; -use diesel::dsl::sql; -use diesel::sql_types::Text; +use std::collections::HashMap; #[derive(Deserialize, ToSchema)] pub struct CreateFolderRequest { @@ -48,6 +52,18 @@ pub struct FolderContentsResponse { pub documents: Vec, } +#[derive(Clone, Serialize, ToSchema)] +pub struct FolderTreeNode { + pub id: Uuid, + pub name: String, + #[schema(nullable)] + pub parent_id: Option, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub children: Vec, +} + #[derive(Deserialize, IntoParams, ToSchema)] #[into_params(parameter_in = Query)] pub struct FolderContentsQuery { @@ -418,6 +434,74 @@ pub async fn list_folder_contents( })) } +#[utoipa::path( + get, + path = "/api/folders/tree", + responses((status = 200, description = "Folder hierarchy", body = [FolderTreeNode])), + tag = "Folders" +)] +pub async fn list_folder_tree( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let folders: Vec = folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(&mut conn)?; + + let mut node_map: HashMap = HashMap::with_capacity(folders.len()); + let mut children_map: HashMap> = HashMap::new(); + let mut roots: Vec = Vec::new(); + + for folder in folders { + let id = folder.id; + let parent_id = folder.parent_id; + let node = FolderTreeNode { + id, + name: folder.name, + parent_id, + created_at: to_iso(folder.created_at), + updated_at: to_iso(folder.updated_at), + children: Vec::new(), + }; + + if let Some(parent) = parent_id { + children_map.entry(parent).or_default().push(id); + } else { + roots.push(id); + } + + node_map.insert(id, node); + } + + fn build_node( + id: Uuid, + nodes: &HashMap, + child_map: &HashMap>, + ) -> FolderTreeNode { + let mut node = nodes.get(&id).cloned().expect("folder node must exist"); + + if let Some(children) = child_map.get(&id) { + node.children = children + .iter() + .map(|child_id| build_node(*child_id, nodes, child_map)) + .collect(); + } + + node + } + + let tree = roots + .iter() + .map(|root_id| build_node(*root_id, &node_map, &children_map)) + .collect(); + + Ok(Json(tree)) +} + #[utoipa::path( delete, path = "/api/folders/{id}", @@ -639,6 +723,7 @@ pub(super) fn gather_descendant_folder_ids( crate::routes::folders::ensure_folder_path, crate::routes::folders::get_folder, crate::routes::folders::list_folder_contents, + crate::routes::folders::list_folder_tree, crate::routes::folders::delete_folder, crate::routes::folders::update_folder ), @@ -649,6 +734,7 @@ pub(super) fn gather_descendant_folder_ids( crate::routes::folders::FolderInfo, crate::routes::folders::FolderContentsQuery, crate::routes::folders::FolderContentsResponse, + crate::routes::folders::FolderTreeNode, crate::routes::folders::UpdateFolderRequest )) )] diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index f6c8379..b79bb48 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -223,6 +223,11 @@ pub fn create_router(state: AppState) -> Router<()> { post(folders::ensure_folder_path) .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), ) + .route( + "/tree", + get(folders::list_folder_tree) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), + ) .route( "/:id", get(folders::get_folder) diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index 67c10ab..031999d 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -605,6 +605,81 @@ async fn upload_skips_existing_when_requested() -> Result<()> { Ok(()) } +#[tokio::test] +async fn filter_documents_without_tags() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "tagfilter"; + app.insert_user("tagfilter", password, "admin").await?; + let token = app.login_token("tagfilter", password).await?; + + // Create a tag and upload a document that uses it. + let tag_payload = CreateTagPayload { + label: "with-tag", + color: None, + }; + let tag_resp = app + .post_json("/api/tags", &tag_payload, Some(&token)) + .await?; + assert!(tag_resp.status().is_success()); + let tag_body = body_to_vec(tag_resp.into_body()).await?; + let tag: TagResponse = serde_json::from_slice(&tag_body)?; + + let tag_json = format!("[\"{}\"]", tag.id); + let tagged_upload = app + .upload_document_with_extras( + "/api/documents", + "with-tag.txt", + "text/plain", + b"tagged", + None, + UploadExtras { + title: Some("With Tag"), + metadata_json: None, + tag_ids_json: Some(tag_json.as_str()), + correspondents_json: None, + issued_at: None, + skip_existing: None, + }, + &token, + ) + .await?; + assert!(tagged_upload.status().is_success()); + + let untagged_upload = app + .upload_document( + "/api/documents", + "without-tag.txt", + "text/plain", + b"untagged", + None, + &token, + ) + .await?; + assert!(untagged_upload.status().is_success()); + let untagged_body = body_to_vec(untagged_upload.into_body()).await?; + let untagged_detail: DocumentDetail = serde_json::from_slice(&untagged_body)?; + + // Sanity: both documents appear in the default listing. + let all_resp = app.get("/api/documents", Some(&token)).await?; + assert_eq!(all_resp.status(), StatusCode::OK); + let all_body = body_to_vec(all_resp.into_body()).await?; + let all_docs: Vec = serde_json::from_slice(&all_body)?; + assert_eq!(all_docs.len(), 2); + + // Filter for documents without tags. + let none_resp = app.get("/api/documents?tags=none", Some(&token)).await?; + assert_eq!(none_resp.status(), StatusCode::OK); + let none_body = body_to_vec(none_resp.into_body()).await?; + let none_docs: Vec = serde_json::from_slice(&none_body)?; + assert_eq!(none_docs.len(), 1); + assert_eq!(none_docs[0].id, untagged_detail.document.id); + + app.cleanup().await?; + Ok(()) +} + #[tokio::test] async fn bulk_move_documents_to_folder() -> Result<()> { let _lock = acquire_db_lock().await; diff --git a/backend/tests/folders_flow.rs b/backend/tests/folders_flow.rs index 12d487b..d26a160 100644 --- a/backend/tests/folders_flow.rs +++ b/backend/tests/folders_flow.rs @@ -32,6 +32,12 @@ struct DocSummary { id: Uuid, } +#[derive(Deserialize)] +struct FolderTreeNodeResponse { + name: String, + children: Vec, +} + #[derive(Serialize)] struct CreateFolder<'a> { name: &'a str, @@ -144,6 +150,71 @@ async fn folder_move_and_delete_flow() -> Result<()> { Ok(()) } +#[tokio::test] +async fn folder_tree_lists_hierarchy() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "folderpass"; + app.insert_user("folder-tree", password, "admin").await?; + let token = app.login_token("folder-tree", password).await?; + + let alpha_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Alpha", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(alpha_resp.status(), StatusCode::CREATED); + let alpha_body = body_to_vec(alpha_resp.into_body()).await?; + let alpha: FolderResponse = serde_json::from_slice(&alpha_body)?; + + let archive_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Archive", + parent_id: Some(alpha.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(archive_resp.status(), StatusCode::CREATED); + + let beta_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Beta", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(beta_resp.status(), StatusCode::CREATED); + + let tree_resp = app.get("/api/folders/tree", Some(&token)).await?; + assert_eq!(tree_resp.status(), StatusCode::OK); + let tree_body = body_to_vec(tree_resp.into_body()).await?; + let tree: Vec = serde_json::from_slice(&tree_body)?; + + assert_eq!(tree.len(), 2); + assert_eq!(tree[0].name, "Alpha"); + assert_eq!(tree[0].children.len(), 1); + assert_eq!(tree[0].children[0].name, "Archive"); + assert!(tree[0].children[0].children.is_empty()); + + assert_eq!(tree[1].name, "Beta"); + assert!(tree[1].children.is_empty()); + + app.cleanup().await?; + Ok(()) +} + #[tokio::test] async fn update_folder_parent_to_root() -> Result<()> { let _lock = acquire_db_lock().await;