This commit is contained in:
2025-10-31 02:19:38 +01:00
parent f796932cd8
commit eacab2ced6
7 changed files with 462 additions and 17 deletions
+51 -1
View File
@@ -325,6 +325,7 @@ struct UploadRequest {
correspondents: Vec<CorrespondentAssignmentInput>,
issued_at_override: Option<NaiveDateTime>,
skip_if_existing: bool,
document_type_id: Option<Uuid>,
}
enum UploadOutcome {
@@ -785,6 +786,7 @@ pub async fn upload_document(
let mut issued_at_override: Option<NaiveDateTime> = None;
let mut skip_if_existing = false;
let mut title_override: Option<String> = None;
let mut document_type_id: Option<Uuid> = None;
while let Some(field) = multipart.next_field().await.map_err(|err| {
let msg = format!("invalid multipart data: {err}");
@@ -903,6 +905,20 @@ pub async fn upload_document(
"1" | "true" | "yes"
);
}
Some("document_type_id") => {
let value = field.text().await.map_err(|err| {
let msg = format!("invalid document_type_id: {err}");
error!(error = %err, "invalid document_type payload");
AppError::bad_request(msg)
})?;
let trimmed = value.trim();
if !trimmed.is_empty() {
let parsed = Uuid::parse_str(trimmed).map_err(|_| {
AppError::bad_request("document_type_id must be a valid UUID")
})?;
document_type_id = Some(parsed);
}
}
_ => {}
}
}
@@ -933,6 +949,7 @@ pub async fn upload_document(
correspondents,
issued_at_override,
skip_if_existing,
document_type_id,
};
let outcome = match process_upload(&state, request, tenant_id, user_id).await {
@@ -2178,8 +2195,23 @@ async fn process_upload(
correspondents,
issued_at_override,
skip_if_existing,
document_type_id,
} = request;
if let Some(type_id) = document_type_id {
let mut conn = state.db_for_tenant(tenant_id)?;
let exists = document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.find(type_id)
.first::<DocumentType>(&mut conn)
.optional()?;
if exists.is_none() {
return Err(AppError::bad_request(
"document_type_id does not exist for this tenant",
));
}
}
if let Some(folder) = folder_id {
let mut conn = state.db_for_tenant(tenant_id)?;
ensure_folder_exists_on_conn(&mut conn, tenant_id, folder)?;
@@ -2260,6 +2292,24 @@ async fn process_upload(
)?;
}
if let Some(type_id) = document_type_id {
if document.document_type_id != Some(type_id) {
let now = Utc::now().naive_utc();
diesel::update(
documents::table
.find(document.id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set((
documents::document_type_id.eq(Some(type_id)),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
document.document_type_id = Some(type_id);
document.updated_at = now;
}
}
if document.deleted_at.is_some() {
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document.id))
@@ -2336,7 +2386,7 @@ async fn process_upload(
title: derived_title.clone(),
metadata: metadata_value.clone(),
tenant_id,
document_type_id: None,
document_type_id,
};
diesel::insert_into(documents::table)
.values(&new_document)
+28
View File
@@ -0,0 +1,28 @@
use diesel::QueryResult;
use crate::error::{AppError, AppResult};
/// Trim and validate a user-supplied entity name, returning an owned String.
///
/// The `on_empty` closure is only invoked when the trimmed name is empty, giving
/// callers control over the concrete error that should be surfaced.
pub fn normalize_name(raw: &str, on_empty: impl Fn() -> AppError) -> AppResult<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(on_empty());
}
Ok(trimmed.to_string())
}
/// Ensure that no conflicting entity exists by executing the provided query
/// closure. If a record is returned, the `on_duplicate` closure is evaluated to
/// produce the appropriate error.
pub fn ensure_name_available<T>(
query: impl FnOnce() -> QueryResult<Option<T>>,
on_duplicate: impl Fn() -> AppError,
) -> AppResult<()> {
if query()?.is_some() {
return Err(on_duplicate());
}
Ok(())
}
+95
View File
@@ -0,0 +1,95 @@
use std::collections::HashMap;
use diesel::{prelude::*, PgConnection};
use uuid::Uuid;
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions, documents};
use crate::state::AppState;
pub(crate) struct DocumentVersionContext {
pub document: Document,
pub version: DocumentVersion,
pub tenant_id: Uuid,
}
pub(crate) fn load_document_version(
state: &AppState,
document_id: Uuid,
version_id: Uuid,
) -> Result<DocumentVersionContext, String> {
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table
.find(version_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if version.document_id != document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(document_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
Ok(DocumentVersionContext {
document,
version,
tenant_id,
})
}
pub(crate) struct LoadedAsset {
pub asset: DocumentAsset,
pub objects: Vec<DocumentAssetObject>,
}
pub(crate) fn load_version_assets(
conn: &mut PgConnection,
tenant_id: Uuid,
version_id: Uuid,
asset_types: &[&str],
) -> Result<HashMap<String, LoadedAsset>, String> {
let mut query = document_assets::table
.filter(document_assets::document_version_id.eq(version_id))
.filter(document_assets::tenant_id.eq(tenant_id))
.into_boxed();
if !asset_types.is_empty() {
let types: Vec<String> = asset_types.iter().map(|ty| (*ty).to_string()).collect();
query = query.filter(document_assets::asset_type.eq_any(types));
}
let assets: Vec<DocumentAsset> = query
.order(document_assets::created_at.asc())
.load(conn)
.map_err(|err| format!("{err:?}"))?;
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
let mut object_map: HashMap<Uuid, Vec<DocumentAssetObject>> = HashMap::new();
if !asset_ids.is_empty() {
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(conn)
.map_err(|err| format!("{err:?}"))?;
for object in objects {
object_map.entry(object.asset_id).or_default().push(object);
}
}
let mut result = HashMap::with_capacity(assets.len());
for asset in assets {
let objects = object_map.remove(&asset.id).unwrap_or_default();
result.insert(asset.asset_type.clone(), LoadedAsset { asset, objects });
}
Ok(result)
}