backend
This commit is contained in:
+32
-1
@@ -238,6 +238,16 @@ mod doc {
|
|||||||
)]
|
)]
|
||||||
pub(super) fn delete_document() {}
|
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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/documents/{id}/download",
|
path = "/api/documents/{id}/download",
|
||||||
@@ -576,11 +586,26 @@ pub mod schemas {
|
|||||||
#[into_params(parameter_in = Query)]
|
#[into_params(parameter_in = Query)]
|
||||||
pub struct DocumentListQuery {
|
pub struct DocumentListQuery {
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub include_deleted: Option<bool>,
|
#[schema(nullable)]
|
||||||
pub include_descendants: Option<bool>,
|
pub include_descendants: Option<bool>,
|
||||||
pub query: Option<String>,
|
pub query: Option<String>,
|
||||||
pub tags: Option<String>,
|
pub tags: Option<String>,
|
||||||
pub correspondents: 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)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -695,6 +720,12 @@ pub mod schemas {
|
|||||||
pub replace: bool,
|
pub replace: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct RestoreDocumentRequest {
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub folder_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateDocumentRequest {
|
pub struct UpdateDocumentRequest {
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
|
|||||||
@@ -186,15 +186,19 @@ pub async fn select_tenant(
|
|||||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||||
Json(payload): Json<TenantSelectionRequest>,
|
Json(payload): Json<TenantSelectionRequest>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let claims = state
|
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
||||||
|
Ok(claims) => claims.sub,
|
||||||
|
Err(_) => state
|
||||||
.jwt
|
.jwt
|
||||||
.verify_tenant_selector_token(bearer.token())
|
.verify_token(bearer.token())
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
.map(|claims| claims.sub)
|
||||||
|
.map_err(|_| AppError::unauthorized())?,
|
||||||
|
};
|
||||||
|
|
||||||
let mut conn = state.db_unscoped()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
let membership_exists = memberships_dsl::user_memberships
|
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))
|
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
||||||
.inner_join(tenant_dsl::tenants)
|
.inner_join(tenant_dsl::tenants)
|
||||||
.select(memberships_dsl::id)
|
.select(memberships_dsl::id)
|
||||||
@@ -206,7 +210,7 @@ pub async fn select_tenant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let user: User = dsl::users
|
let user: User = dsl::users
|
||||||
.find(claims.sub)
|
.find(user_id)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
|||||||
@@ -62,12 +62,24 @@ const QUICKWIT_MAX_HITS: usize = 200;
|
|||||||
pub struct DocumentListQuery {
|
pub struct DocumentListQuery {
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub include_deleted: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub include_descendants: Option<bool>,
|
pub include_descendants: Option<bool>,
|
||||||
pub query: Option<String>,
|
pub query: Option<String>,
|
||||||
pub tags: Option<String>,
|
pub tags: Option<String>,
|
||||||
pub correspondents: 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)]
|
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||||
@@ -380,6 +392,12 @@ pub struct MoveDocumentRequest {
|
|||||||
pub folder_id: Option<Uuid>,
|
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)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct AssignTagsRequest {
|
pub struct AssignTagsRequest {
|
||||||
pub tag_ids: Vec<Uuid>,
|
pub tag_ids: Vec<Uuid>,
|
||||||
@@ -406,20 +424,26 @@ pub async fn list_documents(
|
|||||||
) -> AppResult<Json<Vec<DocumentResponse>>> {
|
) -> AppResult<Json<Vec<DocumentResponse>>> {
|
||||||
let DocumentListQuery {
|
let DocumentListQuery {
|
||||||
folder_id,
|
folder_id,
|
||||||
include_deleted,
|
|
||||||
include_descendants,
|
include_descendants,
|
||||||
query,
|
query,
|
||||||
tags,
|
tags,
|
||||||
correspondents,
|
correspondents,
|
||||||
|
status,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
let mut docs_query = documents::table
|
let mut docs_query = documents::table
|
||||||
.filter(documents::tenant_id.eq(tenant_id))
|
.filter(documents::tenant_id.eq(tenant_id))
|
||||||
.into_boxed();
|
.into_boxed();
|
||||||
|
|
||||||
if !include_deleted {
|
match status {
|
||||||
|
DocumentStatusFilter::Active => {
|
||||||
docs_query = docs_query.filter(documents::deleted_at.is_null());
|
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
|
let search_text = query
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -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> {
|
fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
|
||||||
let mut base = match existing {
|
let mut base = match existing {
|
||||||
Value::Object(map) => map,
|
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),
|
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||||
)
|
)
|
||||||
.route("/:id/folder", patch(documents::move_document))
|
.route("/:id/folder", patch(documents::move_document))
|
||||||
|
.route("/:id/restore", post(documents::restore_document))
|
||||||
.route("/:id/tags", post(documents::assign_tags))
|
.route("/:id/tags", post(documents::assign_tags))
|
||||||
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ struct DocumentInfo {
|
|||||||
title: String,
|
title: String,
|
||||||
filename: String,
|
filename: String,
|
||||||
original_name: String,
|
original_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
folder_id: Option<Uuid>,
|
||||||
deleted_at: Option<String>,
|
deleted_at: Option<String>,
|
||||||
issued_at: Option<String>,
|
issued_at: Option<String>,
|
||||||
metadata: Value,
|
metadata: Value,
|
||||||
@@ -1632,3 +1634,158 @@ async fn patch_document_updates_multiple_fields() -> Result<()> {
|
|||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
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