document updates
This commit is contained in:
@@ -687,10 +687,22 @@ pub mod schemas {
|
||||
pub size_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct DocumentMetadataUpdate {
|
||||
pub value: Value,
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateDocumentRequest {
|
||||
#[schema(nullable)]
|
||||
pub title: Option<String>,
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub issued_at: Option<Value>,
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<DocumentMetadataUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
|
||||
+173
-37
@@ -11,7 +11,7 @@ use diesel::dsl::exists;
|
||||
use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{json, map::Entry, Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
@@ -34,6 +34,7 @@ use crate::state::AppState;
|
||||
use crate::utils::{
|
||||
db::{no_content, validate_bulk_ids, IntoJsonResponse},
|
||||
http::inline_content_disposition,
|
||||
json::{classify_nullable, NullableValue},
|
||||
storage_paths::document_version_object_key,
|
||||
time::to_iso,
|
||||
validation::ensure_exists,
|
||||
@@ -225,9 +226,24 @@ pub struct BulkMoveRequest {
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct DocumentMetadataUpdate {
|
||||
pub value: Value,
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct UpdateDocumentRequest {
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable, value_type = Option<String>)]
|
||||
pub issued_at: Option<Value>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub metadata: Option<DocumentMetadataUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -274,6 +290,16 @@ pub struct AssignCorrespondentsRequest {
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, AsChangeset)]
|
||||
#[diesel(table_name = documents)]
|
||||
struct DocumentUpdateChangeset {
|
||||
title: Option<String>,
|
||||
filename: Option<String>,
|
||||
issued_at: Option<Option<NaiveDateTime>>,
|
||||
metadata: Option<Value>,
|
||||
updated_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BulkCorrespondentAction {
|
||||
@@ -1220,7 +1246,7 @@ pub async fn update_document(
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateDocumentRequest>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> AppResult<Json<DocumentDetailResponse>> {
|
||||
let mut document: Document = documents::table
|
||||
.find(document_id)
|
||||
@@ -1230,52 +1256,113 @@ pub async fn update_document(
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
let new_title = match payload.title {
|
||||
Some(ref title) => {
|
||||
let trimmed = title.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("title must not be empty"));
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
None => None,
|
||||
let payload_obj = payload
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::bad_request("request body must be a JSON object"))?;
|
||||
|
||||
let title = match payload_obj.get("title") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(value)) => Some(value.clone()),
|
||||
Some(_) => return Err(AppError::bad_request("title must be a string")),
|
||||
};
|
||||
|
||||
if new_title.is_none() {
|
||||
let issued_at_class =
|
||||
classify_nullable(payload_obj.get("issued_at")).map_err(AppError::bad_request)?;
|
||||
|
||||
let metadata = match payload_obj.get("metadata") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(value) => Some(
|
||||
serde_json::from_value::<DocumentMetadataUpdate>(value.clone())
|
||||
.map_err(|err| AppError::bad_request(format!("invalid metadata payload: {err}")))?,
|
||||
),
|
||||
};
|
||||
|
||||
let mut changes = DocumentUpdateChangeset::default();
|
||||
let mut has_changes = false;
|
||||
|
||||
if let Some(ref candidate) = title {
|
||||
let trimmed = candidate.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("title must not be empty"));
|
||||
}
|
||||
if trimmed != document.title {
|
||||
let new_title = trimmed.to_string();
|
||||
let new_filename = filename_with_retained_extension(&new_title, &document.filename);
|
||||
changes.title = Some(new_title);
|
||||
if new_filename != document.filename {
|
||||
changes.filename = Some(new_filename);
|
||||
}
|
||||
has_changes = true;
|
||||
}
|
||||
}
|
||||
|
||||
match issued_at_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
if document.issued_at.is_some() {
|
||||
changes.issued_at = Some(None);
|
||||
has_changes = true;
|
||||
}
|
||||
}
|
||||
NullableValue::String(raw) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("issued_at must not be empty"));
|
||||
}
|
||||
let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| {
|
||||
let msg = format!("issued_at must be an RFC3339 timestamp: {err}");
|
||||
AppError::bad_request(msg)
|
||||
})?;
|
||||
let normalized = Some(parsed.naive_utc());
|
||||
if document.issued_at != normalized {
|
||||
changes.issued_at = Some(normalized);
|
||||
has_changes = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(metadata_update) = metadata {
|
||||
let next_metadata = if metadata_update.replace {
|
||||
metadata_update.value
|
||||
} else {
|
||||
merge_document_metadata(document.metadata.clone(), metadata_update.value)?
|
||||
};
|
||||
|
||||
if document.metadata != next_metadata {
|
||||
changes.metadata = Some(next_metadata);
|
||||
has_changes = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !has_changes {
|
||||
return Err(AppError::bad_request("no changes provided"));
|
||||
}
|
||||
|
||||
if let Some(title) = new_title {
|
||||
let now = Utc::now().naive_utc();
|
||||
let new_filename = filename_with_retained_extension(&title, &document.filename);
|
||||
let now = Utc::now().naive_utc();
|
||||
changes.updated_at = Some(now);
|
||||
|
||||
let target = documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id));
|
||||
let target = documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id));
|
||||
|
||||
let update_result = diesel::update(target).set((
|
||||
documents::title.eq(&title),
|
||||
documents::filename.eq(&new_filename),
|
||||
documents::updated_at.eq(now),
|
||||
));
|
||||
let update_result = diesel::update(target).set(&changes);
|
||||
|
||||
match update_result.execute(&mut conn) {
|
||||
Ok(_) => {}
|
||||
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
|
||||
return Err(AppError::conflict(
|
||||
"another document in this folder already uses that filename",
|
||||
)
|
||||
.with_code("duplicate_filename"));
|
||||
}
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
match update_result.execute(&mut conn) {
|
||||
Ok(_) => {}
|
||||
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
|
||||
return Err(AppError::conflict(
|
||||
"another document in this folder already uses that filename",
|
||||
)
|
||||
.with_code("duplicate_filename"));
|
||||
}
|
||||
|
||||
document = documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?;
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
document = documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?;
|
||||
|
||||
let current_version: DocumentVersion = document_versions::table
|
||||
.find(document.current_version_id)
|
||||
.first(&mut conn)?;
|
||||
@@ -1300,6 +1387,55 @@ pub async fn update_document(
|
||||
}))
|
||||
}
|
||||
|
||||
fn merge_document_metadata(existing: Value, updates: Value) -> AppResult<Value> {
|
||||
let mut base = match existing {
|
||||
Value::Object(map) => map,
|
||||
Value::Null => Map::new(),
|
||||
_ => {
|
||||
return Err(AppError::bad_request(
|
||||
"existing metadata is not an object; set replace=true to overwrite",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let incoming = match updates {
|
||||
Value::Object(map) => map,
|
||||
_ => {
|
||||
return Err(AppError::bad_request(
|
||||
"metadata value must be a JSON object when replace is false",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
merge_metadata_maps(&mut base, incoming);
|
||||
Ok(Value::Object(base))
|
||||
}
|
||||
|
||||
fn merge_metadata_maps(target: &mut Map<String, Value>, updates: Map<String, Value>) {
|
||||
for (key, value) in updates {
|
||||
match target.entry(key) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let existing = entry.get_mut();
|
||||
match value {
|
||||
Value::Object(update_map) => {
|
||||
if let Value::Object(existing_map) = existing {
|
||||
merge_metadata_maps(existing_map, update_map);
|
||||
} else {
|
||||
*existing = Value::Object(update_map);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
*existing = other;
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_document(
|
||||
Path(document_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
|
||||
Reference in New Issue
Block a user