use std::{ collections::{HashMap, HashSet}, time::Duration, }; use axum::extract::{Json, Multipart, Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use chrono::{DateTime, NaiveDateTime, Utc}; use diesel::dsl::exists; use diesel::{prelude::*, result::DatabaseErrorKind, select}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use tracing::{debug, error, info, warn}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use super::folders::gather_descendant_folder_ids; use crate::auth::TenantScopedConn; use crate::documents::{ asset::{ build_download_path, derive_document_title, filename_with_retained_extension, load_asset_responses, load_primary_assets, to_asset_detail_response, to_asset_object_response, to_version_response, DocumentAssetDetailResponse, DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse, }, correspondents::{ insert_document_correspondents, load_correspondents_for_documents, normalize_correspondent_ids, DocumentCorrespondentResponse, }, folders::ensure_folder_exists_on_conn, metadata::merge_document_metadata, search::quickwit_search, tags::{assign_tags as assign_tags_to_document, load_tags_for_documents}, }; use crate::error::{AppError, AppResult}; use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT}; use crate::models::{ Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag, }; use crate::schema::{ document_asset_objects, document_assets, document_correspondents, document_tags, document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags, }; 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, }; const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; #[derive(Deserialize, IntoParams, ToSchema)] #[into_params(parameter_in = Query)] pub struct DocumentListQuery { pub folder_id: Option, #[serde(default)] pub include_descendants: Option, pub query: Option, pub tags: Option, pub correspondents: Option, #[serde(default = "default_document_status_filter")] pub status: DocumentStatusFilter, } fn default_document_status_filter() -> DocumentStatusFilter { DocumentStatusFilter::Active } #[derive(Clone, Copy, Deserialize, Serialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum DocumentStatusFilter { Active, Deleted, All, } #[derive(Deserialize, IntoParams, ToSchema)] #[into_params(parameter_in = Query)] pub struct AssetRequestQuery { #[serde(default)] pub force: bool, } #[derive(Deserialize, IntoParams, ToSchema)] #[into_params(parameter_in = Query)] pub struct DocumentCheckQuery { pub checksum: String, } #[derive(Serialize, ToSchema)] pub struct DocumentCheckResponse { pub exists: bool, #[serde(skip_serializing_if = "Option::is_none")] pub document_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(skip_serializing_if = "Option::is_none")] pub filename: Option, #[serde(skip_serializing_if = "Option::is_none")] pub version_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub version_number: Option, #[serde(skip_serializing_if = "Option::is_none")] pub uploaded_at: Option, } #[derive(Serialize, ToSchema)] pub struct TagResponse { pub id: Uuid, pub label: String, pub color: Option, } impl From for TagResponse { fn from(tag: Tag) -> Self { Self { id: tag.id, label: tag.label, color: tag.color, } } } #[derive(Serialize, ToSchema)] pub struct DocumentResponse { pub id: Uuid, pub filename: String, pub title: String, pub original_name: String, pub content_type: Option, pub folder_id: Option, pub uploaded_at: String, pub updated_at: String, pub deleted_at: Option, pub issued_at: Option, pub metadata: Value, pub tags: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub correspondents: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub current_version: Option, } #[derive(Serialize, ToSchema)] pub struct DocumentDetailResponse { pub document: DocumentResponse, } #[derive(Serialize, ToSchema)] pub struct BulkReanalyzeResponse { pub queued: usize, } #[derive(Deserialize, ToSchema)] pub struct BulkMoveRequest { pub document_ids: Vec, pub folder_id: Option, } #[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, #[serde(default)] #[schema(nullable, value_type = Option)] pub issued_at: Option, #[serde(default)] #[schema(nullable)] pub metadata: Option, } #[derive(Serialize, ToSchema)] pub struct BulkMoveResponse { pub updated: usize, } #[derive(Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum BulkTagAction { Add, Remove, } #[derive(Deserialize, ToSchema)] pub struct BulkTagRequest { pub document_ids: Vec, pub tag_ids: Vec, pub action: BulkTagAction, } #[derive(Serialize, ToSchema)] pub struct BulkTagResponse { pub added: usize, pub removed: usize, } #[derive(Serialize, ToSchema)] pub struct BulkCorrespondentResponse { pub assigned: usize, pub removed: usize, } #[derive(Deserialize, ToSchema)] pub struct CorrespondentAssignmentInput { pub correspondent_id: Uuid, } #[derive(Deserialize, ToSchema)] pub struct AssignCorrespondentsRequest { pub assignments: Vec, #[serde(default)] pub replace: bool, } #[derive(Default, AsChangeset)] #[diesel(table_name = documents)] struct DocumentUpdateChangeset { title: Option, filename: Option, issued_at: Option>, metadata: Option, updated_at: Option, } #[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)] #[serde(rename_all = "lowercase")] pub enum BulkCorrespondentAction { Add, Remove, } fn default_bulk_correspondent_action() -> BulkCorrespondentAction { BulkCorrespondentAction::Add } #[derive(Deserialize, ToSchema)] pub struct BulkCorrespondentsRequest { pub document_ids: Vec, pub assignments: Vec, #[serde(default = "default_bulk_correspondent_action")] pub action: BulkCorrespondentAction, } #[derive(Deserialize, ToSchema)] pub struct BulkReanalyzeSelectionRequest { pub document_ids: Vec, #[serde(default = "default_true")] pub force: bool, } fn default_true() -> bool { true } struct UploadRequest { bytes: Vec, original_name: String, content_type: Option, folder_id: Option, metadata: Value, title_override: Option, tag_ids: Vec, correspondents: Vec, issued_at_override: Option, skip_if_existing: bool, } enum UploadOutcome { Created(DocumentDetailResponse), Reused(DocumentDetailResponse), Skipped { document_id: Uuid }, } #[derive(ToSchema)] pub struct UploadDocumentForm { #[schema(value_type = String, format = Binary)] pub file: String, #[schema(nullable)] pub folder_id: Option, #[schema(nullable)] pub metadata: Option, #[schema(nullable)] pub title: Option, #[schema(nullable, value_type = Vec)] pub tag_ids: Option>, #[schema(nullable, value_type = Vec)] pub correspondents: Option>, #[schema(nullable, example = "2024-01-01T00:00:00Z")] pub issued_at: Option, #[schema(nullable)] pub skip_existing: Option, } #[derive(Deserialize, ToSchema)] pub struct MoveDocumentRequest { pub folder_id: Option, } #[derive(Deserialize, ToSchema)] pub struct RestoreDocumentRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub folder_id: Option, } #[derive(Deserialize, ToSchema)] pub struct AssignTagsRequest { pub tag_ids: Vec, } #[derive(Deserialize, Default, IntoParams, ToSchema)] #[into_params(parameter_in = Query)] pub struct AssetObjectsQuery { #[serde(default)] pub start: Option, #[serde(default)] pub limit: Option, } pub async fn list_documents( State(state): State, Query(params): Query, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult>> { let DocumentListQuery { folder_id, include_descendants, query, tags, correspondents, status, } = params; let mut docs_query = documents::table .filter(documents::tenant_id.eq(tenant_id)) .into_boxed(); match status { DocumentStatusFilter::Active => { docs_query = docs_query.filter(documents::deleted_at.is_null()); } DocumentStatusFilter::Deleted => { docs_query = docs_query.filter(documents::deleted_at.is_not_null()); } DocumentStatusFilter::All => {} } let search_text = query .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_owned()); let tags_param = tags .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_owned()); let correspondents_param = correspondents .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_owned()); let include_descendants = include_descendants.unwrap_or(true); match (folder_id, include_descendants) { (Some(folder_id), true) => { let descendant_ids = gather_descendant_folder_ids(&mut conn, tenant_id, folder_id)?; docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids)); } (Some(folder_id), false) => { docs_query = docs_query.filter(documents::folder_id.eq(Some(folder_id))); } (None, false) => { docs_query = docs_query.filter(documents::folder_id.is_null()); } (None, true) => {} } let mut filter_ids: Option> = None; let mut quickwit_order: Option> = None; if let Some(query_str) = search_text.as_ref() { debug!(query = %query_str, "performing quickwit document search"); let endpoint = state .config .quickwit_endpoint .as_ref() .ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?; let tenant = state.tenants.get_by_id(tenant_id)?; let index = tenant .quickwit_index .as_ref() .ok_or_else(|| AppError::internal("quickwit index not configured for tenant"))?; let ids = quickwit_search(endpoint, index, tenant_id, query_str) .await .map_err(|err| AppError::internal(format!("quickwit search failed: {err}")))?; if ids.is_empty() { return Ok(Json(vec![])); } quickwit_order = Some(ids.clone()); let set: HashSet = ids.into_iter().collect(); filter_ids = Some(match &filter_ids { Some(existing) => existing.intersection(&set).copied().collect(), None => set, }); } if let Some(tags_param) = tags_param.as_ref() { let tag_ids: Result, _> = tags_param .split(',') .map(|s| Uuid::parse_str(s.trim())) .collect(); if let Ok(ids) = tag_ids { if !ids.is_empty() { let mut doc_id_set: Option> = None; for tag_id in &ids { let docs_for_tag: Vec = document_tags::table .filter(document_tags::tag_id.eq(*tag_id)) .select(document_tags::document_id) .load(&mut conn)?; let docs_set: HashSet = docs_for_tag.into_iter().collect(); doc_id_set = Some(match doc_id_set { Some(existing) => existing.intersection(&docs_set).cloned().collect(), None => docs_set, }); if let Some(ref set) = doc_id_set { if set.is_empty() { break; } } } let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); if matching_doc_ids.is_empty() { return Ok(Json(vec![])); } let new_filter = match &filter_ids { Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(), None => matching_doc_ids.clone(), }; filter_ids = Some(new_filter); } } } if let Some(correspondents_param) = correspondents_param.as_ref() { let correspondent_ids: Result, _> = correspondents_param .split(',') .map(|s| Uuid::parse_str(s.trim())) .collect(); if let Ok(ids) = correspondent_ids { if !ids.is_empty() { let mut doc_id_set: Option> = None; for correspondent_id in &ids { let docs_for_correspondent: Vec = document_correspondents::table .filter(document_correspondents::correspondent_id.eq(*correspondent_id)) .select(document_correspondents::document_id) .load(&mut conn)?; let docs_set: HashSet = docs_for_correspondent.into_iter().collect(); doc_id_set = Some(match doc_id_set { Some(existing) => existing.intersection(&docs_set).cloned().collect(), None => docs_set, }); if let Some(ref set) = doc_id_set { if set.is_empty() { break; } } } let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); if matching_doc_ids.is_empty() { return Ok(Json(vec![])); } let new_filter = match &filter_ids { Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(), None => matching_doc_ids.clone(), }; filter_ids = Some(new_filter); } } } if let Some(ref set) = filter_ids { if set.is_empty() { return Ok(Json(vec![])); } let ids_vec: Vec = set.iter().copied().collect(); docs_query = docs_query.filter(documents::id.eq_any(ids_vec)); } let docs: Vec = if let Some(order_ids) = quickwit_order.as_ref() { let relevant_ids: Vec = if let Some(filter_set) = filter_ids.as_ref() { order_ids .iter() .copied() .filter(|id| filter_set.contains(id)) .collect() } else { order_ids.clone() }; if relevant_ids.is_empty() { return Ok(Json(vec![])); } let fetched: Vec = docs_query.load(&mut conn)?; let mut by_id: HashMap = fetched.into_iter().map(|doc| (doc.id, doc)).collect(); let mut ordered = Vec::with_capacity(by_id.len()); for id in relevant_ids { if let Some(doc) = by_id.remove(&id) { ordered.push(doc); } } if !by_id.is_empty() { let mut remaining: Vec = by_id.into_values().collect(); remaining.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); ordered.extend(remaining); } ordered } else { docs_query .order(documents::uploaded_at.desc()) .load(&mut conn)? }; let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; drop(conn); let primary_versions = load_primary_assets(&state, tenant_id, &docs)?; let mut response = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); let current_version = primary_versions.get(&doc.id).cloned(); response.push(to_document_response( &state, user_id, doc, tags, correspondents, current_version, )?); } Ok(Json(response)) } pub async fn check_document( Query(query): Query, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult> { let checksum_raw = query.checksum.trim(); if checksum_raw.is_empty() { return Err(AppError::bad_request("checksum must not be empty")); } let checksum = checksum_raw.to_ascii_lowercase(); if !checksum.chars().all(|ch| ch.is_ascii_hexdigit()) { return Err(AppError::bad_request( "checksum must be a hex-encoded string", )); } let record: Option<(Document, DocumentVersion)> = documents::table .inner_join( document_versions::table.on(document_versions::id.eq(documents::current_version_id)), ) .filter(documents::tenant_id.eq(tenant_id)) .filter(document_versions::checksum.eq(&checksum)) .select((documents::all_columns, document_versions::all_columns)) .first(&mut conn) .optional()?; if let Some((document, version)) = record { Ok(Json(DocumentCheckResponse { exists: true, document_id: Some(document.id), title: Some(document.title.clone()), filename: Some(document.filename.clone()), version_id: Some(version.id), version_number: Some(version.version_number), uploaded_at: Some(to_iso(document.uploaded_at)), })) } else { Ok(Json(DocumentCheckResponse { exists: false, document_id: None, title: None, filename: None, version_id: None, version_number: None, uploaded_at: None, })) } } pub async fn get_document( State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult> { let doc: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(&mut conn)?; if doc.deleted_at.is_some() { return Err(AppError::not_found()); } let current_version: DocumentVersion = document_versions::table .find(doc.current_version_id) .first(&mut conn)?; let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?; let version_id = current_version.id; drop(conn); let assets = load_asset_responses(&state, tenant_id, version_id).await?; let version_response = to_version_response(current_version); Ok(Json(DocumentDetailResponse { document: to_document_response( &state, user_id, doc, tags_map.get(&document_id).cloned(), correspondents_map.remove(&document_id).unwrap_or_default(), Some((version_response, assets)), )?, })) } pub async fn upload_document( State(state): State, TenantScopedConn { tenant_id, user_id, .. }: TenantScopedConn, mut multipart: Multipart, ) -> AppResult { let mut file_bytes: Option> = None; let mut original_name: Option = None; 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 skip_if_existing = false; let mut title_override: Option = None; while let Some(field) = multipart.next_field().await.map_err(|err| { let msg = format!("invalid multipart data: {err}"); error!(error = %err, "invalid multipart data"); AppError::bad_request(msg) })? { let name = field.name().map(|n| n.to_string()); match name.as_deref() { Some("file") => { let file_name = field.file_name().map(|n| n.to_string()); original_name = file_name.clone(); content_type = field.content_type().map(|mime| mime.to_string()); let data = field.bytes().await.map_err(|err| { let msg = format!("failed to read file bytes: {err}"); error!(error = %err, "failed to read file bytes"); AppError::bad_request(msg) })?; file_bytes = Some(data.to_vec()); } Some("folder_id") => { let value = field.text().await.map_err(|err| { let msg = format!("invalid folder id: {err}"); error!(error = %err, "invalid folder id"); AppError::bad_request(msg) })?; if !value.trim().is_empty() { let parsed = Uuid::parse_str(value.trim()) .map_err(|_| AppError::bad_request("folder_id must be a valid UUID"))?; folder_id = Some(parsed); } } Some("metadata") => { let value = field.text().await.map_err(|err| { let msg = format!("invalid metadata: {err}"); error!(error = %err, "invalid metadata payload"); AppError::bad_request(msg) })?; metadata = serde_json::from_str(&value).map_err(|err| { let msg = format!("metadata must be valid JSON: {err}"); error!(error = %err, "metadata parse failure"); 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}} 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()); } } Some("skip_existing") => { let value = field.text().await.map_err(|err| { let msg = format!("invalid skip_existing flag: {err}"); error!(error = %err, "invalid skip_existing payload"); AppError::bad_request(msg) })?; skip_if_existing = matches!( value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" ); } _ => {} } } let file_bytes = file_bytes.ok_or_else(|| { error!("upload rejected: missing file field"); AppError::bad_request("file field is required") })?; if file_bytes.is_empty() { error!("upload rejected: empty file payload"); return Err(AppError::bad_request("file field must not be empty")); } let original_name = original_name.ok_or_else(|| { error!("upload rejected: missing original filename"); AppError::bad_request("filename is required") })?; let original_name_for_log = original_name.clone(); let request = UploadRequest { bytes: file_bytes, original_name, content_type, folder_id, metadata, title_override, tag_ids, correspondents, issued_at_override, skip_if_existing, }; let outcome = match process_upload(&state, request, tenant_id, user_id).await { Ok(outcome) => outcome, Err(err) => { error!(error = ?err, original_name = %original_name_for_log, "document upload failed"); return Err(err); } }; let response = match outcome { UploadOutcome::Created(detail) => { info!( document_id = %detail.document.id, original_name = %detail.document.original_name, created = true, reused_existing = false, "document upload succeeded", ); (StatusCode::CREATED, Json(detail)).into_response() } UploadOutcome::Reused(detail) => { info!( document_id = %detail.document.id, original_name = %detail.document.original_name, created = false, reused_existing = true, "document upload succeeded", ); (StatusCode::OK, Json(detail)).into_response() } UploadOutcome::Skipped { document_id } => { info!(document_id = %document_id, "document upload skipped by client request"); StatusCode::NO_CONTENT.into_response() } }; Ok(response) } pub async fn request_document_assets( Path(document_id): Path, Query(query): Query, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { let document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(&mut conn)?; if document.deleted_at.is_some() { return Err(AppError::not_found()); } enqueue_job( &mut conn, tenant_id, JOB_ANALYZE_DOCUMENT, json!({ "document_id": document_id, "document_version_id": document.current_version_id, "force": query.force, }), None, ) .map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?; Ok(StatusCode::ACCEPTED) } pub async fn reanalyze_selected_documents( TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult<(StatusCode, Json)> { let BulkReanalyzeSelectionRequest { mut document_ids, force, } = payload; validate_bulk_ids(&mut document_ids, "document_ids")?; let targets: Vec<(Uuid, Uuid)> = documents::table .filter(documents::id.eq_any(&document_ids)) .filter(documents::deleted_at.is_null()) .filter(documents::tenant_id.eq(tenant_id)) .select((documents::id, documents::current_version_id)) .load(&mut conn)?; if targets.len() != document_ids.len() { return Err(AppError::bad_request( "one or more documents do not exist or are inaccessible", )); } let mut queued = 0usize; for (document_id, version_id) in targets { enqueue_job( &mut conn, tenant_id, JOB_ANALYZE_DOCUMENT, json!({ "document_id": document_id, "document_version_id": version_id, "force": force, }), None, ) .map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?; queued += 1; } Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued }))) } pub async fn list_document_assets( State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult>> { let document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(&mut conn)?; if document.deleted_at.is_some() { return Err(AppError::not_found()); } let version_id = document.current_version_id; drop(conn); let assets = load_asset_responses(&state, tenant_id, version_id).await?; Ok(Json(assets)) } pub async fn get_document_asset( State(state): State, Path(asset_id): Path, Query(query): Query, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult> { let asset: DocumentAsset = match document_assets::table .find(asset_id) .filter(document_assets::tenant_id.eq(tenant_id)) .first(&mut conn) .optional()? { Some(asset) => asset, None => return Err(AppError::not_found()), }; let start = query.start.unwrap_or(1); let limit = query.limit.unwrap_or(1); if start < 1 { return Err(AppError::bad_request("start must be at least 1")); } if limit < 1 { return Err(AppError::bad_request("limit must be at least 1")); } let end = start .checked_add(limit - 1) .ok_or_else(|| AppError::bad_request("requested range is too large"))?; let objects: Vec = document_asset_objects::table .filter(document_asset_objects::asset_id.eq(asset_id)) .filter(document_asset_objects::tenant_id.eq(tenant_id)) .filter(document_asset_objects::ordinal.ge(start)) .filter(document_asset_objects::ordinal.le(end)) .order(document_asset_objects::ordinal.asc()) .load(&mut conn)?; drop(conn); let expires_at = Utc::now() .timestamp_millis() .checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000) .ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?; let storage = state.storage_for_tenant(tenant_id)?; let mut object_responses = Vec::with_capacity(objects.len()); for object in objects { let url = storage .presign_get_object( &object.s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), ) .await .map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?; object_responses.push(to_asset_object_response( object, Some(url), Some(expires_at), )); } if object_responses.is_empty() { return Err(AppError::not_found()); } Ok(Json(to_asset_detail_response(asset, object_responses))) } pub async fn list_document_versions( Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult>> { let document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(&mut conn)?; if document.deleted_at.is_some() { return Err(AppError::not_found()); } let versions: Vec = document_versions::table .filter(document_versions::document_id.eq(document_id)) .filter(document_versions::tenant_id.eq(tenant_id)) .order(document_versions::version_number.asc()) .load(&mut conn)?; let versions: Vec = versions.into_iter().map(to_version_response).collect(); Ok(Json(versions)) } pub async fn get_document_version( State(state): State, Path((document_id, version_id)): Path<(Uuid, Uuid)>, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult> { let document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(&mut conn)?; if document.deleted_at.is_some() { return Err(AppError::not_found()); } let version: DocumentVersion = document_versions::table .find(version_id) .filter(document_versions::document_id.eq(document_id)) .filter(document_versions::tenant_id.eq(tenant_id)) .first(&mut conn)?; drop(conn); let assets = load_asset_responses(&state, tenant_id, version.id).await?; let download_path = build_download_path(&state, &document, user_id)?; let version_core = to_version_response(version); Ok(Json(DocumentVersionDetailResponse { version: version_core, assets, download_path, })) } pub async fn download_with_token( State(state): State, Path(token): Path, ) -> AppResult { let claims = state .jwt .verify_download_token(&token) .map_err(|_| AppError::unauthorized())?; let mut conn = state.db_for_tenant(claims.tenant_id)?; let doc: Document = documents::table .find(claims.doc_id) .filter(documents::tenant_id.eq(claims.tenant_id)) .first(&mut conn)?; if doc.deleted_at.is_some() { return Err(AppError::not_found()); } let version: DocumentVersion = document_versions::table .find(doc.current_version_id) .first(&mut conn)?; let now = Utc::now().naive_utc(); let has_active_refresh: bool = select(exists( refresh_dsl::refresh_tokens .filter(refresh_dsl::user_id.eq(claims.user_id)) .filter(refresh_dsl::tenant_id.eq(claims.tenant_id)) .filter(refresh_dsl::revoked_at.is_null()) .filter(refresh_dsl::expires_at.gt(now)), )) .get_result(&mut conn)?; if !has_active_refresh { return Err(AppError::unauthorized()); } drop(conn); let storage = state.storage_for_tenant(claims.tenant_id)?; let presigned_url = storage .presign_get_object( &version.s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), ) .await .map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?; Ok(axum::response::Redirect::temporary(&presigned_url)) } pub async fn delete_document( Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { let now = Utc::now().naive_utc(); diesel::update( documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)), ) .set(( documents::deleted_at.eq(Some(now)), documents::updated_at.eq(now), )) .execute(&mut conn)?; Ok(StatusCode::NO_CONTENT) } pub async fn update_document( State(state): State, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { let mut document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(&mut conn)?; if document.deleted_at.is_some() { return Err(AppError::not_found()); } 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")), }; 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::(value.clone()) .map_err(|err| AppError::bad_request(format!("invalid metadata payload: {err}")))?, ), }; let mut changes = DocumentUpdateChangeset::default(); let mut has_changes = false; let mut title_changed = 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; title_changed = 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")); } 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 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)), } 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)?; if title_changed { if let Err(err) = enqueue_job( &mut conn, tenant_id, JOB_INDEX_DOCUMENT_TEXT, json!({ "document_id": document.id, "document_version_id": current_version.id, }), None, ) { warn!( document_id = %document.id, version_id = %current_version.id, error = %err, "failed to enqueue reindex job after title change" ); } } let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?; let version_id = current_version.id; drop(conn); let assets = load_asset_responses(&state, tenant_id, version_id).await?; let version_response = to_version_response(current_version); Ok(Json(DocumentDetailResponse { document: to_document_response( &state, user_id, document, tags_map.get(&document_id).cloned(), correspondents_map.remove(&document_id).unwrap_or_default(), Some((version_response, assets)), )?, })) } pub async fn restore_document( Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult { let mut document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(&mut conn)?; if document.deleted_at.is_none() { return Ok(StatusCode::NO_CONTENT); } if let Some(folder_id) = payload.folder_id { ensure_folder_exists_on_conn(&mut conn, tenant_id, folder_id)?; document.folder_id = Some(folder_id); } else if let Some(folder_id) = document.folder_id { let exists: bool = diesel::select(exists( folders::table .filter(folders::id.eq(folder_id)) .filter(folders::tenant_id.eq(tenant_id)), )) .get_result(&mut conn)?; if !exists { document.folder_id = None; } } let now = Utc::now().naive_utc(); diesel::update( documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)), ) .set(( documents::deleted_at.eq::>(None), documents::folder_id.eq(document.folder_id), documents::updated_at.eq(now), )) .execute(&mut conn)?; Ok(StatusCode::NO_CONTENT) } pub async fn move_document( Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult { if let Some(folder_id) = payload.folder_id { ensure_folder_exists_on_conn(&mut conn, tenant_id, folder_id)?; } let now = Utc::now().naive_utc(); diesel::update( documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)), ) .set(( documents::folder_id.eq(payload.folder_id), documents::updated_at.eq(now), )) .execute(&mut conn)?; Ok(StatusCode::NO_CONTENT) } pub async fn bulk_move_documents( TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult<(StatusCode, Json)> { let BulkMoveRequest { mut document_ids, folder_id, } = payload; if document_ids.is_empty() { return Err(AppError::bad_request("document_ids must not be empty")); } document_ids.sort(); document_ids.dedup(); if let Some(target_folder) = folder_id { ensure_folder_exists_on_conn(&mut conn, tenant_id, target_folder)?; } let existing: Vec<(Uuid, Option)> = documents::table .filter(documents::id.eq_any(&document_ids)) .filter(documents::tenant_id.eq(tenant_id)) .select((documents::id, documents::deleted_at)) .load(&mut conn)?; if existing.len() != document_ids.len() { return Err(AppError::bad_request( "one or more documents do not exist or are inaccessible", )); } if existing.iter().any(|(_, deleted)| deleted.is_some()) { return Err(AppError::bad_request("cannot move deleted documents")); } let now = Utc::now().naive_utc(); let updated = match diesel::update( documents::table .filter(documents::id.eq_any(&document_ids)) .filter(documents::tenant_id.eq(tenant_id)), ) .set(( documents::folder_id.eq(folder_id), documents::updated_at.eq(now), )) .execute(&mut conn) { Ok(value) => value, Err(diesel::result::Error::DatabaseError(kind, info)) => { error!( ?kind, detail = ?info.details(), constraint = info.constraint_name(), tenant_id = %tenant_id, target_folder = folder_id.map(|id| id.to_string()), "bulk move update failed" ); let message = info .constraint_name() .map(|name| format!("constraint {name} prevented moving documents")) .unwrap_or_else(|| "unable to move documents due to a constraint".to_string()); return Err(AppError::conflict(message)); } Err(err) => { error!( ?err, tenant_id = %tenant_id, target_folder = folder_id.map(|id| id.to_string()), "bulk move update failed" ); return Err(AppError::from(err)); } }; let body = BulkMoveResponse { updated }; Ok((StatusCode::OK, body.into_json()?)) } pub async fn assign_correspondents( Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult { if payload.assignments.is_empty() { return Err(AppError::bad_request("assignments must not be empty")); } let raw_correspondent_ids: Vec = payload .assignments .iter() .map(|assignment| assignment.correspondent_id) .collect(); let correspondent_ids = normalize_correspondent_ids(&raw_correspondent_ids)?; let replace = payload.replace; conn.transaction::<(), AppError, _>(|conn| { let document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(conn)?; if document.deleted_at.is_some() { return Err(AppError::not_found()); } let mut updated = false; if replace { use diesel::dsl::not; let base = document_correspondents::table .filter(document_correspondents::document_id.eq(document_id)) .filter(document_correspondents::tenant_id.eq(tenant_id)); let removed = if correspondent_ids.is_empty() { diesel::delete(base).execute(conn)? } else { diesel::delete(base.filter(not( document_correspondents::correspondent_id.eq_any(&correspondent_ids), ))) .execute(conn)? }; if removed > 0 { updated = true; } } let inserted = insert_document_correspondents( conn, tenant_id, document.id, user_id, &correspondent_ids, )?; if inserted > 0 { updated = true; } if updated && 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(()) })?; no_content() } pub async fn bulk_assign_correspondents( TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult<(StatusCode, Json)> { if payload.assignments.is_empty() { return Err(AppError::bad_request("assignments must not be empty")); } let mut document_ids = payload.document_ids; validate_bulk_ids(&mut document_ids, "document_ids")?; let raw_correspondent_ids: Vec = payload .assignments .iter() .map(|assignment| assignment.correspondent_id) .collect(); let correspondent_ids = normalize_correspondent_ids(&raw_correspondent_ids)?; let action = payload.action; let user_id_val = user_id; let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| { let docs: Vec<(Uuid, Option)> = documents::table .filter(documents::id.eq_any(&document_ids)) .filter(documents::tenant_id.eq(tenant_id)) .select((documents::id, documents::deleted_at)) .load(conn)?; if docs.len() != document_ids.len() { return Err(AppError::bad_request( "one or more documents do not exist or are inaccessible", )); } if docs.iter().any(|(_, deleted)| deleted.is_some()) { return Err(AppError::bad_request( "cannot assign correspondents to deleted documents", )); } match action { BulkCorrespondentAction::Add => { let mut assigned_total = 0; for (doc_id, _) in &docs { assigned_total += insert_document_correspondents( conn, tenant_id, *doc_id, user_id_val, &correspondent_ids, )?; } Ok((assigned_total, 0)) } BulkCorrespondentAction::Remove => { if correspondent_ids.is_empty() { return Ok((0, 0)); } let removed = diesel::delete( document_correspondents::table .filter(document_correspondents::document_id.eq_any(&document_ids)) .filter(document_correspondents::tenant_id.eq(tenant_id)) .filter( document_correspondents::correspondent_id.eq_any(&correspondent_ids), ), ) .execute(conn)?; if removed > 0 { diesel::update( documents::table .filter(documents::id.eq_any(&document_ids)) .filter(documents::tenant_id.eq(tenant_id)), ) .set(documents::updated_at.eq(Utc::now().naive_utc())) .execute(conn)?; } Ok((0, removed)) } } })?; let body = BulkCorrespondentResponse { assigned, removed }; Ok((StatusCode::OK, body.into_json()?)) } pub async fn remove_correspondent( Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { let document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(&mut conn)?; if document.deleted_at.is_some() { return Err(AppError::not_found()); } let 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::correspondent_id.eq(correspondent_id)), ) .execute(&mut conn)?; if deleted == 0 { return Err(AppError::not_found()); } diesel::update( documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)), ) .set(documents::updated_at.eq(Utc::now().naive_utc())) .execute(&mut conn)?; no_content() } pub async fn assign_tags( Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult { if payload.tag_ids.is_empty() { return Err(AppError::bad_request("tag_ids must not be empty")); } let document: Document = documents::table .find(document_id) .filter(documents::tenant_id.eq(tenant_id)) .first(&mut conn)?; assign_tags_to_document( &mut conn, tenant_id, &document, &payload.tag_ids, Some(user_id), )?; Ok(StatusCode::NO_CONTENT) } pub async fn bulk_update_tags( TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult<(StatusCode, Json)> { let BulkTagRequest { mut document_ids, mut tag_ids, action, } = payload; validate_bulk_ids(&mut document_ids, "document_ids")?; validate_bulk_ids(&mut tag_ids, "tag_ids")?; let docs: Vec<(Uuid, Option)> = documents::table .filter(documents::id.eq_any(&document_ids)) .filter(documents::tenant_id.eq(tenant_id)) .select((documents::id, documents::deleted_at)) .load(&mut conn)?; if docs.len() != document_ids.len() { return Err(AppError::bad_request( "one or more documents do not exist or are inaccessible", )); } if docs.iter().any(|(_, deleted)| deleted.is_some()) { return Err(AppError::bad_request( "cannot assign or remove tags from deleted documents", )); } let existing_tags: Vec = tags::table .filter(tags::id.eq_any(&tag_ids)) .filter(tags::tenant_id.eq(tenant_id)) .load(&mut conn)?; if existing_tags.len() != tag_ids.len() { return Err(AppError::bad_request("one or more tags do not exist")); } let response = match action { BulkTagAction::Add => { let mut inserts = Vec::with_capacity(document_ids.len() * tag_ids.len()); for doc_id in &document_ids { for tag_id in &tag_ids { inserts.push(NewDocumentTag { document_id: *doc_id, tag_id: *tag_id, assigned_by: Some(user_id), tenant_id, }); } } let added = if inserts.is_empty() { 0 } else { diesel::insert_into(document_tags::table) .values(&inserts) .on_conflict_do_nothing() .execute(&mut conn)? }; BulkTagResponse { added, removed: 0 } } BulkTagAction::Remove => { let removed = diesel::delete( document_tags::table .filter(document_tags::document_id.eq_any(&document_ids)) .filter(document_tags::tenant_id.eq(tenant_id)) .filter(document_tags::tag_id.eq_any(&tag_ids)), ) .execute(&mut conn)?; BulkTagResponse { added: 0, removed } } }; Ok((StatusCode::OK, response.into_json()?)) } pub async fn remove_tag( Path((document_id, tag_id)): Path<(Uuid, Uuid)>, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { diesel::delete( document_tags::table .filter(document_tags::document_id.eq(document_id)) .filter(document_tags::tenant_id.eq(tenant_id)) .filter(document_tags::tag_id.eq(tag_id)), ) .execute(&mut conn)?; no_content() } async fn process_upload( state: &AppState, request: UploadRequest, tenant_id: Uuid, user_id: Uuid, ) -> AppResult { let UploadRequest { bytes, original_name, content_type, folder_id, metadata, title_override, tag_ids, correspondents, issued_at_override, skip_if_existing, } = request; 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)?; } let doc_id = Uuid::new_v4(); let version_id = Uuid::new_v4(); let version_number = 1; 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); let size_bytes = bytes.len() as i64; let s3_key = document_version_object_key(doc_id, version_number, version_id); { let mut conn = state.db_for_tenant(tenant_id)?; let existing = documents::table .inner_join( document_versions::table .on(document_versions::id.eq(documents::current_version_id)), ) .filter(documents::tenant_id.eq(tenant_id)) .filter(document_versions::checksum.eq(&checksum_hex)) .select((documents::all_columns, document_versions::all_columns)) .first::<(Document, DocumentVersion)>(&mut conn) .optional()?; if let Some((mut document, version)) = existing { if skip_if_existing { info!( document_id = %document.id, checksum = %checksum_hex, "upload skipped existing document due to skip flag", ); return Ok(UploadOutcome::Skipped { document_id: document.id, }); } 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_to_document(&mut conn, tenant_id, &document, &tag_ids, Some(user_id))?; if !correspondents.is_empty() { let raw_ids: Vec = correspondents .iter() .map(|assignment| assignment.correspondent_id) .collect(); let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; insert_document_correspondents( &mut conn, tenant_id, document.id, user_id, &correspondent_ids, )?; } if document.deleted_at.is_some() { let now = Utc::now().naive_utc(); diesel::update(documents::table.find(document.id)) .set(( documents::deleted_at.eq(None::), documents::updated_at.eq(now), )) .execute(&mut conn)?; document.deleted_at = None; document.updated_at = now; } let tags_map = load_tags_for_documents(&mut conn, &[document.id])?; let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document.id])?; let tags = tags_map.get(&document.id).cloned(); let correspondents = correspondents_map.remove(&document.id).unwrap_or_default(); drop(conn); let assets = load_asset_responses(state, tenant_id, version.id).await?; let version_response = to_version_response(version.clone()); info!( document_id = %document.id, checksum = %checksum_hex, "upload deduplicated existing document" ); return Ok(UploadOutcome::Reused(DocumentDetailResponse { document: to_document_response( state, user_id, document, tags, correspondents, Some((version_response, assets)), )?, })); } } let content_disposition = inline_content_disposition(&stored_filename); let storage = state.storage_for_tenant(tenant_id)?; storage .put_object( &s3_key, bytes.clone(), content_type.clone(), content_disposition.clone(), ) .await .map_err(|err| { error!(error = %err, key = %s3_key, "failed to store document"); AppError::internal(format!("failed to store document: {err}")) })?; let metadata_value = if metadata.is_null() { Value::Object(Default::default()) } else { metadata }; let (document, version) = { let mut conn = state.db_for_tenant(tenant_id)?; let transaction_result = conn.transaction(|conn| { let new_document = NewDocument { id: doc_id, filename: stored_filename.clone(), original_name: original_name.clone(), content_type: content_type.clone(), folder_id, current_version_id: version_id, issued_at: issued_at_override, title: derived_title.clone(), metadata: metadata_value.clone(), tenant_id, }; diesel::insert_into(documents::table) .values(&new_document) .execute(conn)?; let new_version = NewDocumentVersion { id: version_id, document_id: doc_id, version_number, s3_key: s3_key.clone(), size_bytes, checksum: checksum_hex.clone(), metadata: Value::Object(Default::default()), tenant_id, }; diesel::insert_into(document_versions::table) .values(&new_version) .execute(conn)?; let document: Document = documents::table.find(doc_id).first(conn)?; let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?; Ok::<_, diesel::result::Error>((document, version)) }); match transaction_result { Ok(result) => result, 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)), } }; let detail = { let mut conn = state.db_for_tenant(tenant_id)?; assign_tags_to_document(&mut conn, tenant_id, &document, &tag_ids, Some(user_id))?; if !correspondents.is_empty() { let raw_ids: Vec = correspondents .iter() .map(|assignment| assignment.correspondent_id) .collect(); let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; insert_document_correspondents( &mut conn, tenant_id, document.id, user_id, &correspondent_ids, )?; } 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()), Vec::new())), )?, } }; if let Ok(mut conn) = state.db_for_tenant(tenant_id) { if let Err(err) = enqueue_job( &mut conn, tenant_id, JOB_ANALYZE_DOCUMENT, json!({ "document_id": doc_id, "document_version_id": version.id, "force": false, }), None, ) { warn!(document_id = %doc_id, error = %err, "failed to enqueue analyze job"); } } else { warn!(document_id = %doc_id, "failed to enqueue analyze job due to pool error"); } Ok(UploadOutcome::Created(detail)) } pub(crate) fn to_document_response( state: &AppState, user_id: Uuid, doc: Document, tags: Option>, correspondents: Vec, current_version: Option<(DocumentVersionResponse, Vec)>, ) -> AppResult { let current_version = if let Some((version, assets)) = current_version { let download_path = build_download_path(state, &doc, user_id)?; Some(DocumentVersionDetailResponse { version, assets, download_path, }) } else { None }; Ok(DocumentResponse { id: doc.id, filename: doc.filename, title: doc.title, original_name: doc.original_name, content_type: doc.content_type, folder_id: doc.folder_id, uploaded_at: to_iso(doc.uploaded_at), updated_at: to_iso(doc.updated_at), deleted_at: doc.deleted_at.map(to_iso), issued_at: doc.issued_at.map(to_iso), metadata: doc.metadata, tags: tags .unwrap_or_default() .into_iter() .map(TagResponse::from) .collect(), correspondents, current_version, }) }