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, } #[derive(Deserialize)] pub struct UpdateCorrespondentRequest { pub name: Option, pub metadata: Option, } #[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>> { let correspondents_list: Vec = 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 = 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, ) -> AppResult> { 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, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult> { let existing: Correspondent = correspondents::table .find(correspondent_id) .filter(correspondents::tenant_id.eq(tenant_id)) .first(&mut conn) .one()?; let mut new_name: Option = 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::(&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 = 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, TenantScopedConn { mut conn, tenant_id, .. }: TenantScopedConn, ) -> AppResult { 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 { 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 { 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) }