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, #[schema(nullable, value_type = Object)] pub metadata: Option, #[schema(nullable)] pub title: Option, #[schema(nullable, value_type = Vec)] pub tag_ids: Option>, #[schema(nullable, value_type = Vec)] pub correspondents: Option>, #[schema(nullable, example = "2024-01-01T00:00:00Z")] pub issued_at: Option, #[schema(nullable, default = true)] pub skip_existing: Option, } #[derive(Deserialize, ToSchema)] pub struct MoveDocumentRequest { pub folder_id: Option, } #[derive(Deserialize, ToSchema)] pub struct RestoreDocumentRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub folder_id: Option, } #[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, Query(params): Query, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult>> { 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, Query(query): Query, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult> { let checksum_raw = query.checksum.trim(); if checksum_raw.is_empty() { return Err(AppError::bad_request("checksum must not be empty")); } let checksum = checksum_raw.to_ascii_lowercase(); if !checksum.chars().all(|ch| ch.is_ascii_hexdigit()) { return Err(AppError::bad_request( "checksum must be a hex-encoded string", )); } let 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult> { 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, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, mut multipart: Multipart, ) -> AppResult> { let tenant_id = tenant_id; let user_id = user_id; let mut file_bytes: Option> = None; let mut original_name: Option = None; let mut mime_type: Option = None; let mut folder_id: Option = None; let mut metadata: Value = Value::Object(Default::default()); let mut tag_ids: Vec = Vec::new(); let mut correspondents: Vec = Vec::new(); let mut issued_at_override: Option = None; let mut skip_if_existing = true; let mut title_override: Option = None; while let Some(field) = multipart.next_field().await.map_err(|err| { let msg = format!("invalid multipart data: {err}"); error!(error = %err, "invalid multipart data"); AppError::bad_request(msg) })? { let name = field.name().map(|n| n.to_string()); match name.as_deref() { Some("file") => { let file_name = field.file_name().map(|n| n.to_string()); original_name = file_name.clone(); 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 = 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, Path(document_id): Path, Query(query): Query, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { 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, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult>> { 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, Path(asset_id): Path, TenantScopedConn { conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult> { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult> { 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, Path((document_id, version_id)): Path<(Uuid, Uuid)>, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult> { 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, Path(asset_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult> { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult>> { 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, Path((document_id, version_id)): Path<(Uuid, Uuid)>, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult> { 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, Path(token): Path, headers: HeaderMap, ) -> AppResult { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { 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, fallback_content_type: Option<&str>, etag: Option, ) -> AppResult { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult { 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, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult { 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, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { 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, Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { 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, Path(document_id): Path, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult { 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, TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { 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, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { 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;