diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index d3fac6e..1b95df7 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -793,6 +793,8 @@ pub mod schemas { pub folder_id: Option, #[schema(nullable)] pub metadata: Option, + #[schema(nullable)] + pub title: Option, } #[derive(Serialize, Deserialize, ToSchema)] diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 98374c8..2ca83cc 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -6,7 +6,7 @@ use std::{ use axum::extract::{Json, Multipart, Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; -use chrono::{NaiveDateTime, Utc}; +use chrono::{DateTime, NaiveDateTime, Utc}; use diesel::dsl::exists; use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection}; use reqwest::Client; @@ -293,6 +293,10 @@ struct UploadRequest { content_type: Option, folder_id: Option, metadata: Value, + title_override: Option, + tag_ids: Vec, + correspondents: Vec, + issued_at_override: Option, } struct UploadOutcome { @@ -636,6 +640,10 @@ pub async fn upload_document( let mut content_type: Option = None; let mut folder_id: Option = None; let mut metadata: Value = Value::Object(Default::default()); + let mut tag_ids: Vec = Vec::new(); + let mut correspondents: Vec = Vec::new(); + let mut issued_at_override: Option = None; + let mut title_override: Option = None; while let Some(field) = multipart.next_field().await.map_err(|err| { let msg = format!("invalid multipart data: {err}"); @@ -679,6 +687,70 @@ pub async fn upload_document( AppError::bad_request(msg) })?; } + Some("title") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid title: {err}"); + error!(error = %err, "invalid title payload"); + AppError::bad_request(msg) + })?; + let trimmed = value.trim(); + if !trimmed.is_empty() { + title_override = Some(trimmed.to_string()); + } + } + Some("tag_ids") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid tag_ids: {err}"); + error!(error = %err, "invalid tag_ids payload"); + AppError::bad_request(msg) + })?; + let parsed: Vec = serde_json::from_str(&value).map_err(|err| { + let msg = format!("tag_ids must be a JSON array of UUID strings: {err}"); + error!(error = %err, "invalid tag_ids json"); + AppError::bad_request(msg) + })?; + let mut set = HashSet::new(); + for raw in parsed { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let uuid = Uuid::parse_str(trimmed) + .map_err(|_| AppError::bad_request("tag_ids must contain valid UUIDs"))?; + set.insert(uuid); + } + tag_ids = set.into_iter().collect(); + } + Some("correspondents") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid correspondents: {err}"); + error!(error = %err, "invalid correspondents payload"); + AppError::bad_request(msg) + })?; + correspondents = serde_json::from_str(&value).map_err(|err| { + let msg = format!( + "correspondents must be a JSON array of {{correspondent_id, role}} objects: {err}" + ); + error!(error = %err, "invalid correspondents json"); + AppError::bad_request(msg) + })?; + } + Some("issued_at") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid issued_at: {err}"); + error!(error = %err, "invalid issued_at payload"); + AppError::bad_request(msg) + })?; + let trimmed = value.trim(); + if !trimmed.is_empty() { + let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| { + let msg = format!("issued_at must be an RFC3339 timestamp: {err}"); + error!(error = %err, "invalid issued_at format"); + AppError::bad_request(msg) + })?; + issued_at_override = Some(parsed.naive_utc()); + } + } _ => {} } } @@ -704,6 +776,10 @@ pub async fn upload_document( content_type, folder_id, metadata, + title_override, + tag_ids, + correspondents, + issued_at_override, }; let outcome = match process_upload(&state, request, tenant_id, user_id).await { @@ -1212,10 +1288,9 @@ pub async fn assign_correspondents( return Err(AppError::bad_request("assignments must not be empty")); } - let (normalized_pairs, correspondents_vec, roles_vec) = + let (normalized_pairs, _correspondent_ids, roles_vec) = normalize_correspondent_assignments(&payload.assignments)?; let replace = payload.replace; - let user_id_val = user_id; conn.transaction::<(), AppError, _>(|conn| { let document: Document = documents::table @@ -1226,54 +1301,21 @@ pub async fn assign_correspondents( return Err(AppError::not_found()); } - if !correspondents_vec.is_empty() { - let existing: Vec = correspondents::table - .filter(correspondents::id.eq_any(&correspondents_vec)) - .filter(correspondents::tenant_id.eq(tenant_id)) - .load(conn)?; - if existing.len() != correspondents_vec.len() { - return Err(AppError::bad_request( - "one or more correspondents do not exist", - )); - } - } - - let mut changed = false; + let mut deleted = 0; if replace { - let deleted = diesel::delete( + deleted = diesel::delete( document_correspondents::table .filter(document_correspondents::document_id.eq(document_id)) .filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::role.eq_any(&roles_vec)), ) .execute(conn)?; - if deleted > 0 { - changed = true; - } } - let new_rows: Vec = normalized_pairs - .iter() - .map(|(correspondent_id, role)| NewDocumentCorrespondent { - document_id, - correspondent_id: *correspondent_id, - role: role.clone(), - assigned_by: Some(user_id_val), - tenant_id, - }) - .collect(); + let inserted = + insert_document_correspondents(conn, tenant_id, &document, user_id, &normalized_pairs)?; - if !new_rows.is_empty() { - let inserted = diesel::insert_into(document_correspondents::table) - .values(&new_rows) - .on_conflict_do_nothing() - .execute(conn)?; - if inserted > 0 { - changed = true; - } - } - - if changed { + if replace && deleted > 0 && inserted == 0 { diesel::update( documents::table .find(document_id) @@ -1513,36 +1555,18 @@ pub async fn assign_tags( return Err(AppError::bad_request("tag_ids must not be empty")); } - // Ensure document exists - documents::table + let document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) - .first::(&mut conn)?; + .first(&mut conn)?; - // Ensure tags exist - let existing_tags: Vec = tags::table - .filter(tags::id.eq_any(&payload.tag_ids)) - .filter(tags::tenant_id.eq(tenant_id)) - .load(&mut conn)?; - if existing_tags.len() != payload.tag_ids.len() { - return Err(AppError::bad_request("one or more tags do not exist")); - } - - let new_tags: Vec = payload - .tag_ids - .iter() - .map(|tag_id| NewDocumentTag { - document_id, - tag_id: *tag_id, - assigned_by: Some(user_id), - tenant_id, - }) - .collect(); - - diesel::insert_into(document_tags::table) - .values(&new_tags) - .on_conflict_do_nothing() - .execute(&mut conn)?; + assign_tags_internal( + &mut conn, + tenant_id, + &document, + &payload.tag_ids, + Some(user_id), + )?; Ok(StatusCode::NO_CONTENT) } @@ -1663,6 +1687,10 @@ async fn process_upload( content_type, folder_id, metadata, + title_override, + tag_ids, + correspondents, + issued_at_override, } = request; if let Some(folder) = folder_id { @@ -1672,7 +1700,13 @@ async fn process_upload( let doc_id = Uuid::new_v4(); let version_id = Uuid::new_v4(); let version_number = 1; - let stored_filename = original_name.clone(); + let derived_title = title_override + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .unwrap_or_else(|| derive_document_title(&original_name)); + let stored_filename = filename_with_retained_extension(&derived_title, &original_name); let checksum = Sha256::digest(&bytes); let checksum_hex = hex::encode(checksum); @@ -1694,6 +1728,32 @@ async fn process_upload( .optional()?; if let Some((mut document, version)) = existing { + if let Some(issued_at) = issued_at_override { + if document.issued_at != Some(issued_at) { + diesel::update( + documents::table + .find(document.id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::issued_at.eq(Some(issued_at)), + documents::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(&mut conn)?; + document.issued_at = Some(issued_at); + } + } + + assign_tags_internal(&mut conn, tenant_id, &document, &tag_ids, Some(user_id))?; + + assign_correspondents_internal( + &mut conn, + tenant_id, + &document, + user_id, + &correspondents, + )?; + if document.deleted_at.is_some() { let now = Utc::now().naive_utc(); diesel::update(documents::table.find(document.id)) @@ -1737,7 +1797,7 @@ async fn process_upload( } } - let content_disposition = inline_content_disposition(&original_name); + let content_disposition = inline_content_disposition(&stored_filename); let storage = state.storage_for_tenant(tenant_id)?; @@ -1770,8 +1830,8 @@ async fn process_upload( content_type: content_type.clone(), folder_id, current_version_id: version_id, - issued_at: None, - title: derive_document_title(&original_name), + issued_at: issued_at_override, + title: derived_title.clone(), metadata: metadata_value.clone(), tenant_id, }; @@ -1802,15 +1862,29 @@ async fn process_upload( })? }; - let detail = DocumentDetailResponse { - document: to_document_response( - state, - user_id, - document, - None, - Vec::new(), - Some((to_version_response(version.clone(), true), Vec::new())), - )?, + let detail = { + let mut conn = state.db_for_tenant(tenant_id)?; + + assign_tags_internal(&mut conn, tenant_id, &document, &tag_ids, Some(user_id))?; + + assign_correspondents_internal(&mut conn, tenant_id, &document, user_id, &correspondents)?; + + let tags_map = load_tags_for_documents(&mut conn, &[doc_id])?; + let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[doc_id])?; + let tags = tags_map.get(&doc_id).cloned(); + let correspondents = correspondents_map.remove(&doc_id).unwrap_or_default(); + drop(conn); + + DocumentDetailResponse { + document: to_document_response( + state, + user_id, + document, + tags, + correspondents, + Some((to_version_response(version.clone(), true), Vec::new())), + )?, + } }; if let Ok(mut conn) = state.db_for_tenant(tenant_id) { @@ -1837,6 +1911,136 @@ async fn process_upload( }) } +fn assign_tags_internal( + conn: &mut PgConnection, + tenant_id: Uuid, + document: &Document, + raw_tag_ids: &[Uuid], + assigned_by: Option, +) -> AppResult { + if raw_tag_ids.is_empty() { + return Ok(0); + } + + let mut tag_ids: Vec = raw_tag_ids.iter().copied().collect(); + tag_ids.sort_unstable(); + tag_ids.dedup(); + + if tag_ids.is_empty() { + return Ok(0); + } + + let existing: Vec = tags::table + .filter(tags::id.eq_any(&tag_ids)) + .filter(tags::tenant_id.eq(tenant_id)) + .select(tags::id) + .load(conn)?; + + if existing.len() != tag_ids.len() { + return Err(AppError::bad_request("one or more tags do not exist")); + } + + let new_tags: Vec = tag_ids + .into_iter() + .map(|tag_id| NewDocumentTag { + document_id: document.id, + tag_id, + assigned_by, + tenant_id, + }) + .collect(); + + if new_tags.is_empty() { + return Ok(0); + } + + let inserted = diesel::insert_into(document_tags::table) + .values(&new_tags) + .on_conflict_do_nothing() + .execute(conn)?; + + Ok(inserted) +} + +fn assign_correspondents_internal( + conn: &mut PgConnection, + tenant_id: Uuid, + document: &Document, + user_id: Uuid, + assignments: &[CorrespondentAssignmentInput], +) -> AppResult { + if assignments.is_empty() { + return Ok(0); + } + + let (normalized_pairs, _correspondent_ids, _roles) = + normalize_correspondent_assignments(assignments)?; + + insert_document_correspondents(conn, tenant_id, document, user_id, &normalized_pairs) +} + +fn insert_document_correspondents( + conn: &mut PgConnection, + tenant_id: Uuid, + document: &Document, + user_id: Uuid, + normalized_pairs: &[(Uuid, String)], +) -> AppResult { + if normalized_pairs.is_empty() { + return Ok(0); + } + + let mut correspondent_ids: Vec = normalized_pairs.iter().map(|(id, _)| *id).collect(); + correspondent_ids.sort_unstable(); + correspondent_ids.dedup(); + + if !correspondent_ids.is_empty() { + let existing: Vec = correspondents::table + .filter(correspondents::id.eq_any(&correspondent_ids)) + .filter(correspondents::tenant_id.eq(tenant_id)) + .select(correspondents::id) + .load(conn)?; + + if existing.len() != correspondent_ids.len() { + return Err(AppError::bad_request( + "one or more correspondents do not exist", + )); + } + } + + let new_rows: Vec = normalized_pairs + .iter() + .map(|(correspondent_id, role)| NewDocumentCorrespondent { + document_id: document.id, + correspondent_id: *correspondent_id, + role: role.clone(), + assigned_by: Some(user_id), + tenant_id, + }) + .collect(); + + if new_rows.is_empty() { + return Ok(0); + } + + let inserted = diesel::insert_into(document_correspondents::table) + .values(&new_rows) + .on_conflict_do_nothing() + .execute(conn)?; + + if inserted > 0 { + diesel::update( + documents::table + .find(document.id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(inserted) +} + fn ensure_folder_exists(state: &AppState, tenant_id: Uuid, folder_id: Uuid) -> AppResult<()> { let mut conn = state.db_for_tenant(tenant_id)?; let exists: bool = diesel::select(exists( diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 0be3e8a..34f3d94 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -478,6 +478,30 @@ impl TestApp { data: &[u8], folder_id: Option, token: &str, + ) -> Result> { + self.upload_document_with_options( + path, + filename, + content_type, + data, + folder_id, + None, + None, + token, + ) + .await + } + + pub async fn upload_document_with_options( + &self, + path: &str, + filename: &str, + content_type: &str, + data: &[u8], + folder_id: Option, + title: Option<&str>, + metadata_json: Option<&str>, + token: &str, ) -> Result> { let boundary = format!("boundary-{}", Uuid::new_v4()); let mut body = Vec::new(); @@ -500,6 +524,20 @@ impl TestApp { body.extend(b"\r\n"); } + if let Some(title_value) = title { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n"); + body.extend(title_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(metadata_value) = metadata_json { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n"); + body.extend(metadata_value.as_bytes()); + body.extend(b"\r\n"); + } + body.extend(format!("--{boundary}--\r\n").as_bytes()); let builder = Request::builder() diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index e401793..7460fe6 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -15,6 +15,7 @@ struct DocumentDetail { struct DocumentInfo { id: Uuid, title: String, + filename: String, original_name: String, deleted_at: Option, issued_at: Option, @@ -240,6 +241,43 @@ async fn upload_and_list_document() -> Result<()> { Ok(()) } +#[tokio::test] +async fn upload_document_with_custom_title_sets_filename() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "passw0rd"; + app.insert_user("nora", password, "admin").await?; + let token = app.login_token("nora", password).await?; + + let file_bytes = b"example contract body".to_vec(); + let title = "Vendor Contract"; + let original_filename = "scan.pdf"; + + let upload = app + .upload_document_with_options( + "/api/documents", + original_filename, + "application/pdf", + &file_bytes, + None, + Some(title), + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::CREATED); + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + assert_eq!(detail.document.title, title); + assert_eq!(detail.document.filename, format!("{title}.pdf")); + assert_eq!(detail.document.original_name, original_filename); + + app.cleanup().await?; + Ok(()) +} + #[tokio::test] async fn duplicate_and_restore_document() -> Result<()> { let _lock = acquire_db_lock().await; diff --git a/docs/api.txt b/docs/api.txt index 9dea7ae..cf7e490 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -17,7 +17,7 @@ Health Documents --------- - GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info. -- POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields). +- POST /api/documents - Upload a document via multipart form-data (`file`, optional `title`, `folder_id`, and JSON `metadata`). When `title` is supplied, the stored filename is set to `<original_extension>` automatically. - POST /api/documents/bulk/move - Move multiple documents to a target folder. - POST /api/documents/bulk/tags - Add or remove tags across multiple documents. - POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.