diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index f71434a..38b7bf0 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -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, - pub include_deleted: Option, + #[schema(nullable)] pub include_descendants: Option, pub query: Option, pub tags: Option, pub correspondents: Option, + #[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, + } + #[derive(Serialize, Deserialize, ToSchema)] pub struct UpdateDocumentRequest { #[schema(nullable)] diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index cd0166e..1f04662 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -186,15 +186,19 @@ pub async fn select_tenant( TypedHeader(Authorization(bearer)): TypedHeader>, Json(payload): Json, ) -> AppResult { - 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)?; diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 82ef4d8..b28fbe4 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -62,12 +62,24 @@ const QUICKWIT_MAX_HITS: usize = 200; pub struct DocumentListQuery { pub folder_id: Option, #[serde(default)] - pub include_deleted: bool, - #[serde(default)] pub include_descendants: Option, pub query: Option, pub tags: Option, pub correspondents: Option, + #[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, } +#[derive(Deserialize, ToSchema)] +pub struct RestoreDocumentRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_id: Option, +} + #[derive(Deserialize, ToSchema)] pub struct AssignTagsRequest { pub tag_ids: Vec, @@ -406,19 +424,25 @@ pub async fn list_documents( ) -> AppResult>> { 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, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + 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::>(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 { let mut base = match existing { Value::Object(map) => map, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 8f2947f..470371e 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -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( diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index 4b530da..81f5c33 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -18,6 +18,8 @@ struct DocumentInfo { title: String, filename: String, original_name: String, + #[serde(default)] + folder_id: Option, deleted_at: Option, issued_at: Option, 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 = 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 = 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 = 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(()) +}