backend
This commit is contained in:
+32
-1
@@ -238,6 +238,16 @@ mod doc {
|
||||
)]
|
||||
pub(super) fn delete_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/documents/{id}/restore",
|
||||
params(("id" = Uuid, Path, description = "Document ID")),
|
||||
request_body = RestoreDocumentRequest,
|
||||
responses((status = 204, description = "Document restored")),
|
||||
tag = "Documents"
|
||||
)]
|
||||
pub(super) fn restore_document() {}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/documents/{id}/download",
|
||||
@@ -576,11 +586,26 @@ pub mod schemas {
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct DocumentListQuery {
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub include_deleted: Option<bool>,
|
||||
#[schema(nullable)]
|
||||
pub include_descendants: Option<bool>,
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
#[serde(default = "default_document_status_filter")]
|
||||
#[schema(default = "active")]
|
||||
pub status: DocumentStatusFilter,
|
||||
}
|
||||
|
||||
fn default_document_status_filter() -> DocumentStatusFilter {
|
||||
DocumentStatusFilter::Active
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DocumentStatusFilter {
|
||||
Active,
|
||||
Deleted,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -695,6 +720,12 @@ pub mod schemas {
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct RestoreDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
|
||||
@@ -186,15 +186,19 @@ pub async fn select_tenant(
|
||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
Json(payload): Json<TenantSelectionRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_tenant_selector_token(bearer.token())
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => state
|
||||
.jwt
|
||||
.verify_token(bearer.token())
|
||||
.map(|claims| claims.sub)
|
||||
.map_err(|_| AppError::unauthorized())?,
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(claims.sub))
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
||||
.inner_join(tenant_dsl::tenants)
|
||||
.select(memberships_dsl::id)
|
||||
@@ -206,7 +210,7 @@ pub async fn select_tenant(
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(claims.sub)
|
||||
.find(user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
||||
@@ -62,12 +62,24 @@ const QUICKWIT_MAX_HITS: usize = 200;
|
||||
pub struct DocumentListQuery {
|
||||
pub folder_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
pub include_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub include_descendants: Option<bool>,
|
||||
pub query: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub correspondents: Option<String>,
|
||||
#[serde(default = "default_document_status_filter")]
|
||||
pub status: DocumentStatusFilter,
|
||||
}
|
||||
|
||||
fn default_document_status_filter() -> DocumentStatusFilter {
|
||||
DocumentStatusFilter::Active
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DocumentStatusFilter {
|
||||
Active,
|
||||
Deleted,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||
@@ -380,6 +392,12 @@ pub struct MoveDocumentRequest {
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct RestoreDocumentRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AssignTagsRequest {
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
@@ -406,19 +424,25 @@ pub async fn list_documents(
|
||||
) -> AppResult<Json<Vec<DocumentResponse>>> {
|
||||
let DocumentListQuery {
|
||||
folder_id,
|
||||
include_deleted,
|
||||
include_descendants,
|
||||
query,
|
||||
tags,
|
||||
correspondents,
|
||||
status,
|
||||
} = params;
|
||||
|
||||
let mut docs_query = documents::table
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
if !include_deleted {
|
||||
docs_query = docs_query.filter(documents::deleted_at.is_null());
|
||||
match status {
|
||||
DocumentStatusFilter::Active => {
|
||||
docs_query = docs_query.filter(documents::deleted_at.is_null());
|
||||
}
|
||||
DocumentStatusFilter::Deleted => {
|
||||
docs_query = docs_query.filter(documents::deleted_at.is_not_null());
|
||||
}
|
||||
DocumentStatusFilter::All => {}
|
||||
}
|
||||
|
||||
let search_text = query
|
||||
@@ -1387,6 +1411,56 @@ pub async fn update_document(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn restore_document(
|
||||
Path(document_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<RestoreDocumentRequest>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let mut document: Document = documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?;
|
||||
|
||||
if document.deleted_at.is_none() {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
if let Some(folder_id) = payload.folder_id {
|
||||
ensure_folder_exists_on_conn(&mut conn, tenant_id, folder_id)?;
|
||||
document.folder_id = Some(folder_id);
|
||||
} else if let Some(folder_id) = document.folder_id {
|
||||
let exists: bool = diesel::select(exists(
|
||||
folders::table
|
||||
.filter(folders::id.eq(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
))
|
||||
.get_result(&mut conn)?;
|
||||
|
||||
if !exists {
|
||||
document.folder_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
diesel::update(
|
||||
documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((
|
||||
documents::deleted_at.eq::<Option<NaiveDateTime>>(None),
|
||||
documents::folder_id.eq(document.folder_id),
|
||||
documents::updated_at.eq(now),
|
||||
))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
|
||||
let mut base = match existing {
|
||||
Value::Object(map) => map,
|
||||
|
||||
@@ -85,6 +85,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||
)
|
||||
.route("/:id/folder", patch(documents::move_document))
|
||||
.route("/:id/restore", post(documents::restore_document))
|
||||
.route("/:id/tags", post(documents::assign_tags))
|
||||
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
||||
.route(
|
||||
|
||||
@@ -18,6 +18,8 @@ struct DocumentInfo {
|
||||
title: String,
|
||||
filename: String,
|
||||
original_name: String,
|
||||
#[serde(default)]
|
||||
folder_id: Option<Uuid>,
|
||||
deleted_at: Option<String>,
|
||||
issued_at: Option<String>,
|
||||
metadata: Value,
|
||||
@@ -1632,3 +1634,158 @@ async fn patch_document_updates_multiple_fields() -> Result<()> {
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_documents_by_status_filter() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "statusfilter";
|
||||
app.insert_user("statususer", password, "admin").await?;
|
||||
let token = app.login_token("statususer", password).await?;
|
||||
|
||||
let upload = app
|
||||
.upload_document(
|
||||
"/api/documents",
|
||||
"trash.txt",
|
||||
"text/plain",
|
||||
b"trash",
|
||||
None,
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
let body = body_to_vec(upload.into_body()).await?;
|
||||
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||
|
||||
let delete_resp = app
|
||||
.delete(
|
||||
&format!("/api/documents/{}", detail.document.id),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let active_resp = app.get("/api/documents", Some(&token)).await?;
|
||||
assert!(active_resp.status().is_success());
|
||||
let active_body = body_to_vec(active_resp.into_body()).await?;
|
||||
let active_docs: Vec<DocumentListItem> = serde_json::from_slice(&active_body)?;
|
||||
assert!(active_docs.iter().all(|doc| doc.id != detail.document.id));
|
||||
|
||||
let deleted_resp = app
|
||||
.get("/api/documents?status=deleted", Some(&token))
|
||||
.await?;
|
||||
assert!(deleted_resp.status().is_success());
|
||||
let deleted_body = body_to_vec(deleted_resp.into_body()).await?;
|
||||
let deleted_docs: Vec<DocumentListItem> = serde_json::from_slice(&deleted_body)?;
|
||||
assert!(deleted_docs.iter().any(|doc| doc.id == detail.document.id));
|
||||
|
||||
let all_resp = app.get("/api/documents?status=all", Some(&token)).await?;
|
||||
assert!(all_resp.status().is_success());
|
||||
let all_body = body_to_vec(all_resp.into_body()).await?;
|
||||
let all_docs: Vec<DocumentListItem> = serde_json::from_slice(&all_body)?;
|
||||
assert!(all_docs.iter().any(|doc| doc.id == detail.document.id));
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_document_to_original_and_custom_folder() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "restoretest";
|
||||
app.insert_user("restorer", password, "admin").await?;
|
||||
let token = app.login_token("restorer", password).await?;
|
||||
|
||||
let upload = app
|
||||
.upload_document(
|
||||
"/api/documents",
|
||||
"to-restore.txt",
|
||||
"text/plain",
|
||||
b"restore",
|
||||
None,
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
let body = body_to_vec(upload.into_body()).await?;
|
||||
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||
|
||||
let delete_resp = app
|
||||
.delete(
|
||||
&format!("/api/documents/{}", detail.document.id),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let restore_resp = app
|
||||
.post_json(
|
||||
&format!("/api/documents/{}/restore", detail.document.id),
|
||||
&serde_json::json!({}),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(restore_resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let fetched = app
|
||||
.get(
|
||||
&format!("/api/documents/{}", detail.document.id),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert!(fetched.status().is_success());
|
||||
let fetched_body = body_to_vec(fetched.into_body()).await?;
|
||||
let fetched_detail: DocumentDetail = serde_json::from_slice(&fetched_body)?;
|
||||
assert!(fetched_detail.document.deleted_at.is_none());
|
||||
|
||||
let folder_resp = app
|
||||
.post_json(
|
||||
"/api/folders",
|
||||
&CreateFolderRequest {
|
||||
name: "Restored",
|
||||
parent_id: None,
|
||||
},
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert!(folder_resp.status().is_success());
|
||||
let folder_body = body_to_vec(folder_resp.into_body()).await?;
|
||||
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
|
||||
|
||||
let delete_again = app
|
||||
.delete(
|
||||
&format!("/api/documents/{}", detail.document.id),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(delete_again.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let restore_custom = app
|
||||
.post_json(
|
||||
&format!("/api/documents/{}/restore", detail.document.id),
|
||||
&serde_json::json!({
|
||||
"folder_id": folder.folder.id
|
||||
}),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(restore_custom.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let fetched_custom = app
|
||||
.get(
|
||||
&format!("/api/documents/{}", detail.document.id),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
let fetched_custom_body = body_to_vec(fetched_custom.into_body()).await?;
|
||||
let fetched_custom_detail: DocumentDetail = serde_json::from_slice(&fetched_custom_body)?;
|
||||
assert_eq!(
|
||||
fetched_custom_detail.document.folder_id,
|
||||
Some(folder.folder.id)
|
||||
);
|
||||
assert!(fetched_custom_detail.document.deleted_at.is_none());
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user