folder tree
This commit is contained in:
@@ -4,7 +4,7 @@ use axum::extract::{Json, Multipart, Path, Query, State};
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
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 diesel::{prelude::*, result::DatabaseErrorKind, select, sql_types::Text, OptionalExtension};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -467,6 +467,29 @@ pub async fn list_documents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(tags_param) = tags_param.as_ref() {
|
if let Some(tags_param) = tags_param.as_ref() {
|
||||||
|
if tags_param.trim().eq_ignore_ascii_case("none") {
|
||||||
|
let docs_without_tags: Vec<Uuid> = 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)?;
|
||||||
|
|
||||||
|
let docs_set: HashSet<Uuid> = docs_without_tags.into_iter().collect();
|
||||||
|
|
||||||
|
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<Vec<Uuid>, _> = tags_param
|
let tag_ids: Result<Vec<Uuid>, _> = tags_param
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| Uuid::parse_str(s.trim()))
|
.map(|s| Uuid::parse_str(s.trim()))
|
||||||
@@ -500,7 +523,9 @@ pub async fn list_documents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let new_filter = match &filter_ids {
|
let new_filter = match &filter_ids {
|
||||||
Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(),
|
Some(existing) => {
|
||||||
|
existing.intersection(&matching_doc_ids).copied().collect()
|
||||||
|
}
|
||||||
None => matching_doc_ids.clone(),
|
None => matching_doc_ids.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -508,6 +533,7 @@ pub async fn list_documents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(correspondents_param) = correspondents_param.as_ref() {
|
if let Some(correspondents_param) = correspondents_param.as_ref() {
|
||||||
let correspondent_ids: Result<Vec<Uuid>, _> = correspondents_param
|
let correspondent_ids: Result<Vec<Uuid>, _> = correspondents_param
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ use axum::{
|
|||||||
extract::{Json, Path, Query, State},
|
extract::{Json, Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use diesel::{dsl::exists, prelude::*, PgConnection};
|
use diesel::{
|
||||||
|
dsl::{exists, sql},
|
||||||
|
prelude::*,
|
||||||
|
sql_types::Text,
|
||||||
|
PgConnection,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -18,8 +23,7 @@ use crate::{
|
|||||||
use super::documents::{hydrate_documents, DocumentResponse};
|
use super::documents::{hydrate_documents, DocumentResponse};
|
||||||
use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection};
|
use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection};
|
||||||
use crate::utils::{json::deserialize_patch_field, time::to_iso};
|
use crate::utils::{json::deserialize_patch_field, time::to_iso};
|
||||||
use diesel::dsl::sql;
|
use std::collections::HashMap;
|
||||||
use diesel::sql_types::Text;
|
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct CreateFolderRequest {
|
pub struct CreateFolderRequest {
|
||||||
@@ -48,6 +52,18 @@ pub struct FolderContentsResponse {
|
|||||||
pub documents: Vec<DocumentResponse>,
|
pub documents: Vec<DocumentResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, ToSchema)]
|
||||||
|
pub struct FolderTreeNode {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub children: Vec<FolderTreeNode>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||||
#[into_params(parameter_in = Query)]
|
#[into_params(parameter_in = Query)]
|
||||||
pub struct FolderContentsQuery {
|
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<Json<Vec<FolderTreeNode>>> {
|
||||||
|
let folders: Vec<Folder> = folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
let mut node_map: HashMap<Uuid, FolderTreeNode> = HashMap::with_capacity(folders.len());
|
||||||
|
let mut children_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
|
||||||
|
let mut roots: Vec<Uuid> = 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<Uuid, FolderTreeNode>,
|
||||||
|
child_map: &HashMap<Uuid, Vec<Uuid>>,
|
||||||
|
) -> 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(
|
#[utoipa::path(
|
||||||
delete,
|
delete,
|
||||||
path = "/api/folders/{id}",
|
path = "/api/folders/{id}",
|
||||||
@@ -639,6 +723,7 @@ pub(super) fn gather_descendant_folder_ids(
|
|||||||
crate::routes::folders::ensure_folder_path,
|
crate::routes::folders::ensure_folder_path,
|
||||||
crate::routes::folders::get_folder,
|
crate::routes::folders::get_folder,
|
||||||
crate::routes::folders::list_folder_contents,
|
crate::routes::folders::list_folder_contents,
|
||||||
|
crate::routes::folders::list_folder_tree,
|
||||||
crate::routes::folders::delete_folder,
|
crate::routes::folders::delete_folder,
|
||||||
crate::routes::folders::update_folder
|
crate::routes::folders::update_folder
|
||||||
),
|
),
|
||||||
@@ -649,6 +734,7 @@ pub(super) fn gather_descendant_folder_ids(
|
|||||||
crate::routes::folders::FolderInfo,
|
crate::routes::folders::FolderInfo,
|
||||||
crate::routes::folders::FolderContentsQuery,
|
crate::routes::folders::FolderContentsQuery,
|
||||||
crate::routes::folders::FolderContentsResponse,
|
crate::routes::folders::FolderContentsResponse,
|
||||||
|
crate::routes::folders::FolderTreeNode,
|
||||||
crate::routes::folders::UpdateFolderRequest
|
crate::routes::folders::UpdateFolderRequest
|
||||||
))
|
))
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -223,6 +223,11 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
post(folders::ensure_folder_path)
|
post(folders::ensure_folder_path)
|
||||||
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/tree",
|
||||||
|
get(folders::list_folder_tree)
|
||||||
|
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/:id",
|
"/:id",
|
||||||
get(folders::get_folder)
|
get(folders::get_folder)
|
||||||
|
|||||||
@@ -605,6 +605,81 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
|
|||||||
Ok(())
|
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<DocumentListItem> = 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<DocumentListItem> = 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]
|
#[tokio::test]
|
||||||
async fn bulk_move_documents_to_folder() -> Result<()> {
|
async fn bulk_move_documents_to_folder() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ struct DocSummary {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FolderTreeNodeResponse {
|
||||||
|
name: String,
|
||||||
|
children: Vec<FolderTreeNodeResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct CreateFolder<'a> {
|
struct CreateFolder<'a> {
|
||||||
name: &'a str,
|
name: &'a str,
|
||||||
@@ -144,6 +150,71 @@ async fn folder_move_and_delete_flow() -> Result<()> {
|
|||||||
Ok(())
|
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<FolderTreeNodeResponse> = 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]
|
#[tokio::test]
|
||||||
async fn update_folder_parent_to_root() -> Result<()> {
|
async fn update_folder_parent_to_root() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
|
|||||||
Reference in New Issue
Block a user