preview-stack

This commit is contained in:
2025-10-11 11:09:38 +02:00
parent 30f3fe015b
commit 41a9e8f76c
13 changed files with 2088 additions and 287 deletions
+4
View File
@@ -58,6 +58,8 @@ pub struct Document {
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>,
pub title: String,
}
#[derive(Debug, Insertable)]
@@ -70,6 +72,8 @@ pub struct NewDocument {
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>,
pub title: String,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
+252 -1
View File
@@ -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,
+6
View File
@@ -31,6 +31,12 @@ pub fn create_router(state: AppState) -> Router<()> {
get(documents::list_documents).post(documents::upload_document),
)
.route("/reanalyze", post(documents::reanalyze_all_documents))
.route("/bulk/move", post(documents::bulk_move_documents))
.route("/bulk/tags", post(documents::bulk_update_tags))
.route(
"/bulk/reanalyze",
post(documents::reanalyze_selected_documents),
)
.route(
"/:id",
get(documents::get_document).delete(documents::delete_document),
+3
View File
@@ -53,6 +53,9 @@ diesel::table! {
updated_at -> Timestamptz,
deleted_at -> Nullable<Timestamptz>,
metadata -> Jsonb,
issued_at -> Nullable<Timestamptz>,
#[max_length = 255]
title -> Varchar,
}
}