This commit is contained in:
2025-10-28 01:18:35 +01:00
parent 1179f4fcd4
commit 7df9a6415a
17 changed files with 935 additions and 935 deletions
+1 -3
View File
@@ -281,11 +281,10 @@ pub struct NewCorrespondent {
#[diesel(table_name = document_correspondents)]
#[diesel(belongs_to(Document))]
#[diesel(belongs_to(Correspondent))]
#[diesel(primary_key(document_id, correspondent_id, role))]
#[diesel(primary_key(document_id, correspondent_id))]
pub struct DocumentCorrespondent {
pub document_id: Uuid,
pub correspondent_id: Uuid,
pub role: String,
pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
@@ -296,7 +295,6 @@ pub struct DocumentCorrespondent {
pub struct NewDocumentCorrespondent {
pub document_id: Uuid,
pub correspondent_id: Uuid,
pub role: String,
pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid,
}
+12 -14
View File
@@ -76,7 +76,6 @@ use uuid::Uuid;
schemas::BulkCorrespondentsResponse,
schemas::BulkCorrespondentAction,
schemas::AssignCorrespondentsRequest,
schemas::RemoveCorrespondentParams,
schemas::ReanalyzeRequest,
schemas::ReanalyzeResponse,
schemas::DocumentAssetRequestParams,
@@ -95,6 +94,7 @@ use uuid::Uuid;
schemas::TagCatalogEntry,
schemas::CreateTagRequest,
schemas::UpdateTagRequest,
schemas::CorrespondentUsage,
schemas::CorrespondentCatalogEntry,
schemas::CreateCorrespondentRequest,
schemas::UpdateCorrespondentRequest,
@@ -351,8 +351,7 @@ mod doc {
path = "/api/documents/{id}/correspondents/{correspondent_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
RemoveCorrespondentParams
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
),
responses((status = 204, description = "Correspondent removed")),
tag = "Documents"
@@ -683,7 +682,6 @@ pub mod schemas {
pub struct DocumentCorrespondent {
pub id: Uuid,
pub name: String,
pub role: String,
pub metadata: Value,
pub assigned_at: String,
}
@@ -706,8 +704,8 @@ pub mod schemas {
pub issued_at: Option<String>,
pub metadata: Value,
pub tags: Vec<DocumentTag>,
#[schema(nullable)]
pub correspondents: Option<Vec<DocumentCorrespondent>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub correspondents: Vec<DocumentCorrespondent>,
#[schema(nullable)]
pub current_version: Option<DocumentVersionDetailResponse>,
}
@@ -787,7 +785,6 @@ pub mod schemas {
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentAssignment {
pub correspondent_id: Uuid,
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -822,12 +819,6 @@ pub mod schemas {
pub removed: usize,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct RemoveCorrespondentParams {
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ReanalyzeRequest {
pub document_ids: Vec<Uuid>,
@@ -981,12 +972,19 @@ pub mod schemas {
pub color: Option<Option<String>>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentUsage {
pub total: i64,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentCatalogEntry {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub role_counts: Value,
pub created_at: String,
pub updated_at: String,
pub usage: CorrespondentUsage,
}
#[derive(Serialize, Deserialize, ToSchema)]
+17 -41
View File
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::HashMap;
use axum::{extract::Path, http::StatusCode, Json};
use chrono::Utc;
@@ -21,8 +21,6 @@ use crate::{
#[derive(Serialize)]
pub struct CorrespondentUsage {
pub total: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub by_role: BTreeMap<String, i64>,
}
#[derive(Serialize)]
@@ -67,31 +65,21 @@ pub async fn list_correspondents(
.order(correspondents::name.asc())
.load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by((
document_correspondents::correspondent_id,
document_correspondents::role,
))
.select((
document_correspondents::correspondent_id,
document_correspondents::role,
count_star(),
))
.group_by(document_correspondents::correspondent_id)
.select((document_correspondents::correspondent_id, count_star()))
.load(&mut conn)?;
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
for (correspondent_id, role, count) in usage_rows {
usage_map
.entry(correspondent_id)
.or_default()
.insert(role, count);
let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
for (correspondent_id, count) in usage_rows {
usage_map.insert(correspondent_id, count);
}
let mut response = Vec::with_capacity(correspondents_list.len());
for correspondent in correspondents_list {
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
response.push(build_summary(correspondent, role_counts));
let total = usage_map.remove(&correspondent.id).unwrap_or(0);
response.push(build_summary(correspondent, total));
}
response.into_json()
@@ -136,7 +124,7 @@ pub async fn create_correspondent(
.first(&mut conn)
.one()?;
build_summary(correspondent, BTreeMap::new()).into_json()
build_summary(correspondent, 0).into_json()
}
pub async fn update_correspondent(
@@ -245,21 +233,14 @@ pub async fn delete_correspondent(
no_content()
}
fn build_summary(
correspondent: Correspondent,
role_counts: BTreeMap<String, i64>,
) -> CorrespondentSummary {
let total = role_counts.values().copied().sum();
fn build_summary(correspondent: Correspondent, total: i64) -> CorrespondentSummary {
CorrespondentSummary {
id: correspondent.id,
name: correspondent.name,
metadata: correspondent.metadata,
created_at: to_iso(correspondent.created_at),
updated_at: to_iso(correspondent.updated_at),
usage: CorrespondentUsage {
total,
by_role: role_counts,
},
usage: CorrespondentUsage { total },
}
}
@@ -274,17 +255,12 @@ fn load_usage_for_correspondent(
conn: &mut PgConnection,
tenant_id: Uuid,
correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> {
let rows: Vec<(String, i64)> = document_correspondents::table
) -> AppResult<i64> {
let total: i64 = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::role)
.select((document_correspondents::role, count_star()))
.load(conn)?;
.select(count_star())
.get_result(conn)?;
let mut map = BTreeMap::new();
for (role, count) in rows {
map.insert(role, count);
}
Ok(map)
Ok(total)
}
+78 -146
View File
@@ -48,10 +48,7 @@ use asset_utils::{
build_download_path, derive_document_title, filename_with_retained_extension,
to_asset_detail_response, to_asset_object_response, to_asset_summary, to_version_response,
};
use correspondent_utils::{
is_valid_correspondent_role, normalize_correspondent_assignments, normalize_role,
CORRESPONDENT_ROLES,
};
use correspondent_utils::normalize_correspondent_assignments;
use search_utils::{build_quickwit_query, extract_document_id};
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
@@ -186,7 +183,6 @@ pub struct DocumentVersionDetailResponse {
pub struct DocumentCorrespondentResponse {
pub id: Uuid,
pub name: String,
pub role: String,
pub metadata: Value,
pub assigned_at: String,
}
@@ -280,7 +276,6 @@ pub struct BulkCorrespondentResponse {
#[derive(Deserialize, ToSchema)]
pub struct CorrespondentAssignmentInput {
pub correspondent_id: Uuid,
pub role: String,
}
#[derive(Deserialize, ToSchema)]
@@ -319,12 +314,6 @@ pub struct BulkCorrespondentsRequest {
pub action: BulkCorrespondentAction,
}
#[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct CorrespondentRoleQuery {
pub role: String,
}
#[derive(Deserialize, ToSchema)]
pub struct BulkReanalyzeSelectionRequest {
pub document_ids: Vec<Uuid>,
@@ -853,7 +842,7 @@ pub async fn upload_document(
})?;
correspondents = serde_json::from_str(&value).map_err(|err| {
let msg = format!(
"correspondents must be a JSON array of {{correspondent_id, role}} objects: {err}"
"correspondents must be a JSON array of {{correspondent_id}} objects: {err}"
);
error!(error = %err, "invalid correspondents json");
AppError::bad_request(msg)
@@ -1672,8 +1661,7 @@ pub async fn assign_correspondents(
return Err(AppError::bad_request("assignments must not be empty"));
}
let (normalized_pairs, _correspondent_ids, roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?;
let correspondent_ids = normalize_correspondent_assignments(&payload.assignments)?;
let replace = payload.replace;
conn.transaction::<(), AppError, _>(|conn| {
@@ -1685,21 +1673,41 @@ pub async fn assign_correspondents(
return Err(AppError::not_found());
}
let mut deleted = 0;
let mut updated = false;
if replace {
deleted = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::role.eq_any(&roles_vec)),
)
.execute(conn)?;
use diesel::dsl::not;
let base = document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id));
let removed = if correspondent_ids.is_empty() {
diesel::delete(base).execute(conn)?
} else {
diesel::delete(base.filter(not(
document_correspondents::correspondent_id.eq_any(&correspondent_ids),
)))
.execute(conn)?
};
if removed > 0 {
updated = true;
}
}
let inserted =
insert_document_correspondents(conn, tenant_id, &document, user_id, &normalized_pairs)?;
let inserted = insert_document_correspondents(
conn,
tenant_id,
document.id,
user_id,
&correspondent_ids,
)?;
if replace && deleted > 0 && inserted == 0 {
if inserted > 0 {
updated = true;
}
if updated && inserted == 0 {
diesel::update(
documents::table
.find(document_id)
@@ -1731,8 +1739,7 @@ pub async fn bulk_assign_correspondents(
let mut document_ids = payload.document_ids;
validate_bulk_ids(&mut document_ids, "document_ids")?;
let (normalized_pairs, correspondents_vec, _roles_vec) =
normalize_correspondent_assignments(&payload.assignments)?;
let correspondent_ids = normalize_correspondent_assignments(&payload.assignments)?;
let action = payload.action;
let user_id_val = user_id;
let (assigned, removed) = conn.transaction::<(usize, usize), AppError, _>(|conn| {
@@ -1754,12 +1761,12 @@ pub async fn bulk_assign_correspondents(
));
}
if !correspondents_vec.is_empty() {
if !correspondent_ids.is_empty() {
let existing: Vec<Correspondent> = correspondents::table
.filter(correspondents::id.eq_any(&correspondents_vec))
.filter(correspondents::id.eq_any(&correspondent_ids))
.filter(correspondents::tenant_id.eq(tenant_id))
.load(conn)?;
if existing.len() != correspondents_vec.len() {
if existing.len() != correspondent_ids.len() {
return Err(AppError::bad_request(
"one or more correspondents do not exist",
));
@@ -1768,92 +1775,34 @@ pub async fn bulk_assign_correspondents(
match action {
BulkCorrespondentAction::Add => {
use diesel::dsl::not;
let mut grouped_by_role: HashMap<String, Vec<Uuid>> = HashMap::new();
for (correspondent_id, role) in &normalized_pairs {
grouped_by_role
.entry(role.clone())
.or_default()
.push(*correspondent_id);
let mut assigned_total = 0;
for (doc_id, _) in &docs {
assigned_total += insert_document_correspondents(
conn,
tenant_id,
*doc_id,
user_id_val,
&correspondent_ids,
)?;
}
let mut removed = 0;
for (role, ids) in grouped_by_role.iter() {
if ids.is_empty() {
continue;
}
let maintained_ids = ids.clone();
let deleted = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::role.eq(role.as_str()))
.filter(not(
document_correspondents::correspondent_id.eq_any(maintained_ids)
)),
)
.execute(conn)?;
removed += deleted;
}
let mut new_rows = Vec::with_capacity(document_ids.len() * normalized_pairs.len());
for doc_id in &document_ids {
for (correspondent_id, role) in &normalized_pairs {
new_rows.push(NewDocumentCorrespondent {
document_id: *doc_id,
correspondent_id: *correspondent_id,
role: role.clone(),
assigned_by: Some(user_id_val),
tenant_id,
});
}
}
let assigned = if new_rows.is_empty() {
0
} else {
diesel::insert_into(document_correspondents::table)
.values(&new_rows)
.on_conflict_do_nothing()
.execute(conn)?
};
if assigned > 0 || removed > 0 {
diesel::update(
documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok((assigned, removed))
Ok((assigned_total, 0))
}
BulkCorrespondentAction::Remove => {
let mut removed = 0;
if !normalized_pairs.is_empty() {
let mut grouped: HashMap<String, Vec<Uuid>> = HashMap::new();
for (correspondent_id, role) in &normalized_pairs {
grouped
.entry(role.clone())
.or_default()
.push(*correspondent_id);
}
for (role, ids) in grouped {
removed += diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::role.eq(role.as_str()))
.filter(document_correspondents::correspondent_id.eq_any(&ids)),
)
.execute(conn)?;
}
if correspondent_ids.is_empty() {
return Ok((0, 0));
}
let removed = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq_any(&document_ids))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(
document_correspondents::correspondent_id.eq_any(&correspondent_ids),
),
)
.execute(conn)?;
if removed > 0 {
diesel::update(
documents::table
@@ -1875,24 +1824,12 @@ pub async fn bulk_assign_correspondents(
pub async fn remove_correspondent(
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
Query(query): Query<CorrespondentRoleQuery>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let role = normalize_role(&query.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
@@ -1905,8 +1842,7 @@ pub async fn remove_correspondent(
document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::role.eq(&role)),
.filter(document_correspondents::correspondent_id.eq(correspondent_id)),
)
.execute(&mut conn)?;
@@ -2374,47 +2310,45 @@ fn assign_correspondents_internal(
return Ok(0);
}
let (normalized_pairs, _correspondent_ids, _roles) =
normalize_correspondent_assignments(assignments)?;
let ids = normalize_correspondent_assignments(assignments)?;
insert_document_correspondents(conn, tenant_id, document, user_id, &normalized_pairs)
insert_document_correspondents(conn, tenant_id, document.id, user_id, &ids)
}
fn insert_document_correspondents(
conn: &mut PgConnection,
tenant_id: Uuid,
document: &Document,
document_id: Uuid,
user_id: Uuid,
normalized_pairs: &[(Uuid, String)],
correspondent_ids: &[Uuid],
) -> AppResult<usize> {
if normalized_pairs.is_empty() {
if correspondent_ids.is_empty() {
return Ok(0);
}
let mut correspondent_ids: Vec<Uuid> = normalized_pairs.iter().map(|(id, _)| *id).collect();
correspondent_ids.sort_unstable();
correspondent_ids.dedup();
let mut unique_ids: Vec<Uuid> = correspondent_ids.to_vec();
unique_ids.sort_unstable();
unique_ids.dedup();
if !correspondent_ids.is_empty() {
if !unique_ids.is_empty() {
let existing: Vec<Uuid> = correspondents::table
.filter(correspondents::id.eq_any(&correspondent_ids))
.filter(correspondents::id.eq_any(&unique_ids))
.filter(correspondents::tenant_id.eq(tenant_id))
.select(correspondents::id)
.load(conn)?;
if existing.len() != correspondent_ids.len() {
if existing.len() != unique_ids.len() {
return Err(AppError::bad_request(
"one or more correspondents do not exist",
));
}
}
let new_rows: Vec<NewDocumentCorrespondent> = normalized_pairs
.iter()
.map(|(correspondent_id, role)| NewDocumentCorrespondent {
document_id: document.id,
correspondent_id: *correspondent_id,
role: role.clone(),
let new_rows: Vec<NewDocumentCorrespondent> = unique_ids
.into_iter()
.map(|correspondent_id| NewDocumentCorrespondent {
document_id,
correspondent_id,
assigned_by: Some(user_id),
tenant_id,
})
@@ -2432,7 +2366,7 @@ fn insert_document_correspondents(
if inserted > 0 {
diesel::update(
documents::table
.find(document.id)
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
@@ -2490,7 +2424,6 @@ pub(crate) fn load_correspondents_for_documents(
.filter(document_correspondents::document_id.eq_any(document_ids))
.order((
document_correspondents::document_id.asc(),
document_correspondents::role.asc(),
document_correspondents::assigned_at.asc(),
))
.load(conn)?;
@@ -2502,7 +2435,6 @@ pub(crate) fn load_correspondents_for_documents(
.push(DocumentCorrespondentResponse {
id: correspondent.id,
name: correspondent.name,
role: assignment.role,
metadata: correspondent.metadata,
assigned_at: to_iso(assignment.assigned_at),
});
@@ -6,56 +6,23 @@ use crate::error::{AppError, AppResult};
use super::CorrespondentAssignmentInput;
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
pub fn normalize_role(value: &str) -> String {
value.trim().to_lowercase()
}
pub fn is_valid_correspondent_role(role: &str) -> bool {
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
}
pub fn normalize_correspondent_assignments(
assignments: &[CorrespondentAssignmentInput],
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new();
let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new();
let mut role_set: HashSet<String> = HashSet::new();
let mut correspondent_ids: HashSet<Uuid> = HashSet::new();
) -> AppResult<Vec<Uuid>> {
let mut unique_ids = HashSet::new();
let mut result = Vec::new();
for assignment in assignments {
let role = normalize_role(&assignment.role);
if role.is_empty() {
return Err(AppError::bad_request("role must not be empty"));
if unique_ids.insert(assignment.correspondent_id) {
result.push(assignment.correspondent_id);
}
if !is_valid_correspondent_role(&role) {
return Err(AppError::bad_request(format!(
"invalid correspondent role '{role}'. Allowed roles: {}",
CORRESPONDENT_ROLES.join(", ")
)));
}
if !unique_pairs.insert((assignment.correspondent_id, role.clone())) {
continue;
}
normalized_pairs.push((assignment.correspondent_id, role.clone()));
role_set.insert(role);
correspondent_ids.insert(assignment.correspondent_id);
}
if normalized_pairs.is_empty() {
if result.is_empty() {
return Err(AppError::bad_request(
"assignments must contain at least one unique correspondent/role pair",
"assignments must contain at least one correspondent",
));
}
let mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
correspondents_vec.sort();
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
roles_vec.sort();
Ok((normalized_pairs, correspondents_vec, roles_vec))
Ok(result)
}
+1 -3
View File
@@ -37,11 +37,9 @@ diesel::table! {
}
diesel::table! {
document_correspondents (document_id, correspondent_id, role) {
document_correspondents (document_id, correspondent_id) {
document_id -> Uuid,
correspondent_id -> Uuid,
#[max_length = 32]
role -> Varchar,
assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid,