upload with more data

This commit is contained in:
2025-10-26 02:28:02 +02:00
parent 62cadbcfa0
commit e51a59a829
5 changed files with 364 additions and 82 deletions
+2
View File
@@ -793,6 +793,8 @@ pub mod schemas {
pub folder_id: Option<Uuid>,
#[schema(nullable)]
pub metadata: Option<Value>,
#[schema(nullable)]
pub title: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
+285 -81
View File
@@ -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<String>,
folder_id: Option<Uuid>,
metadata: Value,
title_override: Option<String>,
tag_ids: Vec<Uuid>,
correspondents: Vec<CorrespondentAssignmentInput>,
issued_at_override: Option<NaiveDateTime>,
}
struct UploadOutcome {
@@ -636,6 +640,10 @@ pub async fn upload_document(
let mut content_type: Option<String> = None;
let mut folder_id: Option<Uuid> = None;
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| {
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<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,
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<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;
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<NewDocumentCorrespondent> = 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::<Document>(&mut conn)?;
.first(&mut conn)?;
// Ensure tags exist
let existing_tags: Vec<Tag> = 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<NewDocumentTag> = 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<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<()> {
let mut conn = state.db_for_tenant(tenant_id)?;
let exists: bool = diesel::select(exists(