742 lines
22 KiB
Rust
742 lines
22 KiB
Rust
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 uuid::Uuid;
|
|
|
|
use crate::models::{Document, Folder, NewFolder};
|
|
use crate::schema::{documents, folders};
|
|
use crate::state::AppState;
|
|
use crate::{
|
|
auth::TenantScopedConn,
|
|
error::{AppError, AppResult},
|
|
};
|
|
|
|
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;
|
|
|
|
#[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)]
|
|
pub struct FolderResponse {
|
|
pub folder: FolderInfo,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct FolderContentsResponse {
|
|
#[schema(nullable)]
|
|
pub folder: Option<FolderInfo>,
|
|
pub subfolders: Vec<FolderInfo>,
|
|
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}",
|
|
params(("id" = Uuid, Path, description = "Folder ID")),
|
|
responses((status = 200, description = "Folder detail", body = FolderResponse)),
|
|
tag = "Folders"
|
|
)]
|
|
pub async fn get_folder(
|
|
Path(folder_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<Json<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),
|
|
}))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/folders/path",
|
|
request_body = EnsureFolderPathRequest,
|
|
responses((status = 200, description = "Folder path ensured", body = FolderResponse)),
|
|
tag = "Folders"
|
|
)]
|
|
pub async fn ensure_folder_path(
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<EnsureFolderPathRequest>,
|
|
) -> AppResult<Json<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),
|
|
}))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/folders",
|
|
request_body = CreateFolderRequest,
|
|
responses(
|
|
(status = 201, description = "Folder created", body = FolderResponse),
|
|
(status = 200, description = "Folder already existed", body = FolderResponse)
|
|
),
|
|
tag = "Folders"
|
|
)]
|
|
pub async fn create_folder(
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<CreateFolderRequest>,
|
|
) -> AppResult<(StatusCode, Json<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 = Json(FolderResponse {
|
|
folder: folder_to_info(folder),
|
|
});
|
|
|
|
if created {
|
|
Ok((StatusCode::CREATED, response))
|
|
} else {
|
|
Ok((StatusCode::OK, response))
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/folders/{id}/contents",
|
|
params(("id" = Uuid, Path, description = "Folder ID"), FolderContentsQuery),
|
|
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
|
|
tag = "Folders"
|
|
)]
|
|
pub async fn list_folder_contents(
|
|
State(state): State<AppState>,
|
|
Path(folder_identifier): Path<String>,
|
|
Query(query): Query<FolderContentsQuery>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<Json<FolderContentsResponse>> {
|
|
let FolderContentsQuery {
|
|
include_documents,
|
|
sort,
|
|
dir,
|
|
} = query;
|
|
|
|
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)
|
|
.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 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)?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(Json(FolderContentsResponse {
|
|
folder,
|
|
subfolders,
|
|
documents,
|
|
}))
|
|
}
|
|
|
|
#[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(
|
|
delete,
|
|
path = "/api/folders/{id}",
|
|
params(("id" = Uuid, Path, description = "Folder ID")),
|
|
responses((status = 204, description = "Folder deleted")),
|
|
tag = "Folders"
|
|
)]
|
|
pub async fn delete_folder(
|
|
Path(folder_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: 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)?;
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
patch,
|
|
path = "/api/folders/{id}",
|
|
params(("id" = Uuid, Path, description = "Folder ID")),
|
|
request_body = UpdateFolderRequest,
|
|
responses((status = 204, description = "Folder updated")),
|
|
tag = "Folders"
|
|
)]
|
|
pub async fn update_folder(
|
|
Path(folder_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: 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)?;
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
Ok(StatusCode::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)]
|
|
#[openapi(
|
|
paths(
|
|
crate::routes::folders::create_folder,
|
|
crate::routes::folders::ensure_folder_path,
|
|
crate::routes::folders::get_folder,
|
|
crate::routes::folders::list_folder_contents,
|
|
crate::routes::folders::list_folder_tree,
|
|
crate::routes::folders::delete_folder,
|
|
crate::routes::folders::update_folder
|
|
),
|
|
components(schemas(
|
|
crate::routes::folders::CreateFolderRequest,
|
|
crate::routes::folders::EnsureFolderPathRequest,
|
|
crate::routes::folders::FolderResponse,
|
|
crate::routes::folders::FolderInfo,
|
|
crate::routes::folders::FolderContentsQuery,
|
|
crate::routes::folders::FolderContentsResponse,
|
|
crate::routes::folders::FolderTreeNode,
|
|
crate::routes::folders::UpdateFolderRequest
|
|
))
|
|
)]
|
|
pub struct FoldersApiDoc;
|