1
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
use axum::{
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use diesel::{dsl::exists, prelude::*, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
use crate::schema::{document_tags, documents, folders};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::documents::{load_tags_for_documents, to_document_response, to_iso, DocumentResponse};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateFolderRequest {
|
||||
pub name: String,
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FolderResponse {
|
||||
pub folder: FolderInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FolderContentsResponse {
|
||||
pub folder: Option<FolderInfo>,
|
||||
pub subfolders: Vec<FolderInfo>,
|
||||
pub documents: Vec<DocumentResponse>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DocumentSearchQuery {
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FolderInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub path_cache: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn create_folder(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateFolderRequest>,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
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<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
) -> AppResult<Json<FolderContentsResponse>> {
|
||||
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::<Folder>(&mut conn)?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let child_folders: Vec<Folder> = 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<Document> = 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<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
|
||||
let documents = docs
|
||||
.into_iter()
|
||||
.map(|doc| {
|
||||
let tags = tags_map.get(&doc.id).cloned();
|
||||
to_document_response(doc, tags)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(FolderContentsResponse {
|
||||
folder,
|
||||
subfolders,
|
||||
documents,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn search_documents(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
Query(params): Query<DocumentSearchQuery>,
|
||||
) -> AppResult<Json<Vec<DocumentResponse>>> {
|
||||
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));
|
||||
}
|
||||
|
||||
if let Some(query) = params
|
||||
.query
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let pattern = format!("%{}%", query);
|
||||
docs_query = docs_query.filter(documents::original_name.ilike(pattern));
|
||||
}
|
||||
|
||||
if let Some(tags_param) = params
|
||||
.tags
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let tag_ids: Result<Vec<Uuid>, _> = 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<HashSet<Uuid>> = None;
|
||||
for tag_id in &ids {
|
||||
let docs_for_tag: Vec<Uuid> = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(*tag_id))
|
||||
.select(document_tags::document_id)
|
||||
.load(&mut conn)?;
|
||||
let docs_set: HashSet<Uuid> = 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: Vec<Uuid> =
|
||||
doc_id_set.unwrap_or_default().into_iter().collect();
|
||||
|
||||
if matching_doc_ids.is_empty() {
|
||||
return Ok(Json(vec![]));
|
||||
}
|
||||
|
||||
docs_query = docs_query.filter(documents::id.eq_any(matching_doc_ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let docs: Vec<Document> = docs_query
|
||||
.order(documents::uploaded_at.desc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
let response = docs
|
||||
.into_iter()
|
||||
.map(|doc| {
|
||||
let tags = tags_map.get(&doc.id).cloned();
|
||||
to_document_response(doc, tags)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn delete_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
conn.transaction::<_, AppError, _>(|conn| {
|
||||
folders::table.find(folder_id).first::<Folder>(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)
|
||||
}
|
||||
|
||||
fn build_path_cache(
|
||||
conn: &mut PgConnection,
|
||||
parent_id: Option<Uuid>,
|
||||
name: &str,
|
||||
) -> AppResult<Option<String>> {
|
||||
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<Vec<Uuid>> {
|
||||
let mut ids = vec![folder_id];
|
||||
let mut queue = vec![folder_id];
|
||||
|
||||
while let Some(current) = queue.pop() {
|
||||
let child_ids: Vec<Uuid> = 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)
|
||||
}
|
||||
Reference in New Issue
Block a user