This commit is contained in:
2025-11-06 22:21:27 +01:00
parent a6f79dbc75
commit ef2ed2dc58
23 changed files with 4371 additions and 3482 deletions
+50 -550
View File
@@ -2,52 +2,28 @@ use axum::{
extract::{Json, Path, Query, State},
http::StatusCode,
};
use diesel::{
dsl::{exists, sql},
prelude::*,
sql_types::Text,
PgConnection,
};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use utoipa::OpenApi;
use uuid::Uuid;
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders};
use crate::state::AppState;
use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult},
http::responders::{
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
http::responders::{created_json, no_content, ok_json, JsonResponse},
services::folders::{
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery,
FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest,
},
state::AppState,
};
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 std::collections::HashMap;
use crate::services::documents::DocumentResponse;
#[derive(Deserialize, ToSchema)]
pub struct CreateFolderRequest {
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
}
#[derive(Deserialize, ToSchema)]
pub struct EnsureFolderPathRequest {
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Serialize, ToSchema)]
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct FolderResponse {
pub folder: FolderInfo,
}
#[derive(Serialize, ToSchema)]
#[derive(utoipa::ToSchema, serde::Serialize)]
pub struct FolderContentsResponse {
#[schema(nullable)]
pub folder: Option<FolderInfo>,
@@ -55,81 +31,6 @@ pub struct FolderContentsResponse {
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)]
#[into_params(parameter_in = Query)]
pub struct FolderContentsQuery {
#[serde(default = "default_include_documents")]
#[schema(default = true)]
pub include_documents: bool,
#[serde(default)]
#[schema(default = "title")]
pub sort: DocumentSortField,
#[serde(default)]
#[schema(default = "asc")]
pub dir: SortDirection,
}
const fn default_include_documents() -> bool {
true
}
#[derive(Serialize, ToSchema)]
pub struct FolderInfo {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Default, Deserialize, ToSchema)]
pub struct UpdateFolderRequest {
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<Uuid>)]
pub parent_id: Option<Option<Uuid>>,
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable)]
pub name: Option<Option<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn update_folder_request_deserializes_null_parent() {
let req: UpdateFolderRequest =
serde_json::from_value(json!({ "parent_id": null })).unwrap();
assert!(matches!(req.parent_id, Some(None)));
}
#[test]
fn update_folder_request_deserializes_absent_parent() {
let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap();
assert!(req.parent_id.is_none());
}
#[test]
fn update_folder_request_deserializes_null_name() {
let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap();
assert!(matches!(req.name, Some(None)));
}
}
#[utoipa::path(
get,
path = "/api/folders/{id}",
@@ -138,6 +39,7 @@ mod tests {
tag = "Folders"
)]
pub async fn get_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
@@ -145,14 +47,9 @@ pub async fn get_folder(
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<FolderResponse>> {
let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(&mut conn)?;
ok_json(FolderResponse {
folder: folder_to_info(folder),
})
let service = FolderService::new(&state);
let folder = service.get_folder(&mut conn, tenant_id, folder_id)?;
ok_json(FolderResponse { folder })
}
#[utoipa::path(
@@ -163,6 +60,7 @@ pub async fn get_folder(
tag = "Folders"
)]
pub async fn ensure_folder_path(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
@@ -170,82 +68,9 @@ pub async fn ensure_folder_path(
}: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<JsonResponse<FolderResponse>> {
if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty"));
}
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = 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<Folder> = if let Some(parent_id) = current_parent {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(conn)
.optional()?
};
let folder = if let Some(folder) = existing {
folder
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: current_parent,
tenant_id,
};
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder)
.on_conflict_do_nothing()
.returning(folders::id)
.get_result(conn)
.optional()?;
if let Some(id) = inserted_id {
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?
} else if let Some(parent_id) = current_parent {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(conn)?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(conn)?
}
};
current_parent = Some(folder.id);
last_folder = Some(folder);
}
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
})?;
ok_json(FolderResponse {
folder: folder_to_info(target_folder),
})
let service = FolderService::new(&state);
let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?;
ok_json(FolderResponse { folder })
}
#[utoipa::path(
@@ -259,6 +84,7 @@ pub async fn ensure_folder_path(
tag = "Folders"
)]
pub async fn create_folder(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
@@ -266,78 +92,9 @@ pub async fn create_folder(
}: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>,
) -> AppResult<JsonResponse<FolderResponse>> {
if payload.name.trim().is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
let name = payload.name.trim();
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(&mut conn)
.optional()?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(&mut conn)
.optional()?
};
let (folder, created): (Folder, bool) = if let Some(folder) = existing {
(folder, false)
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: payload.parent_id,
tenant_id,
};
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder)
.on_conflict_do_nothing()
.returning(folders::id)
.get_result(&mut conn)
.optional()?;
if let Some(id) = inserted_id {
(
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(&mut conn)?,
true,
)
} else if let Some(parent_id) = payload.parent_id {
(
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(&mut conn)?,
false,
)
} else {
(
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(&mut conn)?,
false,
)
}
};
let response = FolderResponse {
folder: folder_to_info(folder),
};
let service = FolderService::new(&state);
let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?;
let response = FolderResponse { folder };
if created {
created_json(response)
} else {
@@ -348,7 +105,7 @@ pub async fn create_folder(
#[utoipa::path(
get,
path = "/api/folders/{id}/contents",
params(("id" = Uuid, Path, description = "Folder ID"), FolderContentsQuery),
params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery),
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
tag = "Folders"
)]
@@ -378,54 +135,22 @@ pub async fn list_folder_contents(
)
};
let folder = match folder_id {
Some(id) => Some(folder_to_info(
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_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))
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(&mut conn)?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(&mut conn)?
};
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
let service = FolderService::new(&state);
let FolderContentsData {
folder,
subfolders,
documents,
} = service.list_folder_contents(
&mut conn,
tenant_id,
folder_id,
sort,
dir,
include_documents,
)?;
let documents = if include_documents {
let mut docs_query = documents::table
.filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.into_boxed();
let (primary_sql, secondary_sql) = ordering_clauses(sort, dir);
docs_query = docs_query.order(sql::<Text>(primary_sql));
if let Some(second) = secondary_sql {
docs_query = docs_query.then_order_by(sql::<Text>(second));
}
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)?
};
hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?
service.hydrate_documents(&mut conn, tenant_id, user_id, documents)?
} else {
Vec::new()
};
@@ -444,64 +169,15 @@ pub async fn list_folder_contents(
tag = "Folders"
)]
pub async fn list_folder_tree(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<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();
let service = FolderService::new(&state);
let tree = service.list_folder_tree(&mut conn, tenant_id)?;
ok_json(tree)
}
@@ -513,6 +189,7 @@ pub async fn list_folder_tree(
tag = "Folders"
)]
pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
@@ -520,51 +197,7 @@ pub async fn delete_folder(
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
conn.transaction::<_, AppError, _>(|conn| {
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?;
let has_child_folders: bool = diesel::select(exists(
folders::table
.filter(folders::parent_id.eq(Some(folder_id)))
.filter(folders::tenant_id.eq(tenant_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::tenant_id.eq(tenant_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
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
)
.execute(conn)
.into_app_result()?
.or_not_found()?;
Ok(())
})?;
FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?;
no_content()
}
@@ -577,6 +210,7 @@ pub async fn delete_folder(
tag = "Folders"
)]
pub async fn update_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
@@ -585,145 +219,11 @@ pub async fn update_folder(
}: TenantScopedConn,
Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> {
conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let mut next_parent = folder.parent_id;
let mut parent_changed = false;
match payload.parent_id {
None => {}
Some(None) => {
if folder.parent_id.is_some() {
parent_changed = true;
}
next_parent = None;
}
Some(Some(parent_id)) => {
if parent_id == folder_id {
return Err(AppError::bad_request("folder cannot be its own parent"));
}
let _parent: Folder = folders::table
.find(parent_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
if folder.parent_id != Some(parent_id) {
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request(
"cannot move folder into itself or a descendant",
));
}
parent_changed = true;
}
next_parent = Some(parent_id);
}
}
let mut new_name = folder.name.clone();
let mut name_changed = false;
match payload.name {
None => {}
Some(None) => {
return Err(AppError::bad_request("name cannot be null"));
}
Some(Some(value)) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != folder.name {
new_name = trimmed.to_string();
name_changed = true;
}
}
}
if !parent_changed && !name_changed {
return Ok(());
}
let conflict = if let Some(parent_id) = next_parent {
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)
.optional()?
};
if conflict.is_some() {
return Err(AppError::bad_request(
"a folder with the same name already exists in the target",
));
}
diesel::update(
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id)),
)
.set((
folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
))
.execute(conn)
.into_app_result()?
.or_not_found()?;
Ok(())
})?;
FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?;
no_content()
}
fn folder_to_info(folder: Folder) -> FolderInfo {
FolderInfo {
id: folder.id,
name: folder.name,
parent_id: folder.parent_id,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
}
}
pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection,
tenant_id: Uuid,
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)))
.filter(folders::tenant_id.eq(tenant_id))
.select(folders::id)
.load(conn)?;
queue.extend(child_ids.iter().copied());
ids.extend(child_ids);
}
Ok(ids)
}
#[derive(utoipa::OpenApi)]
#[derive(OpenApi)]
#[openapi(
paths(
crate::routes::folders::create_folder,
@@ -735,14 +235,14 @@ pub(super) fn gather_descendant_folder_ids(
crate::routes::folders::update_folder
),
components(schemas(
crate::routes::folders::CreateFolderRequest,
crate::routes::folders::EnsureFolderPathRequest,
crate::services::folders::CreateFolderRequest,
crate::services::folders::EnsureFolderPathRequest,
crate::routes::folders::FolderResponse,
crate::routes::folders::FolderInfo,
crate::routes::folders::FolderContentsQuery,
crate::services::folders::FolderInfo,
crate::services::folders::FolderContentsQuery,
crate::routes::folders::FolderContentsResponse,
crate::routes::folders::FolderTreeNode,
crate::routes::folders::UpdateFolderRequest
crate::services::folders::FolderTreeNode,
crate::services::folders::UpdateFolderRequest
))
)]
pub struct FoldersApiDoc;