From 4c36ea2e9ae487a68306f7503bbd4525bb57a264 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 14 Oct 2025 22:55:30 +0200 Subject: [PATCH] backend: correspondents --- .../202503200001_add_correspondents/down.sql | 5 + .../202503200001_add_correspondents/up.sql | 27 ++ backend/src/models.rs | 40 +++ backend/src/routes/correspondents.rs | 258 ++++++++++++++++++ backend/src/routes/documents.rs | 250 ++++++++++++++++- backend/src/routes/folders.rs | 9 +- backend/src/routes/mod.rs | 25 +- backend/src/routes/tags.rs | 26 ++ backend/src/schema.rs | 27 ++ docs/api.txt | 12 +- 10 files changed, 671 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/202503200001_add_correspondents/down.sql create mode 100644 backend/migrations/202503200001_add_correspondents/up.sql create mode 100644 backend/src/routes/correspondents.rs diff --git a/backend/migrations/202503200001_add_correspondents/down.sql b/backend/migrations/202503200001_add_correspondents/down.sql new file mode 100644 index 0000000..1490292 --- /dev/null +++ b/backend/migrations/202503200001_add_correspondents/down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_document_correspondents_role; +DROP INDEX IF EXISTS idx_document_correspondents_correspondent; +DROP INDEX IF EXISTS idx_document_correspondents_document; +DROP TABLE IF EXISTS document_correspondents; +DROP TABLE IF EXISTS correspondents; diff --git a/backend/migrations/202503200001_add_correspondents/up.sql b/backend/migrations/202503200001_add_correspondents/up.sql new file mode 100644 index 0000000..600680b --- /dev/null +++ b/backend/migrations/202503200001_add_correspondents/up.sql @@ -0,0 +1,27 @@ +CREATE TABLE correspondents ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT correspondents_name_unique UNIQUE (name) +); + +CREATE TABLE document_correspondents ( + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE, + role VARCHAR(32) NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + assigned_by UUID REFERENCES users(id), + PRIMARY KEY (document_id, correspondent_id, role), + CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other')) +); + +CREATE INDEX idx_document_correspondents_document + ON document_correspondents(document_id); + +CREATE INDEX idx_document_correspondents_correspondent + ON document_correspondents(correspondent_id); + +CREATE INDEX idx_document_correspondents_role + ON document_correspondents(role); diff --git a/backend/src/models.rs b/backend/src/models.rs index e199b4b..00091c0 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -188,6 +188,46 @@ pub struct NewDocumentTag { pub assigned_by: Option, } +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = correspondents)] +pub struct Correspondent { + pub id: Uuid, + pub name: String, + pub metadata: serde_json::Value, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = correspondents)] +pub struct NewCorrespondent { + pub id: Uuid, + pub name: String, + pub metadata: serde_json::Value, +} + +#[derive(Debug, Clone, Queryable, Associations)] +#[diesel(table_name = document_correspondents)] +#[diesel(belongs_to(Document))] +#[diesel(belongs_to(Correspondent))] +#[diesel(primary_key(document_id, correspondent_id, role))] +pub struct DocumentCorrespondent { + pub document_id: Uuid, + pub correspondent_id: Uuid, + pub role: String, + pub assigned_at: NaiveDateTime, + pub assigned_by: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_correspondents)] +pub struct NewDocumentCorrespondent { + pub document_id: Uuid, + pub correspondent_id: Uuid, + pub role: String, + pub assigned_by: Option, +} + #[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[diesel(table_name = refresh_tokens)] #[diesel(belongs_to(User))] diff --git a/backend/src/routes/correspondents.rs b/backend/src/routes/correspondents.rs new file mode 100644 index 0000000..24ac7a9 --- /dev/null +++ b/backend/src/routes/correspondents.rs @@ -0,0 +1,258 @@ +use std::collections::{BTreeMap, HashMap}; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + 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::{ + error::{AppError, AppResult}, + models::{Correspondent, NewCorrespondent}, + schema::{correspondents, document_correspondents}, + state::AppState, +}; + +use super::documents::to_iso; + +#[derive(Serialize)] +pub struct CorrespondentUsage { + pub total: i64, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub by_role: BTreeMap, +} + +#[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( + State(state): State, +) -> AppResult>> { + let mut conn = state.db()?; + + let correspondents_list: Vec = correspondents::table + .order(correspondents::name.asc()) + .load(&mut conn)?; + + let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table + .group_by(( + document_correspondents::correspondent_id, + document_correspondents::role, + )) + .select(( + document_correspondents::correspondent_id, + document_correspondents::role, + count_star(), + )) + .load(&mut conn)?; + + let mut usage_map: HashMap> = HashMap::new(); + for (correspondent_id, role, count) in usage_rows { + usage_map + .entry(correspondent_id) + .or_default() + .insert(role, 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)); + } + + Ok(Json(response)) +} + +pub async fn create_correspondent( + State(state): State, + 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, + }; + + let mut conn = state.db()?; + 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).first(&mut conn)?; + Ok(Json(build_summary(correspondent, BTreeMap::new()))) +} + +pub async fn update_correspondent( + State(state): State, + Path(correspondent_id): Path, + Json(payload): Json, +) -> AppResult> { + let mut conn = state.db()?; + let existing: Correspondent = correspondents::table + .find(correspondent_id) + .first(&mut conn)?; + + 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)) + .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() { + return Err(AppError::bad_request("no changes supplied")); + } + + 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)) + .set((&changeset, correspondents::updated_at.eq(now))) + .execute(&mut conn)?; + + let updated: Correspondent = correspondents::table + .find(correspondent_id) + .first(&mut conn)?; + let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?; + Ok(Json(build_summary(updated, usage))) +} + +pub async fn delete_correspondent( + State(state): State, + Path(correspondent_id): Path, +) -> AppResult { + let mut conn = state.db()?; + + let usage: i64 = document_correspondents::table + .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.find(correspondent_id)).execute(&mut conn)?; + if deleted == 0 { + return Err(AppError::not_found()); + } + Ok(StatusCode::NO_CONTENT) +} + +fn build_summary( + correspondent: Correspondent, + role_counts: BTreeMap, +) -> CorrespondentSummary { + let total = role_counts.values().copied().sum(); + 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, + }, + } +} + +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, + correspondent_id: Uuid, +) -> AppResult> { + let rows: Vec<(String, i64)> = document_correspondents::table + .filter(document_correspondents::correspondent_id.eq(correspondent_id)) + .group_by(document_correspondents::role) + .select((document_correspondents::role, count_star())) + .load(conn)?; + + let mut map = BTreeMap::new(); + for (role, count) in rows { + map.insert(role, count); + } + Ok(map) +} diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index c231349..0f5f594 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, path::Path as FsPath, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + path::Path as FsPath, + time::Duration, +}; use axum::extract::{Json, Multipart, Path, Query, State}; use axum::http::StatusCode; @@ -16,15 +20,25 @@ use crate::auth::AuthenticatedUser; use crate::error::{AppError, AppResult}; use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}; use crate::models::{ - Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag, + Correspondent, Document, DocumentAsset, DocumentCorrespondent, DocumentVersion, NewDocument, + NewDocumentCorrespondent, NewDocumentTag, NewDocumentVersion, Tag, }; use crate::schema::{ - document_assets, document_tags, document_versions, documents, folders, - refresh_tokens::dsl as refresh_dsl, tags, + correspondents, document_assets, document_correspondents, document_tags, document_versions, + documents, folders, refresh_tokens::dsl as refresh_dsl, tags, }; use crate::state::AppState; const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; +pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"]; + +fn normalize_role(value: &str) -> String { + value.trim().to_lowercase() +} + +fn is_valid_correspondent_role(role: &str) -> bool { + CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role) +} fn inline_content_disposition(filename: &str) -> Option { if filename.is_empty() { @@ -108,6 +122,15 @@ pub struct DocumentCurrentVersionResponse { pub download_path: String, } +#[derive(Serialize, Clone)] +pub struct DocumentCorrespondentResponse { + pub id: Uuid, + pub name: String, + pub role: String, + pub metadata: Value, + pub assigned_at: String, +} + #[derive(Serialize)] pub struct DocumentResponse { pub id: Uuid, @@ -122,6 +145,8 @@ pub struct DocumentResponse { pub issued_at: Option, pub metadata: Value, pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub correspondents: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub current_version: Option, } @@ -180,6 +205,24 @@ pub struct BulkTagResponse { pub removed: usize, } +#[derive(Deserialize)] +pub struct CorrespondentAssignmentInput { + pub correspondent_id: Uuid, + pub role: String, +} + +#[derive(Deserialize)] +pub struct AssignCorrespondentsRequest { + pub assignments: Vec, + #[serde(default)] + pub replace: bool, +} + +#[derive(Deserialize)] +pub struct CorrespondentRoleQuery { + pub role: String, +} + #[derive(Deserialize)] pub struct BulkReanalyzeSelectionRequest { pub document_ids: Vec, @@ -242,6 +285,7 @@ pub async fn list_documents( let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; + let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; drop(conn); let primary_versions = load_primary_assets(&state, &docs).await?; @@ -249,12 +293,14 @@ pub async fn list_documents( let mut response = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); + let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); let current_version = primary_versions.get(&doc.id).cloned(); response.push(to_document_response( &state, user.user_id, doc, tags, + correspondents, current_version, )?); } @@ -279,6 +325,7 @@ pub async fn get_document( .first(&mut conn)?; let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; + let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?; let version_id = current_version.id; drop(conn); @@ -291,6 +338,7 @@ pub async fn get_document( user.user_id, doc, tags_map.get(&document_id).cloned(), + correspondents_map.remove(&document_id).unwrap_or_default(), Some((version_response, assets)), )?, })) @@ -689,6 +737,7 @@ pub async fn update_document( .first(&mut conn)?; let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; + let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?; let version_id = current_version.id; drop(conn); @@ -701,6 +750,7 @@ pub async fn update_document( user.user_id, document, tags_map.get(&document_id).cloned(), + correspondents_map.remove(&document_id).unwrap_or_default(), Some((version_response, assets)), )?, })) @@ -775,6 +825,157 @@ pub async fn bulk_move_documents( Ok((StatusCode::OK, Json(BulkMoveResponse { updated }))) } +pub async fn assign_correspondents( + State(state): State, + Path(document_id): Path, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult { + if payload.assignments.is_empty() { + return Err(AppError::bad_request("assignments must not be empty")); + } + + let mut unique_pairs: HashSet<(Uuid, String)> = HashSet::new(); + let mut normalized_pairs: Vec<(Uuid, String)> = Vec::new(); + let mut role_set: HashSet = HashSet::new(); + let mut correspondent_ids: HashSet = HashSet::new(); + + for assignment in &payload.assignments { + let role = normalize_role(&assignment.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(", ") + ))); + } + + 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() { + return Err(AppError::bad_request( + "assignments must contain at least one unique correspondent/role pair", + )); + } + + let correspondents_vec: Vec = correspondent_ids.into_iter().collect(); + let roles_vec: Vec = role_set.into_iter().collect(); + let replace = payload.replace; + let user_id = user.user_id; + + let mut conn = state.db()?; + conn.transaction::<(), AppError, _>(|conn| { + let document: Document = documents::table.find(document_id).first(conn)?; + if document.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + if !correspondents_vec.is_empty() { + let existing: Vec = correspondents::table + .filter(correspondents::id.eq_any(&correspondents_vec)) + .load(conn)?; + if existing.len() != correspondents_vec.len() { + return Err(AppError::bad_request( + "one or more correspondents do not exist", + )); + } + } + + let mut changed = false; + if replace { + let deleted = diesel::delete( + document_correspondents::table + .filter(document_correspondents::document_id.eq(document_id)) + .filter(document_correspondents::role.eq_any(&roles_vec)), + ) + .execute(conn)?; + if deleted > 0 { + changed = true; + } + } + + let new_rows: Vec = normalized_pairs + .iter() + .map(|(correspondent_id, role)| NewDocumentCorrespondent { + document_id, + correspondent_id: *correspondent_id, + role: role.clone(), + assigned_by: Some(user_id), + }) + .collect(); + + if !new_rows.is_empty() { + let inserted = diesel::insert_into(document_correspondents::table) + .values(&new_rows) + .on_conflict_do_nothing() + .execute(conn)?; + if inserted > 0 { + changed = true; + } + } + + if changed { + diesel::update(documents::table.find(document_id)) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(()) + })?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn remove_correspondent( + State(state): State, + Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, + Query(query): Query, +) -> AppResult { + 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 mut conn = state.db()?; + let document: Document = documents::table.find(document_id).first(&mut conn)?; + if document.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + let deleted = diesel::delete( + document_correspondents::table + .filter(document_correspondents::document_id.eq(document_id)) + .filter(document_correspondents::correspondent_id.eq(correspondent_id)) + .filter(document_correspondents::role.eq(&role)), + ) + .execute(&mut conn)?; + + if deleted == 0 { + return Err(AppError::not_found()); + } + + diesel::update(documents::table.find(document_id)) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(&mut conn)?; + + Ok(StatusCode::NO_CONTENT) +} + pub async fn assign_tags( State(state): State, Path(document_id): Path, @@ -975,7 +1176,10 @@ async fn process_upload( } let tags_map = load_tags_for_documents(&mut conn, &[document.id])?; + let mut correspondents_map = + load_correspondents_for_documents(&mut conn, &[document.id])?; let tags = tags_map.get(&document.id).cloned(); + let correspondents = correspondents_map.remove(&document.id).unwrap_or_default(); drop(conn); let assets = load_asset_responses(state, version.id).await?; let version_response = to_version_response(version.clone()); @@ -993,6 +1197,7 @@ async fn process_upload( user_id, document, tags, + correspondents, Some((version_response, assets)), )?, }, @@ -1068,6 +1273,7 @@ async fn process_upload( user_id, document, None, + Vec::new(), Some((to_version_response(version.clone()), Vec::new())), )?, }; @@ -1126,6 +1332,40 @@ pub(crate) fn load_tags_for_documents( Ok(map) } +pub(crate) fn load_correspondents_for_documents( + conn: &mut PgConnection, + document_ids: &[Uuid], +) -> AppResult>> { + if document_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table + .inner_join(correspondents::table) + .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)?; + + let mut map: HashMap> = HashMap::new(); + for (assignment, correspondent) in rows { + map.entry(assignment.document_id) + .or_default() + .push(DocumentCorrespondentResponse { + id: correspondent.id, + name: correspondent.name, + role: assignment.role, + metadata: correspondent.metadata, + assigned_at: to_iso(assignment.assigned_at), + }); + } + + Ok(map) +} + pub(crate) async fn load_primary_assets( state: &AppState, documents: &[Document], @@ -1191,6 +1431,7 @@ pub(crate) fn to_document_response( user_id: Uuid, doc: Document, tags: Option>, + correspondents: Vec, current_version: Option<(DocumentVersionResponse, Vec)>, ) -> AppResult { let current_version = if let Some((version, assets)) = current_version { @@ -1221,6 +1462,7 @@ pub(crate) fn to_document_response( .into_iter() .map(TagResponse::from) .collect(), + correspondents, current_version, }) } diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index cafffbb..e0d0658 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -19,7 +19,8 @@ use crate::{ }; use super::documents::{ - load_primary_assets, load_tags_for_documents, to_document_response, to_iso, DocumentResponse, + load_correspondents_for_documents, load_primary_assets, load_tags_for_documents, + to_document_response, to_iso, DocumentResponse, }; const QUICKWIT_MAX_HITS: usize = 200; @@ -213,6 +214,7 @@ pub async fn list_folder_contents( let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; + let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; drop(conn); let primary_versions = load_primary_assets(&state, &docs).await?; @@ -220,12 +222,14 @@ pub async fn list_folder_contents( let mut documents = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); + let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); let current_version = primary_versions.get(&doc.id).cloned(); documents.push(to_document_response( &state, user.user_id, doc, tags, + correspondents, current_version, )?); } @@ -397,18 +401,21 @@ pub async fn search_documents( let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; + let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; drop(conn); let primary_versions = load_primary_assets(&state, &docs).await?; let mut response = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); + let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); let current_version = primary_versions.get(&doc.id).cloned(); response.push(to_document_response( &state, user.user_id, doc, tags, + correspondents, current_version, )?); } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 5ed956c..bea53cd 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -10,6 +10,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer}; use crate::{auth::AuthenticatedUser, state::AppState}; pub mod auth; +pub mod correspondents; pub mod documents; pub mod folders; pub mod health; @@ -76,7 +77,15 @@ pub fn create_router(state: AppState) -> Router<()> { ) .route("/:id/folder", patch(documents::move_document)) .route("/:id/tags", post(documents::assign_tags)) - .route("/:id/tags/:tag_id", delete(documents::remove_tag)); + .route("/:id/tags/:tag_id", delete(documents::remove_tag)) + .route( + "/:id/correspondents", + post(documents::assign_correspondents), + ) + .route( + "/:id/correspondents/:correspondent_id", + delete(documents::remove_correspondent), + ); let download_routes = Router::new().route("/download/:token", get(documents::download_with_token)); @@ -93,13 +102,25 @@ pub fn create_router(state: AppState) -> Router<()> { let tags_routes = Router::new() .route("/", get(tags::list_tags).post(tags::create_tag)) - .route("/:id", patch(tags::update_tag)); + .route("/:id", patch(tags::update_tag).delete(tags::delete_tag)); + + let correspondents_routes = Router::new() + .route( + "/", + get(correspondents::list_correspondents).post(correspondents::create_correspondent), + ) + .route( + "/:id", + patch(correspondents::update_correspondent) + .delete(correspondents::delete_correspondent), + ); let protected_state = state.clone(); let protected_routes = Router::new() .nest("/api/documents", documents_routes) .nest("/api/folders", folders_routes) .nest("/api/tags", tags_routes) + .nest("/api/correspondents", correspondents_routes) .layer(middleware::from_extractor_with_state::(protected_state)); Router::new() diff --git a/backend/src/routes/tags.rs b/backend/src/routes/tags.rs index c9e22e1..0d50f1d 100644 --- a/backend/src/routes/tags.rs +++ b/backend/src/routes/tags.rs @@ -1,6 +1,7 @@ use crate::utils::json::{classify_nullable, NullableValue}; use axum::{ extract::{Path, State}, + http::StatusCode, Json, }; use diesel::{dsl::count_star, prelude::*}; @@ -189,3 +190,28 @@ pub async fn update_tag( usage_count, })) } + +pub async fn delete_tag( + State(state): State, + Path(tag_id): Path, +) -> AppResult { + let mut conn = state.db()?; + + let usage: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .select(count_star()) + .first(&mut conn)?; + + if usage > 0 { + return Err(AppError::bad_request( + "cannot delete tag that is still assigned to documents", + )); + } + + let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?; + if deleted == 0 { + return Err(AppError::not_found()); + } + + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 9dd0f66..625e922 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -1,5 +1,16 @@ // @generated automatically by Diesel CLI. +diesel::table! { + correspondents (id) { + id -> Uuid, + #[max_length = 255] + name -> Varchar, + metadata -> Jsonb, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + diesel::table! { document_assets (id) { id -> Uuid, @@ -12,6 +23,17 @@ diesel::table! { } } +diesel::table! { + document_correspondents (document_id, correspondent_id, role) { + document_id -> Uuid, + correspondent_id -> Uuid, + #[max_length = 32] + role -> Varchar, + assigned_at -> Timestamptz, + assigned_by -> Nullable, + } +} + diesel::table! { document_tags (document_id, tag_id) { document_id -> Uuid, @@ -123,6 +145,9 @@ diesel::table! { } diesel::joinable!(document_assets -> document_versions (document_version_id)); +diesel::joinable!(document_correspondents -> correspondents (correspondent_id)); +diesel::joinable!(document_correspondents -> documents (document_id)); +diesel::joinable!(document_correspondents -> users (assigned_by)); diesel::joinable!(document_tags -> documents (document_id)); diesel::joinable!(document_tags -> tags (tag_id)); diesel::joinable!(document_tags -> users (assigned_by)); @@ -130,7 +155,9 @@ diesel::joinable!(documents -> folders (folder_id)); diesel::joinable!(refresh_tokens -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( + correspondents, document_assets, + document_correspondents, document_tags, document_versions, documents, diff --git a/docs/api.txt b/docs/api.txt index 6282896..1d7ad90 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -16,7 +16,7 @@ Health Documents --------- -- GET /api/documents - List documents, optionally filtered by `folder_id` and `include_deleted`. +- GET /api/documents - List documents, optionally filtered by `folder_id` and `include_deleted`; each entry includes tags, correspondent assignments, and current version info. - POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields). - POST /api/documents/reanalyze - Queue re-analysis for every non-deleted document. - POST /api/documents/bulk/move - Move multiple documents to a target folder. @@ -29,6 +29,8 @@ Documents - PATCH /api/documents/:id/folder - Move a document to another folder. - POST /api/documents/:id/tags - Assign one or more tags to a document. - DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document. +- POST /api/documents/:id/correspondents - Assign correspondents to roles (`assignments[]` with `correspondent_id` and `role`; optional `replace=true` overwrites existing assignments for those roles). Valid roles: `sender`, `receiver`, `other`. +- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment (requires `role` query string). Document Assets --------------- @@ -54,3 +56,11 @@ Tags - GET /api/tags - List all tags with usage counts. - POST /api/tags - Create a new tag. - PATCH /api/tags/:id - Update a tag's label or color. +- DELETE /api/tags/:id - Remove a tag; fails with 400 if still assigned to any document. + +Correspondents +-------------- +- GET /api/correspondents - List correspondents with usage totals and per-role counts (roles: `sender`, `receiver`, `other`). +- POST /api/correspondents - Create a correspondent (name + optional metadata JSON). +- PATCH /api/correspondents/:id - Update name and/or metadata. +- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.