backend: correspondents
This commit is contained in:
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -188,6 +188,46 @@ pub struct NewDocumentTag {
|
|||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
#[diesel(table_name = refresh_tokens)]
|
#[diesel(table_name = refresh_tokens)]
|
||||||
#[diesel(belongs_to(User))]
|
#[diesel(belongs_to(User))]
|
||||||
|
|||||||
@@ -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<String, 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(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
|
||||||
|
let mut conn = state.db()?;
|
||||||
|
|
||||||
|
let correspondents_list: Vec<Correspondent> = 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<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 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<AppState>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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<AppState>,
|
||||||
|
Path(correspondent_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateCorrespondentRequest>,
|
||||||
|
) -> AppResult<Json<CorrespondentSummary>> {
|
||||||
|
let mut conn = state.db()?;
|
||||||
|
let existing: Correspondent = correspondents::table
|
||||||
|
.find(correspondent_id)
|
||||||
|
.first(&mut conn)?;
|
||||||
|
|
||||||
|
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))
|
||||||
|
.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() {
|
||||||
|
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<AppState>,
|
||||||
|
Path(correspondent_id): Path<Uuid>,
|
||||||
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
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<String, i64>,
|
||||||
|
) -> 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>) -> 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<BTreeMap<String, i64>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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::extract::{Json, Multipart, Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
@@ -16,15 +20,25 @@ use crate::auth::AuthenticatedUser;
|
|||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
|
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag,
|
Correspondent, Document, DocumentAsset, DocumentCorrespondent, DocumentVersion, NewDocument,
|
||||||
|
NewDocumentCorrespondent, NewDocumentTag, NewDocumentVersion, Tag,
|
||||||
};
|
};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
document_assets, document_tags, document_versions, documents, folders,
|
correspondents, document_assets, document_correspondents, document_tags, document_versions,
|
||||||
refresh_tokens::dsl as refresh_dsl, tags,
|
documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
|
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<String> {
|
fn inline_content_disposition(filename: &str) -> Option<String> {
|
||||||
if filename.is_empty() {
|
if filename.is_empty() {
|
||||||
@@ -108,6 +122,15 @@ pub struct DocumentCurrentVersionResponse {
|
|||||||
pub download_path: String,
|
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)]
|
#[derive(Serialize)]
|
||||||
pub struct DocumentResponse {
|
pub struct DocumentResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -122,6 +145,8 @@ pub struct DocumentResponse {
|
|||||||
pub issued_at: Option<String>,
|
pub issued_at: Option<String>,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub tags: Vec<TagResponse>,
|
pub tags: Vec<TagResponse>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub correspondents: Vec<DocumentCorrespondentResponse>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub current_version: Option<DocumentCurrentVersionResponse>,
|
pub current_version: Option<DocumentCurrentVersionResponse>,
|
||||||
}
|
}
|
||||||
@@ -180,6 +205,24 @@ pub struct BulkTagResponse {
|
|||||||
pub removed: usize,
|
pub removed: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CorrespondentAssignmentInput {
|
||||||
|
pub correspondent_id: Uuid,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AssignCorrespondentsRequest {
|
||||||
|
pub assignments: Vec<CorrespondentAssignmentInput>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub replace: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CorrespondentRoleQuery {
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct BulkReanalyzeSelectionRequest {
|
pub struct BulkReanalyzeSelectionRequest {
|
||||||
pub document_ids: Vec<Uuid>,
|
pub document_ids: Vec<Uuid>,
|
||||||
@@ -242,6 +285,7 @@ pub async fn list_documents(
|
|||||||
|
|
||||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
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);
|
drop(conn);
|
||||||
|
|
||||||
let primary_versions = load_primary_assets(&state, &docs).await?;
|
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());
|
let mut response = Vec::with_capacity(doc_ids.len());
|
||||||
for doc in docs {
|
for doc in docs {
|
||||||
let tags = tags_map.get(&doc.id).cloned();
|
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();
|
let current_version = primary_versions.get(&doc.id).cloned();
|
||||||
response.push(to_document_response(
|
response.push(to_document_response(
|
||||||
&state,
|
&state,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
doc,
|
doc,
|
||||||
tags,
|
tags,
|
||||||
|
correspondents,
|
||||||
current_version,
|
current_version,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
@@ -279,6 +325,7 @@ pub async fn get_document(
|
|||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
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;
|
let version_id = current_version.id;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
@@ -291,6 +338,7 @@ pub async fn get_document(
|
|||||||
user.user_id,
|
user.user_id,
|
||||||
doc,
|
doc,
|
||||||
tags_map.get(&document_id).cloned(),
|
tags_map.get(&document_id).cloned(),
|
||||||
|
correspondents_map.remove(&document_id).unwrap_or_default(),
|
||||||
Some((version_response, assets)),
|
Some((version_response, assets)),
|
||||||
)?,
|
)?,
|
||||||
}))
|
}))
|
||||||
@@ -689,6 +737,7 @@ pub async fn update_document(
|
|||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
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;
|
let version_id = current_version.id;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
@@ -701,6 +750,7 @@ pub async fn update_document(
|
|||||||
user.user_id,
|
user.user_id,
|
||||||
document,
|
document,
|
||||||
tags_map.get(&document_id).cloned(),
|
tags_map.get(&document_id).cloned(),
|
||||||
|
correspondents_map.remove(&document_id).unwrap_or_default(),
|
||||||
Some((version_response, assets)),
|
Some((version_response, assets)),
|
||||||
)?,
|
)?,
|
||||||
}))
|
}))
|
||||||
@@ -775,6 +825,157 @@ pub async fn bulk_move_documents(
|
|||||||
Ok((StatusCode::OK, Json(BulkMoveResponse { updated })))
|
Ok((StatusCode::OK, Json(BulkMoveResponse { updated })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn assign_correspondents(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(document_id): Path<Uuid>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Json(payload): Json<AssignCorrespondentsRequest>,
|
||||||
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
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<String> = HashSet::new();
|
||||||
|
let mut correspondent_ids: HashSet<Uuid> = 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<Uuid> = correspondent_ids.into_iter().collect();
|
||||||
|
let roles_vec: Vec<String> = 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<Correspondent> = 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<NewDocumentCorrespondent> = 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<AppState>,
|
||||||
|
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
|
||||||
|
Query(query): Query<CorrespondentRoleQuery>,
|
||||||
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
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(
|
pub async fn assign_tags(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(document_id): Path<Uuid>,
|
Path(document_id): Path<Uuid>,
|
||||||
@@ -975,7 +1176,10 @@ async fn process_upload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &[document.id])?;
|
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 tags = tags_map.get(&document.id).cloned();
|
||||||
|
let correspondents = correspondents_map.remove(&document.id).unwrap_or_default();
|
||||||
drop(conn);
|
drop(conn);
|
||||||
let assets = load_asset_responses(state, version.id).await?;
|
let assets = load_asset_responses(state, version.id).await?;
|
||||||
let version_response = to_version_response(version.clone());
|
let version_response = to_version_response(version.clone());
|
||||||
@@ -993,6 +1197,7 @@ async fn process_upload(
|
|||||||
user_id,
|
user_id,
|
||||||
document,
|
document,
|
||||||
tags,
|
tags,
|
||||||
|
correspondents,
|
||||||
Some((version_response, assets)),
|
Some((version_response, assets)),
|
||||||
)?,
|
)?,
|
||||||
},
|
},
|
||||||
@@ -1068,6 +1273,7 @@ async fn process_upload(
|
|||||||
user_id,
|
user_id,
|
||||||
document,
|
document,
|
||||||
None,
|
None,
|
||||||
|
Vec::new(),
|
||||||
Some((to_version_response(version.clone()), Vec::new())),
|
Some((to_version_response(version.clone()), Vec::new())),
|
||||||
)?,
|
)?,
|
||||||
};
|
};
|
||||||
@@ -1126,6 +1332,40 @@ pub(crate) fn load_tags_for_documents(
|
|||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_correspondents_for_documents(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
document_ids: &[Uuid],
|
||||||
|
) -> AppResult<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
|
||||||
|
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<Uuid, Vec<DocumentCorrespondentResponse>> = 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(
|
pub(crate) async fn load_primary_assets(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
documents: &[Document],
|
documents: &[Document],
|
||||||
@@ -1191,6 +1431,7 @@ pub(crate) fn to_document_response(
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
doc: Document,
|
doc: Document,
|
||||||
tags: Option<Vec<Tag>>,
|
tags: Option<Vec<Tag>>,
|
||||||
|
correspondents: Vec<DocumentCorrespondentResponse>,
|
||||||
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
|
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
|
||||||
) -> AppResult<DocumentResponse> {
|
) -> AppResult<DocumentResponse> {
|
||||||
let current_version = if let Some((version, assets)) = current_version {
|
let current_version = if let Some((version, assets)) = current_version {
|
||||||
@@ -1221,6 +1462,7 @@ pub(crate) fn to_document_response(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(TagResponse::from)
|
.map(TagResponse::from)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
correspondents,
|
||||||
current_version,
|
current_version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::documents::{
|
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;
|
const QUICKWIT_MAX_HITS: usize = 200;
|
||||||
@@ -213,6 +214,7 @@ pub async fn list_folder_contents(
|
|||||||
|
|
||||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
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);
|
drop(conn);
|
||||||
|
|
||||||
let primary_versions = load_primary_assets(&state, &docs).await?;
|
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());
|
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||||
for doc in docs {
|
for doc in docs {
|
||||||
let tags = tags_map.get(&doc.id).cloned();
|
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();
|
let current_version = primary_versions.get(&doc.id).cloned();
|
||||||
documents.push(to_document_response(
|
documents.push(to_document_response(
|
||||||
&state,
|
&state,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
doc,
|
doc,
|
||||||
tags,
|
tags,
|
||||||
|
correspondents,
|
||||||
current_version,
|
current_version,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
@@ -397,18 +401,21 @@ pub async fn search_documents(
|
|||||||
|
|
||||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
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);
|
drop(conn);
|
||||||
|
|
||||||
let primary_versions = load_primary_assets(&state, &docs).await?;
|
let primary_versions = load_primary_assets(&state, &docs).await?;
|
||||||
let mut response = Vec::with_capacity(doc_ids.len());
|
let mut response = Vec::with_capacity(doc_ids.len());
|
||||||
for doc in docs {
|
for doc in docs {
|
||||||
let tags = tags_map.get(&doc.id).cloned();
|
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();
|
let current_version = primary_versions.get(&doc.id).cloned();
|
||||||
response.push(to_document_response(
|
response.push(to_document_response(
|
||||||
&state,
|
&state,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
doc,
|
doc,
|
||||||
tags,
|
tags,
|
||||||
|
correspondents,
|
||||||
current_version,
|
current_version,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer};
|
|||||||
use crate::{auth::AuthenticatedUser, state::AppState};
|
use crate::{auth::AuthenticatedUser, state::AppState};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod correspondents;
|
||||||
pub mod documents;
|
pub mod documents;
|
||||||
pub mod folders;
|
pub mod folders;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
@@ -76,7 +77,15 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
)
|
)
|
||||||
.route("/:id/folder", patch(documents::move_document))
|
.route("/:id/folder", patch(documents::move_document))
|
||||||
.route("/:id/tags", post(documents::assign_tags))
|
.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 =
|
let download_routes =
|
||||||
Router::new().route("/download/:token", get(documents::download_with_token));
|
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()
|
let tags_routes = Router::new()
|
||||||
.route("/", get(tags::list_tags).post(tags::create_tag))
|
.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_state = state.clone();
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
.nest("/api/documents", documents_routes)
|
.nest("/api/documents", documents_routes)
|
||||||
.nest("/api/folders", folders_routes)
|
.nest("/api/folders", folders_routes)
|
||||||
.nest("/api/tags", tags_routes)
|
.nest("/api/tags", tags_routes)
|
||||||
|
.nest("/api/correspondents", correspondents_routes)
|
||||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::utils::json::{classify_nullable, NullableValue};
|
use crate::utils::json::{classify_nullable, NullableValue};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use diesel::{dsl::count_star, prelude::*};
|
use diesel::{dsl::count_star, prelude::*};
|
||||||
@@ -189,3 +190,28 @@ pub async fn update_tag(
|
|||||||
usage_count,
|
usage_count,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(tag_id): Path<Uuid>,
|
||||||
|
) -> AppResult<impl axum::response::IntoResponse> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
// @generated automatically by Diesel CLI.
|
// @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! {
|
diesel::table! {
|
||||||
document_assets (id) {
|
document_assets (id) {
|
||||||
id -> Uuid,
|
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<Uuid>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
document_tags (document_id, tag_id) {
|
document_tags (document_id, tag_id) {
|
||||||
document_id -> Uuid,
|
document_id -> Uuid,
|
||||||
@@ -123,6 +145,9 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::joinable!(document_assets -> document_versions (document_version_id));
|
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 -> documents (document_id));
|
||||||
diesel::joinable!(document_tags -> tags (tag_id));
|
diesel::joinable!(document_tags -> tags (tag_id));
|
||||||
diesel::joinable!(document_tags -> users (assigned_by));
|
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::joinable!(refresh_tokens -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
|
correspondents,
|
||||||
document_assets,
|
document_assets,
|
||||||
|
document_correspondents,
|
||||||
document_tags,
|
document_tags,
|
||||||
document_versions,
|
document_versions,
|
||||||
documents,
|
documents,
|
||||||
|
|||||||
+11
-1
@@ -16,7 +16,7 @@ Health
|
|||||||
|
|
||||||
Documents
|
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 - 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/reanalyze - Queue re-analysis for every non-deleted document.
|
||||||
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
- 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.
|
- PATCH /api/documents/:id/folder - Move a document to another folder.
|
||||||
- POST /api/documents/:id/tags - Assign one or more tags to a document.
|
- 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.
|
- 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
|
Document Assets
|
||||||
---------------
|
---------------
|
||||||
@@ -54,3 +56,11 @@ Tags
|
|||||||
- GET /api/tags - List all tags with usage counts.
|
- GET /api/tags - List all tags with usage counts.
|
||||||
- POST /api/tags - Create a new tag.
|
- POST /api/tags - Create a new tag.
|
||||||
- PATCH /api/tags/:id - Update a tag's label or color.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user