This commit is contained in:
2025-10-09 22:16:29 +02:00
commit 768f8cb21c
33 changed files with 12047 additions and 0 deletions
+327
View File
@@ -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)
}