This commit is contained in:
2025-10-27 22:25:43 +01:00
parent 50125f4659
commit 2d80515ad5
5 changed files with 279 additions and 12 deletions
+32 -1
View File
@@ -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)]
+10 -6
View File
@@ -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)?;
+79 -5
View File
@@ -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,
+1
View File
@@ -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(
+157
View File
@@ -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(())
}