Files
papercrate/backend/src/routes/documents.rs
T
nils 3c9a9fc060
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 13s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 12s
Initial commit
2025-12-17 13:37:31 +01:00

1153 lines
39 KiB
Rust

use std::{collections::HashSet, time::Duration};
use axum::body::Body;
use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::exists;
use diesel::{prelude::*, select};
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::Value;
use tracing::{error, info};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::auth::{ensure_active_tenant_with_conn, jwt::DownloadSubject, TenantScopedConn};
use crate::documents::asset::{
asset_disposition, DocumentAssetDetailResponse, DocumentAssetResponse,
DocumentVersionDetailResponse, DocumentVersionResponse, DownloadLink,
};
#[allow(unused_imports)]
use crate::error::ApiErrorResponse;
use crate::error::{AppError, AppResult};
use crate::http::responders::{accepted_json, created_json, no_content, ok_json, JsonResponse};
use crate::models::{Document, DocumentAsset, DocumentVersion};
use crate::schema::{
document_assets, document_versions, documents, user_sessions::dsl as session_dsl,
};
use crate::services::correspondents::{
AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse,
BulkCorrespondentsRequest, CorrespondentAssignmentInput, CorrespondentsService,
};
use crate::services::documents::{
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate,
DocumentResponse, DocumentUploadOutcome, DocumentUploadRequest, DocumentsService,
};
use crate::services::tags::{
AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse, TagsService,
};
use crate::state::AppState;
use crate::storage::TenantStorage;
use crate::utils::{error::StorageResultExt, http::inline_content_disposition};
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
#[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct AssetRequestQuery {
#[serde(default)]
#[schema(default = false)]
pub force: bool,
}
#[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct DocumentCheckQuery {
pub checksum: String,
}
#[derive(ToSchema)]
pub struct UploadDocumentForm {
#[schema(value_type = String, format = Binary)]
pub file: String,
#[schema(nullable)]
pub folder_id: Option<Uuid>,
#[schema(nullable, value_type = Object)]
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, default = true)]
pub skip_existing: Option<bool>,
}
#[derive(Deserialize, ToSchema)]
pub struct MoveDocumentRequest {
pub folder_id: Option<Uuid>,
}
#[derive(Deserialize, ToSchema)]
pub struct RestoreDocumentRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_id: Option<Uuid>,
}
#[utoipa::path(
get,
path = "/api/documents",
params(DocumentListQuery),
responses((status = 200, description = "List documents", body = [DocumentResponse])),
tag = "Documents"
)]
pub async fn list_documents(
State(state): State<AppState>,
Query(params): Query<DocumentListQuery>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<DocumentResponse>>> {
let service = DocumentsService::new(&state);
let documents = service
.list_documents(&mut conn, tenant_id, user_id, params)
.await?;
ok_json(documents)
}
#[utoipa::path(
get,
path = "/api/documents/check",
params(DocumentCheckQuery),
responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)),
tag = "Documents"
)]
pub async fn check_document(
State(state): State<AppState>,
Query(query): Query<DocumentCheckQuery>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<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 service = DocumentsService::new(&state);
let response = service.check_document(&mut conn, tenant_id, &checksum)?;
ok_json(response)
}
#[utoipa::path(
get,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document detail", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub async fn get_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<DocumentDetailResponse>> {
let service = DocumentsService::new(&state);
let detail = service
.get_document_detail(&mut conn, tenant_id, user_id, document_id)
.await?;
ok_json(detail)
}
#[utoipa::path(
post,
path = "/api/documents",
request_body = UploadDocumentForm,
responses(
(status = 201, description = "Document created", body = DocumentDetailResponse),
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
(
status = 409,
description = "Document with identical contents already exists",
body = ApiErrorResponse
)
),
tag = "Documents"
)]
pub async fn upload_document(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
mut multipart: Multipart,
) -> AppResult<JsonResponse<DocumentDetailResponse>> {
let tenant_id = tenant_id;
let user_id = user_id;
let mut file_bytes: Option<Vec<u8>> = None;
let mut original_name: Option<String> = None;
let mut mime_type: Option<String> = None;
let mut folder_id: Option<Uuid> = None;
let mut metadata: Value = Value::Object(Default::default());
let mut tag_ids: Vec<Uuid> = Vec::new();
let mut correspondents: Vec<CorrespondentAssignmentInput> = Vec::new();
let mut issued_at_override: Option<NaiveDateTime> = None;
let mut skip_if_existing = true;
let mut title_override: Option<String> = 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();
mime_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<String> = 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 = DocumentUploadRequest {
bytes: file_bytes,
original_name,
mime_type,
folder_id,
metadata,
title_override,
tag_ids,
correspondents,
issued_at_override,
skip_if_existing,
};
let service = DocumentsService::new(&state);
let outcome = match service
.upload_document(&mut conn, tenant_id, user_id, request)
.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 {
DocumentUploadOutcome::Created(detail) => {
info!(
document_id = %detail.document.id,
original_name = %detail.document.original_name,
created = true,
reused_existing = false,
"document upload succeeded",
);
created_json(detail)?
}
DocumentUploadOutcome::Reused(detail) => {
info!(
document_id = %detail.document.id,
original_name = %detail.document.original_name,
created = false,
reused_existing = true,
"document upload succeeded",
);
ok_json(detail)?
}
};
Ok(response)
}
#[utoipa::path(
post,
path = "/api/documents/{id}/assets",
params(("id" = Uuid, Path, description = "Document ID"), AssetRequestQuery),
responses((status = 202, description = "Asset generation requested")),
tag = "Assets"
)]
pub async fn request_document_assets(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
Query(query): Query<AssetRequestQuery>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let service = DocumentsService::new(&state);
service.request_document_assets(&mut conn, tenant_id, document_id, query.force)?;
Ok(StatusCode::ACCEPTED)
}
#[utoipa::path(
post,
path = "/api/documents/bulk/reanalyze",
request_body = BulkReanalyzeSelectionRequest,
responses((status = 200, description = "Reanalyze queued", body = BulkReanalyzeResponse)),
tag = "Documents"
)]
pub async fn reanalyze_selected_documents(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkReanalyzeSelectionRequest>,
) -> AppResult<JsonResponse<BulkReanalyzeResponse>> {
let BulkReanalyzeSelectionRequest {
mut document_ids,
force,
} = payload;
let service = DocumentsService::new(&state);
let queued = service.reanalyze_documents(&mut conn, tenant_id, &mut document_ids, force)?;
accepted_json(BulkReanalyzeResponse { queued })
}
#[utoipa::path(
get,
path = "/api/documents/{id}/assets",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document assets", body = [DocumentAssetResponse])),
tag = "Assets"
)]
pub async fn list_document_assets(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<DocumentAssetResponse>>> {
let service = DocumentsService::new(&state);
let assets = service
.list_document_assets(&mut conn, tenant_id, user_id, document_id)
.await?;
ok_json(assets)
}
#[utoipa::path(
get,
path = "/api/assets/{asset_id}",
params(("asset_id" = Uuid, Path, description = "Asset ID")),
responses((status = 200, description = "Asset detail", body = DocumentAssetDetailResponse)),
tag = "Assets"
)]
pub async fn get_document_asset(
State(state): State<AppState>,
Path(asset_id): Path<Uuid>,
TenantScopedConn {
conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<DocumentAssetDetailResponse>> {
let service = DocumentsService::new(&state);
let detail = service
.get_document_asset(conn, tenant_id, user_id, asset_id)
.await?;
ok_json(detail)
}
#[utoipa::path(
post,
path = "/api/documents/{id}/download",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Download link for current version", body = DownloadLink)),
tag = "Documents"
)]
pub async fn refresh_document_download(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<DownloadLink>> {
let service = DocumentsService::new(&state);
let link = service
.get_document_download_link(&mut conn, tenant_id, user_id, document_id)
.await?;
ok_json(link)
}
#[utoipa::path(
post,
path = "/api/documents/{id}/versions/{version_id}/download",
params(
("id" = Uuid, Path, description = "Document ID"),
("version_id" = Uuid, Path, description = "Version ID")
),
responses((status = 200, description = "Download link for version", body = DownloadLink)),
tag = "Documents"
)]
pub async fn refresh_document_version_download(
State(state): State<AppState>,
Path((document_id, version_id)): Path<(Uuid, Uuid)>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<DownloadLink>> {
let service = DocumentsService::new(&state);
let link = service
.get_document_version_download_link(&mut conn, tenant_id, user_id, document_id, version_id)
.await?;
ok_json(link)
}
#[utoipa::path(
post,
path = "/api/assets/{asset_id}/download",
params(("asset_id" = Uuid, Path, description = "Asset ID")),
responses((status = 200, description = "Asset download link", body = DownloadLink)),
tag = "Assets"
)]
pub async fn refresh_asset_download(
State(state): State<AppState>,
Path(asset_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<DownloadLink>> {
let service = DocumentsService::new(&state);
let link = service
.get_asset_download_link(&mut conn, tenant_id, user_id, asset_id)
.await?;
ok_json(link)
}
#[utoipa::path(
get,
path = "/api/documents/{id}/versions",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
tag = "Documents"
)]
pub async fn list_document_versions(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<DocumentVersionResponse>>> {
let service = DocumentsService::new(&state);
let versions = service.list_document_versions(&mut conn, tenant_id, document_id)?;
ok_json(versions)
}
#[utoipa::path(
get,
path = "/api/documents/{id}/versions/{version_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("version_id" = Uuid, Path, description = "Version ID"),
),
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
tag = "Documents"
)]
pub async fn get_document_version(
State(state): State<AppState>,
Path((document_id, version_id)): Path<(Uuid, Uuid)>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<DocumentVersionDetailResponse>> {
let service = DocumentsService::new(&state);
let detail = service
.get_document_version(&mut conn, tenant_id, user_id, document_id, version_id)
.await?;
ok_json(detail)
}
#[utoipa::path(
get,
path = "/api/download/{token}",
params(("token" = String, Path, description = "Download token")),
responses((status = 200, description = "Proxied download stream or redirect")),
tag = "Documents"
)]
pub async fn download_with_token(
State(state): State<AppState>,
Path(token): Path<String>,
headers: HeaderMap,
) -> AppResult<Response> {
let claims = state
.jwt
.verify_download_token(&token)
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_for_tenant(claims.tenant_id)?;
ensure_active_tenant_with_conn(&mut conn, claims.tenant_id)?;
let now = Utc::now().naive_utc();
let has_active_refresh: bool = select(exists(
session_dsl::user_sessions
.filter(session_dsl::user_id.eq(claims.user_id))
.filter(session_dsl::tenant_id.eq(claims.tenant_id))
.filter(session_dsl::revoked_at.is_null())
.filter(session_dsl::expires_at.gt(now)),
))
.get_result(&mut conn)?;
if !has_active_refresh {
return Err(AppError::unauthorized());
}
match &claims.subject {
DownloadSubject::Document { doc_id, version_id } => {
let doc_id = *doc_id;
let version_id = *version_id;
let doc: Document = documents::table
.find(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(version_id)
.filter(document_versions::document_id.eq(doc_id))
.first(&mut conn)?;
drop(conn);
let storage = state.storage_for_tenant(claims.tenant_id)?;
let disposition = inline_content_disposition(&doc.filename);
if !state.config.proxy_downloads {
let presigned_url = storage
.presign_get_object(
&version.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
disposition.as_deref(),
)
.await
.storage_context("failed to generate download URL")?;
return Ok(axum::response::Redirect::temporary(&presigned_url).into_response());
}
proxy_storage_object(
storage,
&version.s3_key,
disposition.as_deref(),
headers.get(header::RANGE).cloned(),
doc.mime_type.as_deref(),
Some(version.id.to_string()),
)
.await
}
DownloadSubject::Asset { asset_id } => {
let asset: DocumentAsset = document_assets::table
.find(*asset_id)
.filter(document_assets::tenant_id.eq(claims.tenant_id))
.first(&mut conn)?;
drop(conn);
let storage = state.storage_for_tenant(claims.tenant_id)?;
let disposition = asset_disposition(&asset);
if !state.config.proxy_downloads {
let presigned_url = storage
.presign_get_object(
&asset.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
disposition.as_deref(),
)
.await
.storage_context("failed to generate download URL")?;
return Ok(axum::response::Redirect::temporary(&presigned_url).into_response());
}
proxy_storage_object(
storage,
&asset.s3_key,
disposition.as_deref(),
headers.get(header::RANGE).cloned(),
Some(asset.mime_type.as_str()),
Some(asset.id.to_string()),
)
.await
}
}
}
#[utoipa::path(
post,
path = "/api/documents/{id}/trash",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 204, description = "Document deleted")),
tag = "Documents"
)]
pub async fn trash_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
let service = DocumentsService::new(&state);
service.trash_document(&mut conn, tenant_id, document_id)?;
Ok(StatusCode::NO_CONTENT)
}
async fn proxy_storage_object(
storage: TenantStorage,
key: &str,
response_disposition: Option<&str>,
range_header: Option<HeaderValue>,
fallback_content_type: Option<&str>,
etag: Option<String>,
) -> AppResult<Response> {
let url = storage
.presign_get_object(
key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
response_disposition,
)
.await
.storage_context("failed to generate download URL")?;
let client = reqwest::Client::new();
let mut request = client.get(url.clone());
if let Some(range) = range_header {
request = request.header(header::RANGE, range);
}
let upstream = request.send().await.map_err(|err| {
tracing::error!(error = ?err, "failed to fetch document stream");
AppError::internal("failed to fetch document stream")
})?;
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
tracing::error!(status = %status, "upstream download returned error status");
return Err(AppError::internal("failed to fetch document stream"));
}
let mut builder = Response::builder().status(status);
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
builder = builder.header(header::CONTENT_TYPE, content_type);
} else if let Some(fallback) = fallback_content_type {
builder = builder.header(header::CONTENT_TYPE, fallback);
}
if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) {
builder = builder.header(header::CONTENT_LENGTH, content_length);
}
if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) {
builder = builder.header(header::CONTENT_RANGE, range);
}
builder = builder.header("Accept-Ranges", "bytes");
if let Some(disposition) = response_disposition {
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
}
if let Some(etag_value) = etag {
builder = builder.header(header::ETAG, format!("\"{}\"", etag_value));
}
let stream = upstream
.bytes_stream()
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
let body = Body::from_stream(stream);
builder.body(body).map_err(|err| {
tracing::error!(error = ?err, "failed to build proxied response");
AppError::internal("failed to build proxied response")
})
}
#[utoipa::path(
delete,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 202, description = "Document purge scheduled")),
tag = "Documents"
)]
pub async fn delete_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
let service = DocumentsService::new(&state);
service.delete_document(&mut conn, tenant_id, document_id)?;
Ok(StatusCode::ACCEPTED)
}
#[utoipa::path(
patch,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = crate::services::documents::UpdateDocumentRequest,
responses((status = 200, description = "Updated document", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub async fn update_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<Value>,
) -> AppResult<JsonResponse<DocumentDetailResponse>> {
let service = DocumentsService::new(&state);
let detail = service
.update_document(&mut conn, tenant_id, user_id, document_id, payload)
.await?;
ok_json(detail)
}
#[utoipa::path(
post,
path = "/api/documents/{id}/restore",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = RestoreDocumentRequest,
responses((status = 204, description = "Document restored")),
tag = "Documents"
)]
pub async fn restore_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<RestoreDocumentRequest>,
) -> AppResult<StatusCode> {
let service = DocumentsService::new(&state);
service.restore_document(&mut conn, tenant_id, document_id, payload.folder_id)?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
patch,
path = "/api/documents/{id}/folder",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = MoveDocumentRequest,
responses((status = 204, description = "Document moved")),
tag = "Documents"
)]
pub async fn move_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<MoveDocumentRequest>,
) -> AppResult<impl IntoResponse> {
let service = DocumentsService::new(&state);
service.move_document(&mut conn, tenant_id, document_id, payload.folder_id)?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/documents/bulk/move",
request_body = BulkMoveRequest,
responses((status = 200, description = "Bulk move outcome", body = BulkMoveResponse)),
tag = "Documents"
)]
pub async fn bulk_move_documents(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkMoveRequest>,
) -> AppResult<JsonResponse<BulkMoveResponse>> {
let BulkMoveRequest {
document_ids,
folder_id,
} = payload;
let service = DocumentsService::new(&state);
let updated = service.bulk_move_documents(&mut conn, tenant_id, document_ids, folder_id)?;
ok_json(BulkMoveResponse { updated })
}
#[utoipa::path(
post,
path = "/api/documents/{id}/correspondents",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignCorrespondentsRequest,
responses((status = 204, description = "Correspondents assigned")),
tag = "Documents"
)]
pub async fn assign_correspondents(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<AssignCorrespondentsRequest>,
) -> AppResult<StatusCode> {
let service = CorrespondentsService::new(&state);
service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload)?;
no_content()
}
#[utoipa::path(
post,
path = "/api/documents/bulk/correspondents",
request_body = BulkCorrespondentsRequest,
responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentResponse)),
tag = "Documents"
)]
pub async fn bulk_assign_correspondents(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkCorrespondentsRequest>,
) -> AppResult<JsonResponse<BulkCorrespondentResponse>> {
let service = CorrespondentsService::new(&state);
let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?;
ok_json(response)
}
#[utoipa::path(
delete,
path = "/api/documents/{id}/correspondents/{correspondent_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
),
responses((status = 204, description = "Correspondent removed")),
tag = "Documents"
)]
pub async fn remove_correspondent(
State(state): State<AppState>,
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let service = CorrespondentsService::new(&state);
service.remove_from_document(&mut conn, tenant_id, document_id, correspondent_id)?;
no_content()
}
#[utoipa::path(
post,
path = "/api/documents/{id}/tags",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignTagsRequest,
responses((status = 204, description = "Tags assigned")),
tag = "Documents"
)]
pub async fn assign_tags(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<AssignTagsRequest>,
) -> AppResult<impl IntoResponse> {
let service = TagsService::new(&state);
service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload.tag_ids)?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/documents/bulk/tags",
request_body = BulkTagRequest,
responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)),
tag = "Documents"
)]
pub async fn bulk_update_tags(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<BulkTagRequest>,
) -> AppResult<JsonResponse<BulkTagResponse>> {
let service = TagsService::new(&state);
let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?;
ok_json(response)
}
#[utoipa::path(
delete,
path = "/api/documents/{id}/tags/{tag_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("tag_id" = Uuid, Path, description = "Tag ID")
),
responses((status = 204, description = "Tag removed")),
tag = "Documents"
)]
pub async fn remove_tag(
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let service = TagsService::new(&state);
service.remove_from_document(&mut conn, tenant_id, document_id, tag_id)?;
no_content()
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::documents::list_documents,
crate::routes::documents::check_document,
crate::routes::documents::upload_document,
crate::routes::documents::get_document,
crate::routes::documents::refresh_document_download,
crate::routes::documents::update_document,
crate::routes::documents::trash_document,
crate::routes::documents::delete_document,
crate::routes::documents::restore_document,
crate::routes::documents::download_with_token,
crate::routes::documents::move_document,
crate::routes::documents::assign_tags,
crate::routes::documents::remove_tag,
crate::routes::documents::bulk_move_documents,
crate::routes::documents::bulk_update_tags,
crate::routes::documents::bulk_assign_correspondents,
crate::routes::documents::assign_correspondents,
crate::routes::documents::remove_correspondent,
crate::routes::documents::reanalyze_selected_documents,
crate::routes::documents::list_document_assets,
crate::routes::documents::request_document_assets,
crate::routes::documents::get_document_asset,
crate::routes::documents::list_document_versions,
crate::routes::documents::get_document_version,
crate::routes::documents::refresh_document_version_download,
crate::routes::documents::refresh_asset_download,
),
components(schemas(
crate::services::documents::DocumentListQuery,
crate::services::documents::DocumentStatusFilter,
crate::routes::documents::AssetRequestQuery,
crate::routes::documents::DocumentCheckQuery,
crate::routes::documents::DocumentCheckResponse,
crate::services::documents::DocumentResponse,
crate::services::documents::DocumentDetailResponse,
crate::routes::documents::DocumentMetadataUpdate,
crate::services::documents::TagResponse,
crate::routes::documents::CorrespondentAssignmentInput,
crate::routes::documents::AssignCorrespondentsRequest,
crate::routes::documents::BulkCorrespondentAction,
crate::routes::documents::BulkCorrespondentsRequest,
crate::routes::documents::BulkCorrespondentResponse,
crate::routes::documents::BulkMoveRequest,
crate::routes::documents::BulkMoveResponse,
crate::routes::documents::BulkTagAction,
crate::routes::documents::BulkTagRequest,
crate::routes::documents::BulkTagResponse,
crate::routes::documents::AssignTagsRequest,
crate::routes::documents::MoveDocumentRequest,
crate::routes::documents::BulkReanalyzeSelectionRequest,
crate::routes::documents::BulkReanalyzeResponse,
crate::routes::documents::UploadDocumentForm,
crate::documents::asset::DocumentVersionResponse,
crate::documents::asset::DocumentVersionDetailResponse,
crate::documents::asset::DocumentAssetResponse,
crate::documents::asset::DocumentAssetDetailResponse,
crate::documents::asset::DownloadLink,
crate::documents::correspondents::DocumentCorrespondentResponse,
crate::error::ApiErrorResponse,
))
)]
pub struct DocumentsApiDoc;