1626 lines
54 KiB
Rust
1626 lines
54 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
|
|
use chrono::{DateTime, Duration as ChronoDuration, NaiveDateTime, Utc};
|
|
use diesel::{
|
|
dsl::{exists, not, sql},
|
|
prelude::*,
|
|
result::DatabaseErrorKind,
|
|
sql_types::Text,
|
|
Connection,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Map, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use tracing::{debug, error, info, warn};
|
|
use utoipa::{IntoParams, ToSchema};
|
|
use uuid::Uuid;
|
|
|
|
use crate::documents::{
|
|
asset::{
|
|
build_download_link, derive_document_title, filename_with_retained_extension,
|
|
to_asset_detail_response, to_version_response, DocumentAssetDetailResponse,
|
|
DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse,
|
|
DownloadLink,
|
|
},
|
|
correspondents::{
|
|
insert_document_correspondents, normalize_correspondent_ids, DocumentCorrespondentResponse,
|
|
},
|
|
folders::ensure_folder_exists_on_conn,
|
|
metadata::merge_document_metadata,
|
|
ordering::{ordering_clauses, DocumentSortField, SortDirection},
|
|
relations::load_tags_and_correspondents,
|
|
search::quickwit_search,
|
|
tags::assign_tags as assign_tags_to_document,
|
|
};
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::jobs::{enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_PURGE_DOCUMENT};
|
|
use crate::models::{
|
|
Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentVersion, Tag,
|
|
};
|
|
use crate::schema::{
|
|
document_assets, document_correspondents, document_tags, document_versions, documents, folders,
|
|
};
|
|
use crate::services::{
|
|
correspondents::CorrespondentAssignmentInput, folders::gather_descendant_folder_ids,
|
|
helpers::load_active_document,
|
|
};
|
|
use crate::state::{AppState, PgPooledConnection};
|
|
use crate::utils::{
|
|
db::validate_bulk_ids, error::StorageResultExt, http::inline_content_disposition,
|
|
json::classify_nullable, json::NullableValue, setops::intersect_option_sets,
|
|
setops::load_linked_doc_ids, storage_paths::document_version_object_key, time::to_iso,
|
|
};
|
|
|
|
#[derive(Deserialize, IntoParams, ToSchema, Clone)]
|
|
#[into_params(parameter_in = Query)]
|
|
pub struct DocumentListQuery {
|
|
#[schema(nullable)]
|
|
pub folder_id: Option<Uuid>,
|
|
#[serde(default)]
|
|
#[schema(nullable)]
|
|
pub include_descendants: Option<bool>,
|
|
pub query: Option<String>,
|
|
pub tags: Option<String>,
|
|
pub correspondents: Option<String>,
|
|
#[serde(default = "default_document_status_filter")]
|
|
#[schema(default = "active")]
|
|
pub status: DocumentStatusFilter,
|
|
#[serde(default)]
|
|
#[schema(default = "title")]
|
|
pub sort: DocumentSortField,
|
|
#[serde(default)]
|
|
#[schema(default = "asc")]
|
|
pub dir: SortDirection,
|
|
}
|
|
|
|
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(Serialize, ToSchema)]
|
|
pub struct TagResponse {
|
|
pub id: Uuid,
|
|
pub label: String,
|
|
#[schema(nullable)]
|
|
pub color: Option<String>,
|
|
}
|
|
|
|
impl From<Tag> for TagResponse {
|
|
fn from(tag: Tag) -> Self {
|
|
Self {
|
|
id: tag.id,
|
|
label: tag.label,
|
|
color: tag.color,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct DocumentCheckResponse {
|
|
pub exists: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub document_id: Option<Uuid>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub title: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub filename: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub version_id: Option<Uuid>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub version_number: Option<i32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub created_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct DocumentResponse {
|
|
pub id: Uuid,
|
|
pub filename: String,
|
|
pub title: String,
|
|
pub original_name: String,
|
|
#[schema(nullable)]
|
|
pub content_type: Option<String>,
|
|
#[schema(nullable)]
|
|
pub folder_id: Option<Uuid>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
#[schema(nullable)]
|
|
pub deleted_at: Option<String>,
|
|
#[schema(nullable)]
|
|
pub issued_at: Option<String>,
|
|
#[schema(value_type = Object)]
|
|
pub metadata: Value,
|
|
pub tags: Vec<TagResponse>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub correspondents: Vec<DocumentCorrespondentResponse>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub current_version: Option<DocumentVersionDetailResponse>,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct DocumentDetailResponse {
|
|
pub document: DocumentResponse,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct DocumentMetadataUpdate {
|
|
#[schema(value_type = Object)]
|
|
pub value: Value,
|
|
#[serde(default)]
|
|
#[schema(default = false)]
|
|
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>,
|
|
}
|
|
|
|
struct DocumentUpdatePlan {
|
|
changeset: DocumentUpdateChangeset,
|
|
title_changed: 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, value_type = Object)]
|
|
pub metadata: Option<DocumentMetadataUpdate>,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct BulkMoveRequest {
|
|
pub document_ids: Vec<Uuid>,
|
|
#[schema(nullable)]
|
|
pub folder_id: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct BulkMoveResponse {
|
|
pub updated: usize,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct BulkReanalyzeSelectionRequest {
|
|
pub document_ids: Vec<Uuid>,
|
|
#[serde(default = "default_true")]
|
|
#[schema(default = true)]
|
|
pub force: bool,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct BulkReanalyzeResponse {
|
|
pub queued: usize,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
pub struct DocumentUploadRequest {
|
|
pub bytes: Vec<u8>,
|
|
pub original_name: String,
|
|
pub content_type: Option<String>,
|
|
pub folder_id: Option<Uuid>,
|
|
pub metadata: Value,
|
|
pub title_override: Option<String>,
|
|
pub tag_ids: Vec<Uuid>,
|
|
pub correspondents: Vec<CorrespondentAssignmentInput>,
|
|
pub issued_at_override: Option<NaiveDateTime>,
|
|
pub skip_if_existing: bool,
|
|
}
|
|
|
|
pub enum DocumentUploadOutcome {
|
|
Created(DocumentDetailResponse),
|
|
Reused(DocumentDetailResponse),
|
|
}
|
|
|
|
pub struct DocumentsService<'a> {
|
|
state: &'a AppState,
|
|
}
|
|
|
|
impl<'a> DocumentsService<'a> {
|
|
pub fn new(state: &'a AppState) -> Self {
|
|
Self { state }
|
|
}
|
|
|
|
fn build_document_update_plan(
|
|
document: &Document,
|
|
payload: &Map<String, Value>,
|
|
) -> AppResult<DocumentUpdatePlan> {
|
|
let title = match payload.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.get("issued_at")).map_err(AppError::bad_request)?;
|
|
|
|
let metadata = match payload.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;
|
|
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"));
|
|
}
|
|
|
|
Ok(DocumentUpdatePlan {
|
|
changeset: changes,
|
|
title_changed,
|
|
})
|
|
}
|
|
|
|
pub async fn get_document_detail(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
document_id: Uuid,
|
|
) -> AppResult<DocumentDetailResponse> {
|
|
let doc = load_active_document(conn, tenant_id, document_id)?;
|
|
|
|
let current_version: DocumentVersion = document_versions::table
|
|
.find(doc.current_version_id)
|
|
.first(conn)?;
|
|
|
|
let tags_and_correspondents = load_tags_and_correspondents(conn, &[document_id])?;
|
|
let (tags, correspondents) = tags_and_correspondents
|
|
.get(&document_id)
|
|
.cloned()
|
|
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
|
|
|
let assets = self.load_asset_responses(conn, tenant_id, current_version.id, user_id)?;
|
|
let download = build_download_link(self.state, &doc, current_version.id, user_id)?;
|
|
let current_version_data = Some((to_version_response(current_version), assets, download));
|
|
|
|
let response =
|
|
self.to_document_response(user_id, doc, tags, correspondents, current_version_data)?;
|
|
|
|
Ok(DocumentDetailResponse { document: response })
|
|
}
|
|
|
|
pub fn check_document(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
checksum_hex: &str,
|
|
) -> AppResult<DocumentCheckResponse> {
|
|
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_hex))
|
|
.select((documents::all_columns, document_versions::all_columns))
|
|
.first::<(Document, DocumentVersion)>(conn)
|
|
.optional()?;
|
|
|
|
if let Some((document, version)) = record {
|
|
Ok(DocumentCheckResponse {
|
|
exists: true,
|
|
document_id: Some(document.id),
|
|
title: Some(document.title),
|
|
filename: Some(document.filename),
|
|
version_id: Some(version.id),
|
|
version_number: Some(version.version_number),
|
|
created_at: Some(to_iso(document.created_at)),
|
|
})
|
|
} else {
|
|
Ok(DocumentCheckResponse {
|
|
exists: false,
|
|
document_id: None,
|
|
title: None,
|
|
filename: None,
|
|
version_id: None,
|
|
version_number: None,
|
|
created_at: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub async fn list_documents(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
params: DocumentListQuery,
|
|
) -> AppResult<Vec<DocumentResponse>> {
|
|
let DocumentListQuery {
|
|
folder_id,
|
|
include_descendants,
|
|
query,
|
|
tags,
|
|
correspondents,
|
|
status,
|
|
sort,
|
|
dir,
|
|
} = 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(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<HashSet<Uuid>> = None;
|
|
let mut quickwit_order: Option<Vec<Uuid>> = None;
|
|
|
|
if let Some(query_str) = search_text.as_ref() {
|
|
debug!(query = %query_str, "performing quickwit document search");
|
|
let endpoint = self
|
|
.state
|
|
.config
|
|
.quickwit_endpoint
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?;
|
|
let tenant = self.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| {
|
|
error!(error = ?err, "quickwit search failed");
|
|
AppError::internal("quickwit search failed")
|
|
})?;
|
|
|
|
if ids.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
quickwit_order = Some(ids.clone());
|
|
let set: HashSet<Uuid> = ids.into_iter().collect();
|
|
filter_ids = intersect_option_sets(filter_ids, set);
|
|
}
|
|
|
|
if let Some(tags_param) = tags_param.as_ref() {
|
|
if tags_param.trim().eq_ignore_ascii_case("none") {
|
|
let docs_without_tags: Vec<Uuid> = documents::table
|
|
.filter(documents::tenant_id.eq(tenant_id))
|
|
.filter(documents::deleted_at.is_null())
|
|
.filter(not(exists(
|
|
document_tags::table.filter(document_tags::document_id.eq(documents::id)),
|
|
)))
|
|
.select(documents::id)
|
|
.load(conn)?;
|
|
|
|
let docs_set: HashSet<Uuid> = docs_without_tags.into_iter().collect();
|
|
|
|
if docs_set.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
filter_ids = intersect_option_sets(filter_ids, docs_set);
|
|
} else {
|
|
let tag_ids: Result<Vec<Uuid>, _> = tags_param
|
|
.split(',')
|
|
.map(|s| Uuid::parse_str(s.trim()))
|
|
.collect();
|
|
|
|
if let Ok(ids) = tag_ids {
|
|
if !ids.is_empty() {
|
|
let matching_doc_ids = load_linked_doc_ids(conn, &ids, |conn, tag_id| {
|
|
let docs_for_tag: Vec<Uuid> = document_tags::table
|
|
.filter(document_tags::tag_id.eq(tag_id))
|
|
.select(document_tags::document_id)
|
|
.load(conn)
|
|
.map_err(AppError::from)?;
|
|
Ok(docs_for_tag.into_iter().collect())
|
|
})?;
|
|
|
|
if matching_doc_ids.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
filter_ids = intersect_option_sets(filter_ids, matching_doc_ids);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(correspondents_param) = correspondents_param.as_ref() {
|
|
let correspondent_ids: Result<Vec<Uuid>, _> = correspondents_param
|
|
.split(',')
|
|
.map(|s| Uuid::parse_str(s.trim()))
|
|
.collect();
|
|
|
|
if let Ok(ids) = correspondent_ids {
|
|
if !ids.is_empty() {
|
|
let matching_doc_ids =
|
|
load_linked_doc_ids(conn, &ids, |conn, correspondent_id| {
|
|
let docs_for_correspondent: Vec<Uuid> = document_correspondents::table
|
|
.filter(
|
|
document_correspondents::correspondent_id.eq(correspondent_id),
|
|
)
|
|
.select(document_correspondents::document_id)
|
|
.load(conn)
|
|
.map_err(AppError::from)?;
|
|
Ok(docs_for_correspondent.into_iter().collect())
|
|
})?;
|
|
|
|
if matching_doc_ids.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
filter_ids = intersect_option_sets(filter_ids, matching_doc_ids);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(filter_ids) = filter_ids {
|
|
docs_query = docs_query.filter(documents::id.eq_any(filter_ids));
|
|
}
|
|
|
|
let (primary_sql, secondary_sql) = ordering_clauses(sort, dir);
|
|
docs_query = docs_query.order(sql::<Text>(primary_sql));
|
|
if let Some(second) = secondary_sql {
|
|
docs_query = docs_query.then_order_by(sql::<Text>(second));
|
|
}
|
|
|
|
let docs: Vec<Document> = docs_query.load(conn)?;
|
|
let mut responses = self.hydrate_documents(conn, tenant_id, user_id, docs)?;
|
|
|
|
if let Some(order) = quickwit_order {
|
|
let order_map: HashMap<Uuid, usize> = order
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(idx, id)| (id, idx))
|
|
.collect();
|
|
responses.sort_by_key(|doc| order_map.get(&doc.id).copied().unwrap_or(usize::MAX));
|
|
}
|
|
|
|
Ok(responses)
|
|
}
|
|
|
|
pub fn hydrate_documents(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
docs: Vec<Document>,
|
|
) -> AppResult<Vec<DocumentResponse>> {
|
|
if docs.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
|
let mut relations = load_tags_and_correspondents(conn, &doc_ids)?;
|
|
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(doc_ids.len());
|
|
let mut version_ids: Vec<Uuid> = Vec::with_capacity(doc_ids.len());
|
|
for doc in &docs {
|
|
doc_to_version.insert(doc.id, doc.current_version_id);
|
|
version_ids.push(doc.current_version_id);
|
|
}
|
|
|
|
version_ids.sort();
|
|
version_ids.dedup();
|
|
|
|
let versions: Vec<DocumentVersion> = document_versions::table
|
|
.filter(document_versions::id.eq_any(&version_ids))
|
|
.filter(document_versions::tenant_id.eq(tenant_id))
|
|
.load(conn)?;
|
|
|
|
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
|
|
for version in versions {
|
|
version_map.insert(version.id, version);
|
|
}
|
|
|
|
let assets: Vec<DocumentAsset> = document_assets::table
|
|
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
|
.order((
|
|
document_assets::document_version_id.asc(),
|
|
document_assets::created_at.asc(),
|
|
))
|
|
.load(conn)?;
|
|
|
|
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
|
for asset in assets {
|
|
let version_id = asset.document_version_id;
|
|
let response = self.asset_response(asset, tenant_id, user_id)?;
|
|
assets_by_version
|
|
.entry(version_id)
|
|
.or_default()
|
|
.push(response);
|
|
}
|
|
|
|
docs.into_iter()
|
|
.map(|doc| {
|
|
let (tags, correspondents) = relations
|
|
.remove(&doc.id)
|
|
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
|
let current_version = doc_to_version
|
|
.get(&doc.id)
|
|
.and_then(|version_id| version_map.remove(version_id))
|
|
.map(|version| -> AppResult<_> {
|
|
let assets = assets_by_version.remove(&version.id).unwrap_or_default();
|
|
let download = build_download_link(self.state, &doc, version.id, user_id)?;
|
|
Ok((to_version_response(version), assets, download))
|
|
})
|
|
.transpose()?;
|
|
self.to_document_response(user_id, doc, tags, correspondents, current_version)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub async fn upload_document(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
request: DocumentUploadRequest,
|
|
) -> AppResult<DocumentUploadOutcome> {
|
|
let DocumentUploadRequest {
|
|
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 {
|
|
ensure_folder_exists_on_conn(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);
|
|
|
|
if let Some(reused) = self
|
|
.try_reuse_existing_document(
|
|
conn,
|
|
tenant_id,
|
|
user_id,
|
|
&checksum_hex,
|
|
issued_at_override,
|
|
skip_if_existing,
|
|
&tag_ids,
|
|
&correspondents,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(DocumentUploadOutcome::Reused(reused));
|
|
}
|
|
|
|
let content_disposition = inline_content_disposition(&stored_filename);
|
|
|
|
let storage = self.state.storage_for_tenant(tenant_id)?;
|
|
|
|
storage
|
|
.put_object(
|
|
&s3_key,
|
|
bytes.clone(),
|
|
content_type.clone(),
|
|
content_disposition.clone(),
|
|
)
|
|
.await
|
|
.storage_context("failed to store document")?;
|
|
|
|
let metadata_value = if metadata.is_null() {
|
|
Value::Object(Default::default())
|
|
} else {
|
|
metadata
|
|
};
|
|
|
|
let (document, version) = match 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,
|
|
metadata: metadata_value.clone(),
|
|
issued_at: issued_at_override,
|
|
title: derived_title.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))
|
|
}) {
|
|
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 = {
|
|
assign_tags_to_document(conn, tenant_id, &document, &tag_ids, Some(user_id))?;
|
|
|
|
if !correspondents.is_empty() {
|
|
let raw_ids: Vec<Uuid> = correspondents
|
|
.iter()
|
|
.map(|assignment| assignment.correspondent_id)
|
|
.collect();
|
|
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
|
|
insert_document_correspondents(
|
|
conn,
|
|
tenant_id,
|
|
document.id,
|
|
user_id,
|
|
&correspondent_ids,
|
|
)?;
|
|
}
|
|
|
|
let tags_and_correspondents = load_tags_and_correspondents(conn, &[doc_id])?;
|
|
let (tags, correspondents) = tags_and_correspondents
|
|
.get(&doc_id)
|
|
.cloned()
|
|
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
|
|
|
let download = build_download_link(self.state, &document, version.id, user_id)?;
|
|
|
|
DocumentDetailResponse {
|
|
document: self.to_document_response(
|
|
user_id,
|
|
document,
|
|
tags,
|
|
correspondents,
|
|
Some((to_version_response(version.clone()), Vec::new(), download)),
|
|
)?,
|
|
}
|
|
};
|
|
|
|
if let Err(err) = enqueue_job(
|
|
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");
|
|
}
|
|
|
|
Ok(DocumentUploadOutcome::Created(detail))
|
|
}
|
|
|
|
pub fn request_document_assets(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
document_id: Uuid,
|
|
force: bool,
|
|
) -> AppResult<()> {
|
|
let document = load_active_document(conn, tenant_id, document_id)?;
|
|
|
|
enqueue_job(
|
|
conn,
|
|
tenant_id,
|
|
JOB_ANALYZE_DOCUMENT,
|
|
json!({
|
|
"document_id": document_id,
|
|
"document_version_id": document.current_version_id,
|
|
"force": force,
|
|
}),
|
|
None,
|
|
)
|
|
.map_err(|err| {
|
|
error!(error = ?err, "failed to enqueue analyze job");
|
|
AppError::internal("failed to enqueue analyze job")
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn reanalyze_documents(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
document_ids: &mut Vec<Uuid>,
|
|
force: bool,
|
|
) -> AppResult<usize> {
|
|
validate_bulk_ids(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(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(
|
|
conn,
|
|
tenant_id,
|
|
JOB_ANALYZE_DOCUMENT,
|
|
json!({
|
|
"document_id": document_id,
|
|
"document_version_id": version_id,
|
|
"force": force,
|
|
}),
|
|
None,
|
|
)
|
|
.map_err(|err| {
|
|
error!(error = ?err, "failed to enqueue analyze job");
|
|
AppError::internal("failed to enqueue analyze job")
|
|
})?;
|
|
queued += 1;
|
|
}
|
|
|
|
Ok(queued)
|
|
}
|
|
|
|
pub async fn list_document_assets(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
document_id: Uuid,
|
|
) -> AppResult<Vec<DocumentAssetResponse>> {
|
|
let document = load_active_document(conn, tenant_id, document_id)?;
|
|
|
|
let version_id = document.current_version_id;
|
|
let assets = document_assets::table
|
|
.filter(document_assets::document_version_id.eq(version_id))
|
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
|
.order(document_assets::created_at.asc())
|
|
.load::<DocumentAsset>(conn)?;
|
|
|
|
let mut responses = Vec::with_capacity(assets.len());
|
|
for asset in assets {
|
|
responses.push(self.asset_response(asset, tenant_id, user_id)?);
|
|
}
|
|
|
|
Ok(responses)
|
|
}
|
|
|
|
pub async fn get_document_asset(
|
|
&self,
|
|
mut conn: PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
asset_id: Uuid,
|
|
) -> AppResult<DocumentAssetDetailResponse> {
|
|
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()),
|
|
};
|
|
|
|
drop(conn);
|
|
|
|
let download = self.asset_download_link(asset.id, tenant_id, user_id)?;
|
|
|
|
Ok(to_asset_detail_response(asset, Some(download)))
|
|
}
|
|
|
|
pub async fn get_document_download_link(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
document_id: Uuid,
|
|
) -> AppResult<DownloadLink> {
|
|
let document = load_active_document(conn, tenant_id, document_id)?;
|
|
let version: DocumentVersion = document_versions::table
|
|
.find(document.current_version_id)
|
|
.filter(document_versions::tenant_id.eq(tenant_id))
|
|
.first(conn)?;
|
|
|
|
build_download_link(self.state, &document, version.id, user_id)
|
|
}
|
|
|
|
pub async fn get_document_version_download_link(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
document_id: Uuid,
|
|
version_id: Uuid,
|
|
) -> AppResult<DownloadLink> {
|
|
let document = load_active_document(conn, tenant_id, document_id)?;
|
|
|
|
let version: Option<DocumentVersion> = document_versions::table
|
|
.find(version_id)
|
|
.filter(document_versions::document_id.eq(document_id))
|
|
.filter(document_versions::tenant_id.eq(tenant_id))
|
|
.first(conn)
|
|
.optional()?;
|
|
|
|
let Some(version) = version else {
|
|
return Err(AppError::not_found());
|
|
};
|
|
|
|
build_download_link(self.state, &document, version.id, user_id)
|
|
}
|
|
|
|
pub async fn get_asset_download_link(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
asset_id: Uuid,
|
|
) -> AppResult<DownloadLink> {
|
|
let asset: Option<DocumentAsset> = document_assets::table
|
|
.find(asset_id)
|
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
|
.first(conn)
|
|
.optional()?;
|
|
|
|
let Some(asset) = asset else {
|
|
return Err(AppError::not_found());
|
|
};
|
|
|
|
self.asset_download_link(asset.id, tenant_id, user_id)
|
|
}
|
|
|
|
pub fn list_document_versions(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
document_id: Uuid,
|
|
) -> AppResult<Vec<DocumentVersionResponse>> {
|
|
load_active_document(conn, tenant_id, document_id)?;
|
|
|
|
let versions: Vec<DocumentVersion> = 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(conn)?;
|
|
|
|
Ok(versions.into_iter().map(to_version_response).collect())
|
|
}
|
|
|
|
pub async fn get_document_version(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
document_id: Uuid,
|
|
version_id: Uuid,
|
|
) -> AppResult<DocumentVersionDetailResponse> {
|
|
let document = load_active_document(conn, tenant_id, document_id)?;
|
|
|
|
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(conn)?;
|
|
|
|
let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?;
|
|
let download = build_download_link(self.state, &document, version.id, user_id)?;
|
|
let version_core = to_version_response(version);
|
|
|
|
Ok(DocumentVersionDetailResponse {
|
|
version: version_core,
|
|
assets,
|
|
download,
|
|
})
|
|
}
|
|
|
|
pub fn trash_document(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
document_id: Uuid,
|
|
) -> AppResult<()> {
|
|
conn.transaction::<_, AppError, _>(|conn| {
|
|
let document: Document = documents::table
|
|
.find(document_id)
|
|
.filter(documents::tenant_id.eq(tenant_id))
|
|
.for_update()
|
|
.first(conn)
|
|
.optional()?
|
|
.ok_or_else(AppError::not_found)?;
|
|
|
|
if document.deleted_at.is_some() {
|
|
return Err(AppError::conflict("document already trashed"));
|
|
}
|
|
|
|
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(conn)?;
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
pub fn delete_document(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
document_id: Uuid,
|
|
) -> AppResult<()> {
|
|
conn.transaction::<_, AppError, _>(|conn| {
|
|
let document = documents::table
|
|
.find(document_id)
|
|
.filter(documents::tenant_id.eq(tenant_id))
|
|
.for_update()
|
|
.first::<Document>(conn)
|
|
.optional()?
|
|
.ok_or_else(AppError::not_found)?;
|
|
|
|
if document.deleted_at.is_none() {
|
|
return Err(AppError::conflict(
|
|
"document must be trashed before permanent deletion",
|
|
));
|
|
}
|
|
|
|
let payload = json!({ "document_id": document_id });
|
|
match enqueue_job(conn, tenant_id, JOB_PURGE_DOCUMENT, payload, None) {
|
|
Ok(_) => Ok(()),
|
|
Err(JobQueueError::Database(diesel::result::Error::DatabaseError(
|
|
DatabaseErrorKind::UniqueViolation,
|
|
_,
|
|
))) => Ok(()),
|
|
Err(JobQueueError::Database(err)) => Err(AppError::from(err)),
|
|
}
|
|
})
|
|
}
|
|
|
|
pub async fn update_document(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
document_id: Uuid,
|
|
payload: Value,
|
|
) -> AppResult<DocumentDetailResponse> {
|
|
let mut 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 payload_obj = payload
|
|
.as_object()
|
|
.ok_or_else(|| AppError::bad_request("request body must be a JSON object"))?;
|
|
let DocumentUpdatePlan {
|
|
mut changeset,
|
|
title_changed,
|
|
} = Self::build_document_update_plan(&document, payload_obj)?;
|
|
|
|
let now = Utc::now().naive_utc();
|
|
changeset.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(&changeset);
|
|
|
|
match update_result.execute(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(conn)?;
|
|
|
|
let current_version: DocumentVersion = document_versions::table
|
|
.find(document.current_version_id)
|
|
.first(conn)?;
|
|
|
|
if title_changed {
|
|
if let Err(err) = enqueue_job(
|
|
conn,
|
|
tenant_id,
|
|
JOB_ANALYZE_DOCUMENT,
|
|
json!({
|
|
"document_id": document.id,
|
|
"document_version_id": current_version.id,
|
|
"force": false,
|
|
}),
|
|
None,
|
|
) {
|
|
warn!(
|
|
document_id = %document.id,
|
|
version_id = %current_version.id,
|
|
error = %err,
|
|
"failed to enqueue analyze job after title change"
|
|
);
|
|
}
|
|
}
|
|
|
|
let tags_and_correspondents = load_tags_and_correspondents(conn, &[document_id])?;
|
|
let version_id = current_version.id;
|
|
let assets = self.load_asset_responses(conn, tenant_id, version_id, user_id)?;
|
|
let version_response = to_version_response(current_version);
|
|
let download = build_download_link(self.state, &document, version_id, user_id)?;
|
|
let (tags, correspondents) = tags_and_correspondents
|
|
.get(&document_id)
|
|
.cloned()
|
|
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
|
|
|
let document_response = self.to_document_response(
|
|
user_id,
|
|
document,
|
|
tags,
|
|
correspondents,
|
|
Some((version_response, assets, download)),
|
|
)?;
|
|
|
|
Ok(DocumentDetailResponse {
|
|
document: document_response,
|
|
})
|
|
}
|
|
|
|
pub fn restore_document(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
document_id: Uuid,
|
|
folder_id: Option<Uuid>,
|
|
) -> AppResult<()> {
|
|
let mut document: Document = documents::table
|
|
.find(document_id)
|
|
.filter(documents::tenant_id.eq(tenant_id))
|
|
.first(conn)?;
|
|
|
|
if document.deleted_at.is_none() {
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(folder_id) = folder_id {
|
|
ensure_folder_exists_on_conn(conn, tenant_id, folder_id)?;
|
|
document.folder_id = Some(folder_id);
|
|
} else if let Some(existing_folder) = document.folder_id {
|
|
let exists: bool = diesel::select(exists(
|
|
folders::table
|
|
.filter(folders::id.eq(existing_folder))
|
|
.filter(folders::tenant_id.eq(tenant_id)),
|
|
))
|
|
.get_result(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::<Option<NaiveDateTime>>(None),
|
|
documents::folder_id.eq(document.folder_id),
|
|
documents::updated_at.eq(now),
|
|
))
|
|
.execute(conn)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn move_document(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
document_id: Uuid,
|
|
folder_id: Option<Uuid>,
|
|
) -> AppResult<()> {
|
|
if let Some(folder_id) = folder_id {
|
|
ensure_folder_exists_on_conn(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(folder_id),
|
|
documents::updated_at.eq(now),
|
|
))
|
|
.execute(conn)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn bulk_move_documents(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
mut document_ids: Vec<Uuid>,
|
|
folder_id: Option<Uuid>,
|
|
) -> AppResult<usize> {
|
|
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(conn, tenant_id, target_folder)?;
|
|
}
|
|
|
|
let existing: Vec<(Uuid, Option<NaiveDateTime>)> = 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 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(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));
|
|
}
|
|
};
|
|
|
|
Ok(updated)
|
|
}
|
|
|
|
fn to_document_response(
|
|
&self,
|
|
_user_id: Uuid,
|
|
doc: Document,
|
|
tags: Vec<Tag>,
|
|
correspondents: Vec<DocumentCorrespondentResponse>,
|
|
current_version: Option<(
|
|
DocumentVersionResponse,
|
|
Vec<DocumentAssetResponse>,
|
|
DownloadLink,
|
|
)>,
|
|
) -> AppResult<DocumentResponse> {
|
|
let current_version = match current_version {
|
|
Some((version, assets, download)) => Some(DocumentVersionDetailResponse {
|
|
version,
|
|
assets,
|
|
download,
|
|
}),
|
|
None => 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,
|
|
created_at: to_iso(doc.created_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.into_iter().map(TagResponse::from).collect(),
|
|
correspondents,
|
|
current_version,
|
|
})
|
|
}
|
|
|
|
async fn try_reuse_existing_document(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
checksum_hex: &str,
|
|
issued_at_override: Option<NaiveDateTime>,
|
|
skip_if_existing: bool,
|
|
tag_ids: &[Uuid],
|
|
correspondents: &[CorrespondentAssignmentInput],
|
|
) -> AppResult<Option<DocumentDetailResponse>> {
|
|
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)>(conn)
|
|
.optional()?;
|
|
|
|
let Some((mut document, version)) = existing else {
|
|
return Ok(None);
|
|
};
|
|
|
|
if skip_if_existing {
|
|
info!(
|
|
document_id = %document.id,
|
|
checksum = %checksum_hex,
|
|
"upload rejected because document already exists",
|
|
);
|
|
|
|
let mut message = String::from("a document with the same contents already exists");
|
|
let mut details = json!({
|
|
"conflict_document_id": document.id,
|
|
});
|
|
|
|
if let Some(deleted_at) = document.deleted_at {
|
|
message.push_str(". Note: the existing document is currently in the trash.");
|
|
if let Some(obj) = details.as_object_mut() {
|
|
obj.insert("conflict_document_in_trash".to_string(), Value::Bool(true));
|
|
obj.insert(
|
|
"conflict_document_deleted_at".to_string(),
|
|
Value::String(to_iso(deleted_at)),
|
|
);
|
|
}
|
|
}
|
|
|
|
return Err(AppError::conflict(message)
|
|
.with_code("duplicate_document")
|
|
.with_details(details));
|
|
}
|
|
|
|
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(conn)?;
|
|
document.issued_at = Some(issued_at);
|
|
}
|
|
}
|
|
|
|
assign_tags_to_document(conn, tenant_id, &document, tag_ids, Some(user_id))?;
|
|
|
|
if !correspondents.is_empty() {
|
|
let raw_ids: Vec<Uuid> = correspondents
|
|
.iter()
|
|
.map(|assignment| assignment.correspondent_id)
|
|
.collect();
|
|
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
|
|
insert_document_correspondents(
|
|
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::<NaiveDateTime>),
|
|
documents::updated_at.eq(now),
|
|
))
|
|
.execute(conn)?;
|
|
document.deleted_at = None;
|
|
document.updated_at = now;
|
|
}
|
|
|
|
let relations = load_tags_and_correspondents(conn, &[document.id])?;
|
|
let (tags, correspondents_list) = relations
|
|
.get(&document.id)
|
|
.cloned()
|
|
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
|
|
|
let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?;
|
|
let download = build_download_link(self.state, &document, version.id, user_id)?;
|
|
let version_response = to_version_response(version.clone());
|
|
|
|
info!(
|
|
document_id = %document.id,
|
|
checksum = %checksum_hex,
|
|
"upload deduplicated existing document"
|
|
);
|
|
|
|
let detail = DocumentDetailResponse {
|
|
document: self.to_document_response(
|
|
user_id,
|
|
document,
|
|
tags,
|
|
correspondents_list,
|
|
Some((version_response, assets, download)),
|
|
)?,
|
|
};
|
|
|
|
Ok(Some(detail))
|
|
}
|
|
|
|
fn asset_download_link(
|
|
&self,
|
|
asset_id: Uuid,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
) -> AppResult<DownloadLink> {
|
|
let token = self
|
|
.state
|
|
.jwt
|
|
.generate_asset_download_token(asset_id, user_id, tenant_id)
|
|
.map_err(|err| {
|
|
error!(error = ?err, "failed to issue asset download token");
|
|
AppError::internal("failed to issue asset download token")
|
|
})?;
|
|
|
|
let expires_at = Utc::now()
|
|
.checked_add_signed(ChronoDuration::minutes(
|
|
self.state.config.download_token_expiry_minutes,
|
|
))
|
|
.ok_or_else(|| AppError::internal("failed to compute download expiry"))?
|
|
.timestamp_millis();
|
|
|
|
Ok(DownloadLink {
|
|
url: format!("/api/download/{token}"),
|
|
expires_at,
|
|
})
|
|
}
|
|
|
|
fn asset_response(
|
|
&self,
|
|
asset: DocumentAsset,
|
|
tenant_id: Uuid,
|
|
user_id: Uuid,
|
|
) -> AppResult<DocumentAssetResponse> {
|
|
let download = self.asset_download_link(asset.id, tenant_id, user_id)?;
|
|
|
|
Ok(DocumentAssetResponse {
|
|
id: asset.id,
|
|
asset_type: asset.asset_type,
|
|
mime_type: asset.mime_type,
|
|
metadata: asset.metadata,
|
|
download: Some(download),
|
|
})
|
|
}
|
|
|
|
fn load_asset_responses(
|
|
&self,
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
version_id: Uuid,
|
|
user_id: Uuid,
|
|
) -> AppResult<Vec<DocumentAssetResponse>> {
|
|
let assets: Vec<DocumentAsset> = document_assets::table
|
|
.filter(document_assets::document_version_id.eq(version_id))
|
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
|
.order(document_assets::created_at.asc())
|
|
.load(conn)?;
|
|
|
|
assets
|
|
.into_iter()
|
|
.map(|asset| self.asset_response(asset, tenant_id, user_id))
|
|
.collect()
|
|
}
|
|
}
|