upload with more data
This commit is contained in:
@@ -793,6 +793,8 @@ pub mod schemas {
|
|||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub title: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
+278
-74
@@ -6,7 +6,7 @@ use std::{
|
|||||||
use axum::extract::{Json, Multipart, Path, Query, State};
|
use axum::extract::{Json, Multipart, Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
use diesel::dsl::exists;
|
use diesel::dsl::exists;
|
||||||
use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection};
|
use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
@@ -293,6 +293,10 @@ struct UploadRequest {
|
|||||||
content_type: Option<String>,
|
content_type: Option<String>,
|
||||||
folder_id: Option<Uuid>,
|
folder_id: Option<Uuid>,
|
||||||
metadata: Value,
|
metadata: Value,
|
||||||
|
title_override: Option<String>,
|
||||||
|
tag_ids: Vec<Uuid>,
|
||||||
|
correspondents: Vec<CorrespondentAssignmentInput>,
|
||||||
|
issued_at_override: Option<NaiveDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UploadOutcome {
|
struct UploadOutcome {
|
||||||
@@ -636,6 +640,10 @@ pub async fn upload_document(
|
|||||||
let mut content_type: Option<String> = None;
|
let mut content_type: Option<String> = None;
|
||||||
let mut folder_id: Option<Uuid> = None;
|
let mut folder_id: Option<Uuid> = None;
|
||||||
let mut metadata: Value = Value::Object(Default::default());
|
let mut metadata: Value = Value::Object(Default::default());
|
||||||
|
let mut tag_ids: Vec<Uuid> = Vec::new();
|
||||||
|
let mut correspondents: Vec<CorrespondentAssignmentInput> = Vec::new();
|
||||||
|
let mut issued_at_override: Option<NaiveDateTime> = None;
|
||||||
|
let mut title_override: Option<String> = None;
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await.map_err(|err| {
|
while let Some(field) = multipart.next_field().await.map_err(|err| {
|
||||||
let msg = format!("invalid multipart data: {err}");
|
let msg = format!("invalid multipart data: {err}");
|
||||||
@@ -679,6 +687,70 @@ pub async fn upload_document(
|
|||||||
AppError::bad_request(msg)
|
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<String> = 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,
|
content_type,
|
||||||
folder_id,
|
folder_id,
|
||||||
metadata,
|
metadata,
|
||||||
|
title_override,
|
||||||
|
tag_ids,
|
||||||
|
correspondents,
|
||||||
|
issued_at_override,
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome = match process_upload(&state, request, tenant_id, user_id).await {
|
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"));
|
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)?;
|
normalize_correspondent_assignments(&payload.assignments)?;
|
||||||
let replace = payload.replace;
|
let replace = payload.replace;
|
||||||
let user_id_val = user_id;
|
|
||||||
|
|
||||||
conn.transaction::<(), AppError, _>(|conn| {
|
conn.transaction::<(), AppError, _>(|conn| {
|
||||||
let document: Document = documents::table
|
let document: Document = documents::table
|
||||||
@@ -1226,54 +1301,21 @@ pub async fn assign_correspondents(
|
|||||||
return Err(AppError::not_found());
|
return Err(AppError::not_found());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !correspondents_vec.is_empty() {
|
let mut deleted = 0;
|
||||||
let existing: Vec<Correspondent> = 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;
|
|
||||||
if replace {
|
if replace {
|
||||||
let deleted = diesel::delete(
|
deleted = diesel::delete(
|
||||||
document_correspondents::table
|
document_correspondents::table
|
||||||
.filter(document_correspondents::document_id.eq(document_id))
|
.filter(document_correspondents::document_id.eq(document_id))
|
||||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
.filter(document_correspondents::role.eq_any(&roles_vec)),
|
.filter(document_correspondents::role.eq_any(&roles_vec)),
|
||||||
)
|
)
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
if deleted > 0 {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_rows: Vec<NewDocumentCorrespondent> = normalized_pairs
|
let inserted =
|
||||||
.iter()
|
insert_document_correspondents(conn, tenant_id, &document, user_id, &normalized_pairs)?;
|
||||||
.map(|(correspondent_id, role)| NewDocumentCorrespondent {
|
|
||||||
document_id,
|
|
||||||
correspondent_id: *correspondent_id,
|
|
||||||
role: role.clone(),
|
|
||||||
assigned_by: Some(user_id_val),
|
|
||||||
tenant_id,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !new_rows.is_empty() {
|
if replace && deleted > 0 && inserted == 0 {
|
||||||
let inserted = diesel::insert_into(document_correspondents::table)
|
|
||||||
.values(&new_rows)
|
|
||||||
.on_conflict_do_nothing()
|
|
||||||
.execute(conn)?;
|
|
||||||
if inserted > 0 {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed {
|
|
||||||
diesel::update(
|
diesel::update(
|
||||||
documents::table
|
documents::table
|
||||||
.find(document_id)
|
.find(document_id)
|
||||||
@@ -1513,36 +1555,18 @@ pub async fn assign_tags(
|
|||||||
return Err(AppError::bad_request("tag_ids must not be empty"));
|
return Err(AppError::bad_request("tag_ids must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure document exists
|
let document: Document = documents::table
|
||||||
documents::table
|
|
||||||
.find(document_id)
|
.find(document_id)
|
||||||
.filter(documents::tenant_id.eq(tenant_id))
|
.filter(documents::tenant_id.eq(tenant_id))
|
||||||
.first::<Document>(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
// Ensure tags exist
|
assign_tags_internal(
|
||||||
let existing_tags: Vec<Tag> = tags::table
|
&mut conn,
|
||||||
.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<NewDocumentTag> = payload
|
|
||||||
.tag_ids
|
|
||||||
.iter()
|
|
||||||
.map(|tag_id| NewDocumentTag {
|
|
||||||
document_id,
|
|
||||||
tag_id: *tag_id,
|
|
||||||
assigned_by: Some(user_id),
|
|
||||||
tenant_id,
|
tenant_id,
|
||||||
})
|
&document,
|
||||||
.collect();
|
&payload.tag_ids,
|
||||||
|
Some(user_id),
|
||||||
diesel::insert_into(document_tags::table)
|
)?;
|
||||||
.values(&new_tags)
|
|
||||||
.on_conflict_do_nothing()
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -1663,6 +1687,10 @@ async fn process_upload(
|
|||||||
content_type,
|
content_type,
|
||||||
folder_id,
|
folder_id,
|
||||||
metadata,
|
metadata,
|
||||||
|
title_override,
|
||||||
|
tag_ids,
|
||||||
|
correspondents,
|
||||||
|
issued_at_override,
|
||||||
} = request;
|
} = request;
|
||||||
|
|
||||||
if let Some(folder) = folder_id {
|
if let Some(folder) = folder_id {
|
||||||
@@ -1672,7 +1700,13 @@ async fn process_upload(
|
|||||||
let doc_id = Uuid::new_v4();
|
let doc_id = Uuid::new_v4();
|
||||||
let version_id = Uuid::new_v4();
|
let version_id = Uuid::new_v4();
|
||||||
let version_number = 1;
|
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 = Sha256::digest(&bytes);
|
||||||
let checksum_hex = hex::encode(checksum);
|
let checksum_hex = hex::encode(checksum);
|
||||||
@@ -1694,6 +1728,32 @@ async fn process_upload(
|
|||||||
.optional()?;
|
.optional()?;
|
||||||
|
|
||||||
if let Some((mut document, version)) = existing {
|
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() {
|
if document.deleted_at.is_some() {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
diesel::update(documents::table.find(document.id))
|
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)?;
|
let storage = state.storage_for_tenant(tenant_id)?;
|
||||||
|
|
||||||
@@ -1770,8 +1830,8 @@ async fn process_upload(
|
|||||||
content_type: content_type.clone(),
|
content_type: content_type.clone(),
|
||||||
folder_id,
|
folder_id,
|
||||||
current_version_id: version_id,
|
current_version_id: version_id,
|
||||||
issued_at: None,
|
issued_at: issued_at_override,
|
||||||
title: derive_document_title(&original_name),
|
title: derived_title.clone(),
|
||||||
metadata: metadata_value.clone(),
|
metadata: metadata_value.clone(),
|
||||||
tenant_id,
|
tenant_id,
|
||||||
};
|
};
|
||||||
@@ -1802,15 +1862,29 @@ async fn process_upload(
|
|||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
|
|
||||||
let detail = DocumentDetailResponse {
|
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(
|
document: to_document_response(
|
||||||
state,
|
state,
|
||||||
user_id,
|
user_id,
|
||||||
document,
|
document,
|
||||||
None,
|
tags,
|
||||||
Vec::new(),
|
correspondents,
|
||||||
Some((to_version_response(version.clone(), true), Vec::new())),
|
Some((to_version_response(version.clone(), true), Vec::new())),
|
||||||
)?,
|
)?,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(mut conn) = state.db_for_tenant(tenant_id) {
|
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<Uuid>,
|
||||||
|
) -> AppResult<usize> {
|
||||||
|
if raw_tag_ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tag_ids: Vec<Uuid> = raw_tag_ids.iter().copied().collect();
|
||||||
|
tag_ids.sort_unstable();
|
||||||
|
tag_ids.dedup();
|
||||||
|
|
||||||
|
if tag_ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: Vec<Uuid> = 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<NewDocumentTag> = 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<usize> {
|
||||||
|
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<usize> {
|
||||||
|
if normalized_pairs.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut correspondent_ids: Vec<Uuid> = normalized_pairs.iter().map(|(id, _)| *id).collect();
|
||||||
|
correspondent_ids.sort_unstable();
|
||||||
|
correspondent_ids.dedup();
|
||||||
|
|
||||||
|
if !correspondent_ids.is_empty() {
|
||||||
|
let existing: Vec<Uuid> = 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<NewDocumentCorrespondent> = 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<()> {
|
fn ensure_folder_exists(state: &AppState, tenant_id: Uuid, folder_id: Uuid) -> AppResult<()> {
|
||||||
let mut conn = state.db_for_tenant(tenant_id)?;
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
let exists: bool = diesel::select(exists(
|
let exists: bool = diesel::select(exists(
|
||||||
|
|||||||
@@ -478,6 +478,30 @@ impl TestApp {
|
|||||||
data: &[u8],
|
data: &[u8],
|
||||||
folder_id: Option<Uuid>,
|
folder_id: Option<Uuid>,
|
||||||
token: &str,
|
token: &str,
|
||||||
|
) -> Result<hyper::Response<Body>> {
|
||||||
|
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<Uuid>,
|
||||||
|
title: Option<&str>,
|
||||||
|
metadata_json: Option<&str>,
|
||||||
|
token: &str,
|
||||||
) -> Result<hyper::Response<Body>> {
|
) -> Result<hyper::Response<Body>> {
|
||||||
let boundary = format!("boundary-{}", Uuid::new_v4());
|
let boundary = format!("boundary-{}", Uuid::new_v4());
|
||||||
let mut body = Vec::new();
|
let mut body = Vec::new();
|
||||||
@@ -500,6 +524,20 @@ impl TestApp {
|
|||||||
body.extend(b"\r\n");
|
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());
|
body.extend(format!("--{boundary}--\r\n").as_bytes());
|
||||||
|
|
||||||
let builder = Request::builder()
|
let builder = Request::builder()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct DocumentDetail {
|
|||||||
struct DocumentInfo {
|
struct DocumentInfo {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
title: String,
|
title: String,
|
||||||
|
filename: String,
|
||||||
original_name: String,
|
original_name: String,
|
||||||
deleted_at: Option<String>,
|
deleted_at: Option<String>,
|
||||||
issued_at: Option<String>,
|
issued_at: Option<String>,
|
||||||
@@ -240,6 +241,43 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn duplicate_and_restore_document() -> Result<()> {
|
async fn duplicate_and_restore_document() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ Health
|
|||||||
Documents
|
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.
|
- 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 `<title><original_extension>` automatically.
|
||||||
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
- 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/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.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user