use anyhow::anyhow; use axum::{ extract::{Json, Path, Query, State}, http::StatusCode, }; use diesel::{dsl::exists, prelude::*, PgConnection}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; use crate::models::{Document, Folder, NewFolder}; use crate::schema::{document_tags, documents, folders}; use crate::state::AppState; use crate::{ auth::AuthenticatedUser, error::{AppError, AppResult}, }; use super::documents::{ load_primary_assets, load_tags_for_documents, to_document_response, to_iso, DocumentResponse, }; const QUICKWIT_MAX_HITS: usize = 200; #[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 { pub parent_id: Option, } #[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 DocumentSearchQuery { pub query: Option, pub tags: Option, } #[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)?; 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 current_version = primary_versions.get(&doc.id).cloned(); documents.push(to_document_response( &state, user.user_id, doc, tags, current_version, )?); } Ok(Json(FolderContentsResponse { folder, subfolders, documents, })) } pub async fn search_documents( State(state): State, Path(folder_identifier): Path, Query(params): Query, 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 mut docs_query = documents::table .filter(documents::deleted_at.is_null()) .into_boxed(); if let Some(folder_id) = folder_id { let descendant_ids = gather_descendant_folder_ids(&mut conn, folder_id)?; docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids)); } let mut filter_ids: Option> = None; let mut quickwit_order: Option> = None; if let Some(query) = params .query .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { let endpoint = state .config .quickwit_endpoint .as_ref() .ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?; let index = state .config .quickwit_index .as_ref() .ok_or_else(|| AppError::internal("quickwit index not configured"))?; let ids = quickwit_search(endpoint, index, query) .await .map_err(|err| AppError::internal(format!("quickwit search failed: {err}")))?; if ids.is_empty() { return Ok(Json(vec![])); } quickwit_order = Some(ids.clone()); let set: HashSet = ids.into_iter().collect(); filter_ids = Some(match &filter_ids { Some(existing) => existing.intersection(&set).copied().collect(), None => set, }); } if let Some(tags_param) = params .tags .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { 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); } } } if let Some(ref set) = filter_ids { if set.is_empty() { return Ok(Json(vec![])); } let ids_vec: Vec = set.iter().copied().collect(); docs_query = docs_query.filter(documents::id.eq_any(ids_vec)); } let docs: Vec = if let Some(order_ids) = quickwit_order.as_ref() { let relevant_ids: Vec = if let Some(filter_set) = filter_ids.as_ref() { order_ids .iter() .copied() .filter(|id| filter_set.contains(id)) .collect() } else { order_ids.clone() }; if relevant_ids.is_empty() { return Ok(Json(vec![])); } let fetched: Vec = docs_query.load(&mut conn)?; let mut by_id: HashMap = fetched.into_iter().map(|doc| (doc.id, doc)).collect(); let mut ordered = Vec::with_capacity(by_id.len()); for id in relevant_ids { if let Some(doc) = by_id.remove(&id) { ordered.push(doc); } } if !by_id.is_empty() { let mut remaining: Vec = by_id.into_values().collect(); remaining.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); ordered.extend(remaining); } ordered } else { docs_query .order(documents::uploaded_at.desc()) .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)?; drop(conn); let primary_versions = load_primary_assets(&state, &docs).await?; let mut response = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); let current_version = primary_versions.get(&doc.id).cloned(); response.push(to_document_response( &state, user.user_id, doc, tags, current_version, )?); } Ok(Json(response)) } async fn quickwit_search(endpoint: &str, index: &str, query: &str) -> anyhow::Result> { let quickwit_query = match build_quickwit_query(query) { Some(q) => q, None => return Ok(vec![]), }; let client = Client::new(); let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index); let payload = json!({ "query": quickwit_query, "max_hits": QUICKWIT_MAX_HITS, }); let response = client.post(url).json(&payload).send().await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(anyhow!( "quickwit search failed with status {status}: {body}" )); } let data: QuickwitSearchResponse = response.json().await?; let mut seen = HashSet::new(); let mut doc_ids = Vec::new(); for hit in data.hits { if let Some(doc_id) = extract_document_id(&hit) { if seen.insert(doc_id) { doc_ids.push(doc_id); } } } Ok(doc_ids) } fn build_quickwit_query(input: &str) -> Option { let tokens: Vec = input .split_whitespace() .filter(|token| !token.is_empty()) .map(|token| { let normalized = token.to_lowercase(); escape_quickwit_token(&normalized) }) .collect(); if tokens.is_empty() { return None; } let parts: Vec = tokens .into_iter() .map(|token| format!("(title:{token} OR text:{token})")) .collect(); Some(parts.join(" AND ")) } fn escape_quickwit_token(token: &str) -> String { let mut escaped = String::with_capacity(token.len()); for ch in token.chars() { match ch { '+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' | '*' | '?' | ':' | '\\' | '/' => { escaped.push('\\'); escaped.push(ch); } _ => escaped.push(ch), } } escaped } #[derive(Deserialize)] struct QuickwitSearchResponse { #[serde(default)] hits: Vec, } fn extract_document_id(hit: &Value) -> Option { for key in ["_source", "source", "fields", "stored_fields"] { if let Some(value) = hit.get(key) { if let Some(uuid) = extract_uuid_from_value(value) { return Some(uuid); } } } if let Some(value) = hit.get("document_id") { if let Some(uuid) = extract_uuid_from_value(value) { return Some(uuid); } } None } fn extract_uuid_from_value(value: &Value) -> Option { if let Some(obj) = value.as_object() { if let Some(inner) = obj.get("document_id") { return parse_uuid_value(inner); } } if let Some(arr) = value.as_array() { for item in arr { if let Some(uuid) = extract_uuid_from_value(item) { return Some(uuid); } } } parse_uuid_value(value) } fn parse_uuid_value(value: &Value) -> Option { if let Some(s) = value.as_str() { return Uuid::parse_str(s).ok(); } if let Some(arr) = value.as_array() { for item in arr { if let Some(uuid) = parse_uuid_value(item) { return Some(uuid); } } } None } 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_parent( State(state): State, Path(folder_id): Path, Json(payload): Json, ) -> AppResult { if payload.parent_id == Some(folder_id) { return Err(AppError::bad_request("folder cannot be its own parent")); } let mut conn = state.db()?; conn.transaction::<(), AppError, _>(|conn| { let folder: Folder = folders::table.find(folder_id).first(conn)?; let current_parent = folder.parent_id; let next_parent = payload.parent_id; if current_parent == next_parent { return Ok(()); } if let Some(parent_id) = next_parent { let _parent: Folder = folders::table.find(parent_id).first(conn)?; } if let Some(parent_id) = next_parent { 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", )); } } let conflict = if let Some(parent_id) = next_parent { folders::table .filter(folders::parent_id.eq(Some(parent_id))) .filter(folders::name.eq(&folder.name)) .filter(folders::id.ne(folder_id)) .first::(conn) .optional()? } else { folders::table .filter(folders::parent_id.is_null()) .filter(folders::name.eq(&folder.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, &folder.name)?; diesel::update(folders::table.find(folder_id)) .set(( folders::parent_id.eq(next_parent), 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), } } 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(()) }