|
|
|
@@ -1,4 +1,4 @@
|
|
|
|
|
use std::{collections::HashMap, time::Duration};
|
|
|
|
|
use std::{collections::HashMap, path::Path as FsPath, time::Duration};
|
|
|
|
|
|
|
|
|
|
use axum::extract::{Json, Multipart, Path, Query, State};
|
|
|
|
|
use axum::http::StatusCode;
|
|
|
|
@@ -57,6 +57,7 @@ impl From<Tag> for TagResponse {
|
|
|
|
|
pub struct DocumentResponse {
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
pub filename: String,
|
|
|
|
|
pub title: String,
|
|
|
|
|
pub original_name: String,
|
|
|
|
|
pub content_type: Option<String>,
|
|
|
|
|
pub folder_id: Option<Uuid>,
|
|
|
|
@@ -64,6 +65,7 @@ pub struct DocumentResponse {
|
|
|
|
|
pub uploaded_at: String,
|
|
|
|
|
pub updated_at: String,
|
|
|
|
|
pub deleted_at: Option<String>,
|
|
|
|
|
pub issued_at: Option<String>,
|
|
|
|
|
pub metadata: Value,
|
|
|
|
|
pub tags: Vec<TagResponse>,
|
|
|
|
|
pub thumbnail: Option<DocumentAssetResponse>,
|
|
|
|
@@ -112,6 +114,48 @@ pub struct BulkReanalyzeResponse {
|
|
|
|
|
pub queued: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct BulkMoveRequest {
|
|
|
|
|
pub document_ids: Vec<Uuid>,
|
|
|
|
|
pub folder_id: Option<Uuid>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
pub struct BulkMoveResponse {
|
|
|
|
|
pub updated: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
pub enum BulkTagAction {
|
|
|
|
|
Add,
|
|
|
|
|
Remove,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct BulkTagRequest {
|
|
|
|
|
pub document_ids: Vec<Uuid>,
|
|
|
|
|
pub tag_ids: Vec<Uuid>,
|
|
|
|
|
pub action: BulkTagAction,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
pub struct BulkTagResponse {
|
|
|
|
|
pub added: usize,
|
|
|
|
|
pub removed: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
pub struct BulkReanalyzeSelectionRequest {
|
|
|
|
|
pub document_ids: Vec<Uuid>,
|
|
|
|
|
#[serde(default = "default_true")]
|
|
|
|
|
pub force: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_true() -> bool {
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct UploadRequest {
|
|
|
|
|
bytes: Vec<u8>,
|
|
|
|
|
original_name: String,
|
|
|
|
@@ -379,6 +423,57 @@ pub async fn reanalyze_all_documents(
|
|
|
|
|
Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn reanalyze_selected_documents(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Json(payload): Json<BulkReanalyzeSelectionRequest>,
|
|
|
|
|
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
|
|
|
|
|
let BulkReanalyzeSelectionRequest {
|
|
|
|
|
mut document_ids,
|
|
|
|
|
force,
|
|
|
|
|
} = payload;
|
|
|
|
|
|
|
|
|
|
if document_ids.is_empty() {
|
|
|
|
|
return Err(AppError::bad_request("document_ids must not be empty"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document_ids.sort();
|
|
|
|
|
document_ids.dedup();
|
|
|
|
|
|
|
|
|
|
let mut conn = state.db()?;
|
|
|
|
|
|
|
|
|
|
let targets: Vec<(Uuid, Uuid)> = document_versions::table
|
|
|
|
|
.inner_join(documents::table.on(document_versions::document_id.eq(documents::id)))
|
|
|
|
|
.filter(documents::id.eq_any(&document_ids))
|
|
|
|
|
.filter(documents::deleted_at.is_null())
|
|
|
|
|
.filter(document_versions::version_number.eq(documents::current_version))
|
|
|
|
|
.select((documents::id, document_versions::id))
|
|
|
|
|
.load(&mut conn)?;
|
|
|
|
|
|
|
|
|
|
if targets.len() != document_ids.len() {
|
|
|
|
|
return Err(AppError::bad_request(
|
|
|
|
|
"one or more documents do not exist or are inaccessible",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut queued = 0usize;
|
|
|
|
|
for (document_id, version_id) in targets {
|
|
|
|
|
enqueue_job(
|
|
|
|
|
&mut conn,
|
|
|
|
|
JOB_ANALYZE_DOCUMENT,
|
|
|
|
|
json!({
|
|
|
|
|
"document_id": document_id,
|
|
|
|
|
"document_version_id": version_id,
|
|
|
|
|
"force": force,
|
|
|
|
|
}),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?;
|
|
|
|
|
queued += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn list_document_assets(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(document_id): Path<Uuid>,
|
|
|
|
@@ -468,6 +563,54 @@ pub async fn move_document(
|
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn bulk_move_documents(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Json(payload): Json<BulkMoveRequest>,
|
|
|
|
|
) -> AppResult<(StatusCode, Json<BulkMoveResponse>)> {
|
|
|
|
|
let BulkMoveRequest {
|
|
|
|
|
mut document_ids,
|
|
|
|
|
folder_id,
|
|
|
|
|
} = payload;
|
|
|
|
|
|
|
|
|
|
if document_ids.is_empty() {
|
|
|
|
|
return Err(AppError::bad_request("document_ids must not be empty"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document_ids.sort();
|
|
|
|
|
document_ids.dedup();
|
|
|
|
|
|
|
|
|
|
if let Some(target_folder) = folder_id {
|
|
|
|
|
ensure_folder_exists(&state, target_folder)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut conn = state.db()?;
|
|
|
|
|
|
|
|
|
|
let existing: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
|
|
|
|
|
.filter(documents::id.eq_any(&document_ids))
|
|
|
|
|
.select((documents::id, documents::deleted_at))
|
|
|
|
|
.load(&mut conn)?;
|
|
|
|
|
|
|
|
|
|
if existing.len() != document_ids.len() {
|
|
|
|
|
return Err(AppError::bad_request(
|
|
|
|
|
"one or more documents do not exist or are inaccessible",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if existing.iter().any(|(_, deleted)| deleted.is_some()) {
|
|
|
|
|
return Err(AppError::bad_request("cannot move deleted documents"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let now = Utc::now().naive_utc();
|
|
|
|
|
let updated = diesel::update(documents::table.filter(documents::id.eq_any(&document_ids)))
|
|
|
|
|
.set((
|
|
|
|
|
documents::folder_id.eq(folder_id),
|
|
|
|
|
documents::updated_at.eq(now),
|
|
|
|
|
))
|
|
|
|
|
.execute(&mut conn)?;
|
|
|
|
|
|
|
|
|
|
Ok((StatusCode::OK, Json(BulkMoveResponse { updated })))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn assign_tags(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path(document_id): Path<Uuid>,
|
|
|
|
@@ -511,6 +654,94 @@ pub async fn assign_tags(
|
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn bulk_update_tags(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
user: AuthenticatedUser,
|
|
|
|
|
Json(payload): Json<BulkTagRequest>,
|
|
|
|
|
) -> AppResult<(StatusCode, Json<BulkTagResponse>)> {
|
|
|
|
|
let BulkTagRequest {
|
|
|
|
|
mut document_ids,
|
|
|
|
|
mut tag_ids,
|
|
|
|
|
action,
|
|
|
|
|
} = payload;
|
|
|
|
|
|
|
|
|
|
if document_ids.is_empty() {
|
|
|
|
|
return Err(AppError::bad_request("document_ids must not be empty"));
|
|
|
|
|
}
|
|
|
|
|
if tag_ids.is_empty() {
|
|
|
|
|
return Err(AppError::bad_request("tag_ids must not be empty"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document_ids.sort();
|
|
|
|
|
document_ids.dedup();
|
|
|
|
|
tag_ids.sort();
|
|
|
|
|
tag_ids.dedup();
|
|
|
|
|
|
|
|
|
|
let mut conn = state.db()?;
|
|
|
|
|
|
|
|
|
|
let docs: Vec<(Uuid, Option<NaiveDateTime>)> = documents::table
|
|
|
|
|
.filter(documents::id.eq_any(&document_ids))
|
|
|
|
|
.select((documents::id, documents::deleted_at))
|
|
|
|
|
.load(&mut conn)?;
|
|
|
|
|
|
|
|
|
|
if docs.len() != document_ids.len() {
|
|
|
|
|
return Err(AppError::bad_request(
|
|
|
|
|
"one or more documents do not exist or are inaccessible",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
|
|
|
|
|
return Err(AppError::bad_request(
|
|
|
|
|
"cannot assign or remove tags from deleted documents",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let existing_tags: Vec<Tag> = tags::table
|
|
|
|
|
.filter(tags::id.eq_any(&tag_ids))
|
|
|
|
|
.load(&mut conn)?;
|
|
|
|
|
if existing_tags.len() != tag_ids.len() {
|
|
|
|
|
return Err(AppError::bad_request("one or more tags do not exist"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let response = match action {
|
|
|
|
|
BulkTagAction::Add => {
|
|
|
|
|
let mut inserts = Vec::with_capacity(document_ids.len() * tag_ids.len());
|
|
|
|
|
for doc_id in &document_ids {
|
|
|
|
|
for tag_id in &tag_ids {
|
|
|
|
|
inserts.push(NewDocumentTag {
|
|
|
|
|
document_id: *doc_id,
|
|
|
|
|
tag_id: *tag_id,
|
|
|
|
|
assigned_by: Some(user.user_id),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let added = if inserts.is_empty() {
|
|
|
|
|
0
|
|
|
|
|
} else {
|
|
|
|
|
diesel::insert_into(document_tags::table)
|
|
|
|
|
.values(&inserts)
|
|
|
|
|
.on_conflict_do_nothing()
|
|
|
|
|
.execute(&mut conn)?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
BulkTagResponse { added, removed: 0 }
|
|
|
|
|
}
|
|
|
|
|
BulkTagAction::Remove => {
|
|
|
|
|
let removed = diesel::delete(
|
|
|
|
|
document_tags::table
|
|
|
|
|
.filter(document_tags::document_id.eq_any(&document_ids))
|
|
|
|
|
.filter(document_tags::tag_id.eq_any(&tag_ids)),
|
|
|
|
|
)
|
|
|
|
|
.execute(&mut conn)?;
|
|
|
|
|
|
|
|
|
|
BulkTagResponse { added: 0, removed }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok((StatusCode::OK, Json(response)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn remove_tag(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
|
|
|
|
@@ -627,6 +858,8 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<U
|
|
|
|
|
content_type: content_type.clone(),
|
|
|
|
|
folder_id,
|
|
|
|
|
current_version: version_number,
|
|
|
|
|
issued_at: None,
|
|
|
|
|
title: derive_document_title(&original_name),
|
|
|
|
|
metadata: metadata_value.clone(),
|
|
|
|
|
};
|
|
|
|
|
diesel::insert_into(documents::table)
|
|
|
|
@@ -793,6 +1026,7 @@ pub(crate) fn to_document_response(
|
|
|
|
|
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,
|
|
|
|
@@ -800,6 +1034,7 @@ pub(crate) fn to_document_response(
|
|
|
|
|
uploaded_at: to_iso(doc.uploaded_at),
|
|
|
|
|
updated_at: to_iso(doc.updated_at),
|
|
|
|
|
deleted_at: doc.deleted_at.map(to_iso),
|
|
|
|
|
issued_at: doc.issued_at.map(to_iso),
|
|
|
|
|
metadata: doc.metadata,
|
|
|
|
|
tags: tags
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
@@ -834,6 +1069,22 @@ fn to_asset_response(asset: DocumentAsset, url: String) -> DocumentAssetResponse
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn derive_document_title(original: &str) -> String {
|
|
|
|
|
let trimmed = original.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return "Document".to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let stem = FsPath::new(trimmed)
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|s| s.to_str())
|
|
|
|
|
.map(|s| s.trim())
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.map(|s| s.to_string());
|
|
|
|
|
|
|
|
|
|
stem.unwrap_or_else(|| trimmed.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn load_asset_responses(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
version_id: Uuid,
|
|
|
|
|