Skip uploads of existing documents and support checksum preflight in importer
This commit is contained in:
+48
-1
@@ -13,6 +13,7 @@ use uuid::Uuid;
|
|||||||
doc::me,
|
doc::me,
|
||||||
doc::select_tenant,
|
doc::select_tenant,
|
||||||
doc::list_documents,
|
doc::list_documents,
|
||||||
|
doc::check_document,
|
||||||
doc::upload_document,
|
doc::upload_document,
|
||||||
doc::get_document,
|
doc::get_document,
|
||||||
doc::update_document,
|
doc::update_document,
|
||||||
@@ -81,6 +82,8 @@ use uuid::Uuid;
|
|||||||
schemas::ReanalyzeResponse,
|
schemas::ReanalyzeResponse,
|
||||||
schemas::DocumentAssetRequestParams,
|
schemas::DocumentAssetRequestParams,
|
||||||
schemas::AssetObjectsQuery,
|
schemas::AssetObjectsQuery,
|
||||||
|
schemas::DocumentCheckQuery,
|
||||||
|
schemas::DocumentCheckResponse,
|
||||||
schemas::UploadDocumentForm,
|
schemas::UploadDocumentForm,
|
||||||
schemas::CreateFolderRequest,
|
schemas::CreateFolderRequest,
|
||||||
schemas::EnsureFolderPathRequest,
|
schemas::EnsureFolderPathRequest,
|
||||||
@@ -189,11 +192,24 @@ mod doc {
|
|||||||
post,
|
post,
|
||||||
path = "/api/documents",
|
path = "/api/documents",
|
||||||
request_body = UploadDocumentForm,
|
request_body = UploadDocumentForm,
|
||||||
responses((status = 201, description = "Document uploaded", body = DocumentDetailResponse)),
|
responses(
|
||||||
|
(status = 201, description = "Document created", body = DocumentDetailResponse),
|
||||||
|
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
|
||||||
|
(status = 204, description = "Upload skipped because the document already exists")
|
||||||
|
),
|
||||||
tag = "Documents"
|
tag = "Documents"
|
||||||
)]
|
)]
|
||||||
pub(super) fn upload_document() {}
|
pub(super) fn upload_document() {}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/documents/check",
|
||||||
|
params(DocumentCheckQuery),
|
||||||
|
responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)),
|
||||||
|
tag = "Documents"
|
||||||
|
)]
|
||||||
|
pub(super) fn check_document() {}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/documents/{id}",
|
path = "/api/documents/{id}",
|
||||||
@@ -785,6 +801,29 @@ pub mod schemas {
|
|||||||
pub limit: Option<i32>,
|
pub limit: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
|
||||||
|
#[into_params(parameter_in = Query)]
|
||||||
|
pub struct DocumentCheckQuery {
|
||||||
|
pub checksum: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct DocumentCheckResponse {
|
||||||
|
pub exists: bool,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub document_id: Option<Uuid>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub filename: Option<String>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub version_id: Option<Uuid>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub version_number: Option<i32>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub uploaded_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UploadDocumentForm {
|
pub struct UploadDocumentForm {
|
||||||
#[schema(value_type = String, format = Binary)]
|
#[schema(value_type = String, format = Binary)]
|
||||||
@@ -795,6 +834,14 @@ pub mod schemas {
|
|||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
|
#[schema(nullable, value_type = Vec<Uuid>)]
|
||||||
|
pub tag_ids: Option<Vec<Uuid>>,
|
||||||
|
#[schema(nullable, value_type = Vec<CorrespondentAssignment>)]
|
||||||
|
pub correspondents: Option<Vec<CorrespondentAssignment>>,
|
||||||
|
#[schema(nullable, example = "2024-01-01T00:00:00Z")]
|
||||||
|
pub issued_at: Option<String>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub skip_existing: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
+156
-36
@@ -76,6 +76,29 @@ pub struct AssetRequestQuery {
|
|||||||
pub force: bool,
|
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<Uuid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub filename: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version_id: Option<Uuid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version_number: Option<i32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub uploaded_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct TagResponse {
|
pub struct TagResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -297,11 +320,13 @@ struct UploadRequest {
|
|||||||
tag_ids: Vec<Uuid>,
|
tag_ids: Vec<Uuid>,
|
||||||
correspondents: Vec<CorrespondentAssignmentInput>,
|
correspondents: Vec<CorrespondentAssignmentInput>,
|
||||||
issued_at_override: Option<NaiveDateTime>,
|
issued_at_override: Option<NaiveDateTime>,
|
||||||
|
skip_if_existing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UploadOutcome {
|
enum UploadOutcome {
|
||||||
detail: DocumentDetailResponse,
|
Created(DocumentDetailResponse),
|
||||||
created: bool,
|
Reused(DocumentDetailResponse),
|
||||||
|
Skipped { document_id: Uuid },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ToSchema)]
|
#[derive(ToSchema)]
|
||||||
@@ -312,6 +337,16 @@ pub struct UploadDocumentForm {
|
|||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[schema(nullable, value_type = Vec<Uuid>)]
|
||||||
|
pub tag_ids: Option<Vec<Uuid>>,
|
||||||
|
#[schema(nullable, value_type = Vec<CorrespondentAssignmentInput>)]
|
||||||
|
pub correspondents: Option<Vec<CorrespondentAssignmentInput>>,
|
||||||
|
#[schema(nullable, example = "2024-01-01T00:00:00Z")]
|
||||||
|
pub issued_at: Option<String>,
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub skip_existing: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -586,6 +621,59 @@ pub async fn list_documents(
|
|||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn check_document(
|
||||||
|
Query(query): Query<DocumentCheckQuery>,
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<Json<DocumentCheckResponse>> {
|
||||||
|
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(
|
pub async fn get_document(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(document_id): Path<Uuid>,
|
Path(document_id): Path<Uuid>,
|
||||||
@@ -634,7 +722,7 @@ pub async fn upload_document(
|
|||||||
tenant_id, user_id, ..
|
tenant_id, user_id, ..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> AppResult<(StatusCode, Json<DocumentDetailResponse>)> {
|
) -> AppResult<impl IntoResponse> {
|
||||||
let mut file_bytes: Option<Vec<u8>> = None;
|
let mut file_bytes: Option<Vec<u8>> = None;
|
||||||
let mut original_name: Option<String> = None;
|
let mut original_name: Option<String> = None;
|
||||||
let mut content_type: Option<String> = None;
|
let mut content_type: Option<String> = None;
|
||||||
@@ -643,6 +731,7 @@ pub async fn upload_document(
|
|||||||
let mut tag_ids: Vec<Uuid> = Vec::new();
|
let mut tag_ids: Vec<Uuid> = Vec::new();
|
||||||
let mut correspondents: Vec<CorrespondentAssignmentInput> = Vec::new();
|
let mut correspondents: Vec<CorrespondentAssignmentInput> = Vec::new();
|
||||||
let mut issued_at_override: Option<NaiveDateTime> = None;
|
let mut issued_at_override: Option<NaiveDateTime> = None;
|
||||||
|
let mut skip_if_existing = false;
|
||||||
let mut title_override: Option<String> = None;
|
let mut title_override: Option<String> = None;
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await.map_err(|err| {
|
while let Some(field) = multipart.next_field().await.map_err(|err| {
|
||||||
@@ -751,6 +840,17 @@ pub async fn upload_document(
|
|||||||
issued_at_override = Some(parsed.naive_utc());
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -780,31 +880,45 @@ pub async fn upload_document(
|
|||||||
tag_ids,
|
tag_ids,
|
||||||
correspondents,
|
correspondents,
|
||||||
issued_at_override,
|
issued_at_override,
|
||||||
|
skip_if_existing,
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome = match process_upload(&state, request, tenant_id, user_id).await {
|
let outcome = match process_upload(&state, request, tenant_id, user_id).await {
|
||||||
Ok(outcome) => {
|
Ok(outcome) => outcome,
|
||||||
info!(
|
|
||||||
document_id = %outcome.detail.document.id,
|
|
||||||
original_name = %outcome.detail.document.original_name,
|
|
||||||
created = outcome.created,
|
|
||||||
reused_existing = !outcome.created,
|
|
||||||
"document upload succeeded"
|
|
||||||
);
|
|
||||||
outcome
|
|
||||||
}
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!(error = ?err, original_name = %original_name_for_log, "document upload failed");
|
error!(error = ?err, original_name = %original_name_for_log, "document upload failed");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let status = if outcome.created {
|
|
||||||
StatusCode::CREATED
|
let response = match outcome {
|
||||||
} else {
|
UploadOutcome::Created(detail) => {
|
||||||
StatusCode::OK
|
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((status, Json(outcome.detail)))
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn request_document_assets(
|
pub async fn request_document_assets(
|
||||||
@@ -1692,6 +1806,7 @@ async fn process_upload(
|
|||||||
tag_ids,
|
tag_ids,
|
||||||
correspondents,
|
correspondents,
|
||||||
issued_at_override,
|
issued_at_override,
|
||||||
|
skip_if_existing,
|
||||||
} = request;
|
} = request;
|
||||||
|
|
||||||
if let Some(folder) = folder_id {
|
if let Some(folder) = folder_id {
|
||||||
@@ -1729,6 +1844,17 @@ async fn process_upload(
|
|||||||
.optional()?;
|
.optional()?;
|
||||||
|
|
||||||
if let Some((mut document, version)) = existing {
|
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 let Some(issued_at) = issued_at_override {
|
||||||
if document.issued_at != Some(issued_at) {
|
if document.issued_at != Some(issued_at) {
|
||||||
diesel::update(
|
diesel::update(
|
||||||
@@ -1782,19 +1908,16 @@ async fn process_upload(
|
|||||||
"upload deduplicated existing document"
|
"upload deduplicated existing document"
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(UploadOutcome {
|
return Ok(UploadOutcome::Reused(DocumentDetailResponse {
|
||||||
detail: DocumentDetailResponse {
|
document: to_document_response(
|
||||||
document: to_document_response(
|
state,
|
||||||
state,
|
user_id,
|
||||||
user_id,
|
document,
|
||||||
document,
|
tags,
|
||||||
tags,
|
correspondents,
|
||||||
correspondents,
|
Some((version_response, assets)),
|
||||||
Some((version_response, assets)),
|
)?,
|
||||||
)?,
|
}));
|
||||||
},
|
|
||||||
created: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1917,10 +2040,7 @@ async fn process_upload(
|
|||||||
warn!(document_id = %doc_id, "failed to enqueue analyze job due to pool error");
|
warn!(document_id = %doc_id, "failed to enqueue analyze job due to pool error");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(UploadOutcome {
|
Ok(UploadOutcome::Created(detail))
|
||||||
detail,
|
|
||||||
created: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assign_tags_internal(
|
fn assign_tags_internal(
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/me", get(auth::me));
|
.route("/me", get(auth::me));
|
||||||
|
|
||||||
let documents_routes = Router::new()
|
let documents_routes = Router::new()
|
||||||
|
.route("/check", get(documents::check_document))
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/",
|
||||||
get(documents::list_documents).post(documents::upload_document),
|
get(documents::list_documents).post(documents::upload_document),
|
||||||
|
|||||||
+2
-1
@@ -17,7 +17,8 @@ Health
|
|||||||
Documents
|
Documents
|
||||||
---------
|
---------
|
||||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
||||||
- POST /api/documents - Upload a document via multipart form-data (`file`, optional `title`, `folder_id`, and JSON `metadata`). When `title` is supplied, the stored filename is set to `<title><original_extension>` automatically.
|
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
|
||||||
|
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document.
|
||||||
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
||||||
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
|
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
|
||||||
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
|
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
|
||||||
|
|||||||
Reference in New Issue
Block a user