Files
papercrate/backend/src/routes/correspondents.rs
T
2025-10-28 01:18:35 +01:00

267 lines
8.0 KiB
Rust

use std::collections::HashMap;
use axum::{extract::Path, http::StatusCode, Json};
use chrono::Utc;
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult},
models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents},
utils::{
db::{no_content, EnsureEntity, IntoJsonResponse},
time::to_iso,
},
};
#[derive(Serialize)]
pub struct CorrespondentUsage {
pub total: i64,
}
#[derive(Serialize)]
pub struct CorrespondentSummary {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub created_at: String,
pub updated_at: String,
pub usage: CorrespondentUsage,
}
#[derive(Deserialize)]
pub struct CreateCorrespondentRequest {
pub name: String,
#[serde(default)]
pub metadata: Option<Value>,
}
#[derive(Deserialize)]
pub struct UpdateCorrespondentRequest {
pub name: Option<String>,
pub metadata: Option<Value>,
}
#[derive(AsChangeset, Default)]
#[diesel(table_name = correspondents)]
struct CorrespondentChangeset<'a> {
name: Option<&'a str>,
metadata: Option<&'a Value>,
}
pub async fn list_correspondents(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc())
.load(&mut conn)?;
let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::correspondent_id)
.select((document_correspondents::correspondent_id, count_star()))
.load(&mut conn)?;
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 total = usage_map.remove(&correspondent.id).unwrap_or(0);
response.push(build_summary(correspondent, total));
}
response.into_json()
}
pub async fn create_correspondent(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
let name = payload.name.trim();
if name.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
let metadata_value = normalize_metadata(payload.metadata);
let new_id = Uuid::new_v4();
let new_correspondent = NewCorrespondent {
id: new_id,
name: name.to_string(),
metadata: metadata_value,
tenant_id,
};
match diesel::insert_into(correspondents::table)
.values(&new_correspondent)
.execute(&mut conn)
{
Ok(_) => {}
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
return Err(AppError::bad_request("correspondent name already exists"));
}
Err(err) => return Err(AppError::from(err)),
}
let correspondent: Correspondent = correspondents::table
.find(new_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
build_summary(correspondent, 0).into_json()
}
pub async fn update_correspondent(
Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
let existing: Correspondent = correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name {
let trimmed = candidate.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != existing.name {
let duplicate = correspondents::table
.filter(correspondents::name.eq(trimmed))
.filter(correspondents::id.ne(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id))
.first::<Correspondent>(&mut conn)
.optional()?;
if duplicate.is_some() {
return Err(AppError::bad_request("correspondent name already exists"));
}
new_name = Some(trimmed.to_string());
}
}
let mut new_metadata: Option<Value> = None;
if let Some(metadata) = payload.metadata.clone() {
let candidate = normalize_metadata(Some(metadata));
if candidate != existing.metadata {
new_metadata = Some(candidate);
}
}
if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
return build_summary(existing.clone(), usage).into_json();
}
let mut changeset = CorrespondentChangeset::default();
if let Some(ref name) = new_name {
changeset.name = Some(name.as_str());
}
if let Some(ref metadata) = new_metadata {
changeset.metadata = Some(metadata);
}
let now = Utc::now().naive_utc();
diesel::update(
correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?;
let updated: Correspondent = correspondents::table
.find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn)
.one()?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
build_summary(updated, usage).into_json()
}
pub async fn delete_correspondent(
Path(correspondent_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let usage: i64 = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.select(count_star())
.first(&mut conn)?;
if usage > 0 {
return Err(AppError::bad_request(
"cannot delete correspondent that is still assigned to documents",
));
}
let deleted = diesel::delete(
correspondents::table
.filter(correspondents::id.eq(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)),
)
.execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
no_content()
}
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 },
}
}
fn normalize_metadata(input: Option<Value>) -> Value {
match input {
None | Some(Value::Null) => Value::Object(Default::default()),
Some(value) => value,
}
}
fn load_usage_for_correspondent(
conn: &mut PgConnection,
tenant_id: Uuid,
correspondent_id: Uuid,
) -> AppResult<i64> {
let total: i64 = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.select(count_star())
.get_result(conn)?;
Ok(total)
}