Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c8cd98aac | ||
|
|
3db221cce4 | ||
|
|
60939306e7 | ||
|
|
80161fe86e | ||
|
|
4c36ea2e9a | ||
|
|
32432026db |
@@ -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,19 +102,31 @@ 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)
|
||||||
.route("/api/health", get(health::health_check))
|
.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()
|
||||||
.merge(download_routes)
|
.merge(download_routes)
|
||||||
.nest("/api/auth", auth_routes)
|
.nest("/api/auth", auth_routes)
|
||||||
|
.route("/api/health", get(health::health_check))
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
Papercrate REST API
|
||||||
|
===================
|
||||||
|
|
||||||
|
Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <token>` header.
|
||||||
|
|
||||||
|
Authentication
|
||||||
|
--------------
|
||||||
|
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public).
|
||||||
|
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie).
|
||||||
|
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
||||||
|
- GET /api/auth/me - Return the authenticated principal payload.
|
||||||
|
|
||||||
|
Health
|
||||||
|
------
|
||||||
|
- GET /api/health - Lightweight liveness probe (no authentication required).
|
||||||
|
|
||||||
|
Documents
|
||||||
|
---------
|
||||||
|
- 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.
|
||||||
|
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
|
||||||
|
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
|
||||||
|
- GET /api/documents/:id - Retrieve metadata and current version details for a document.
|
||||||
|
- PATCH /api/documents/:id - Update document metadata (currently title).
|
||||||
|
- DELETE /api/documents/:id - Soft-delete a document.
|
||||||
|
- GET /api/documents/:id/download - Create a pre-signed download URL for the current version.
|
||||||
|
- 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
|
||||||
|
---------------
|
||||||
|
- GET /api/documents/:id/assets - List generated assets for the current version.
|
||||||
|
- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag.
|
||||||
|
- GET /api/documents/:id/assets/:asset_id - Fetch metadata and a pre-signed URL for a specific asset.
|
||||||
|
|
||||||
|
Downloads
|
||||||
|
---------
|
||||||
|
- GET /download/:token - Follow a one-time download token; redirects to a pre-signed URL (public token required).
|
||||||
|
|
||||||
|
Folders
|
||||||
|
-------
|
||||||
|
- POST /api/folders - Create a folder (optionally under a parent).
|
||||||
|
- POST /api/folders/path - Ensure a nested folder path exists, creating missing segments.
|
||||||
|
- GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root.
|
||||||
|
- GET /api/folders/:id/documents - Search within a folder tree with optional `query` and `tags` filters.
|
||||||
|
- DELETE /api/folders/:id - Soft-delete a folder.
|
||||||
|
- PATCH /api/folders/:id - Change a folder's parent.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -275,7 +275,13 @@ const DocumentsTable = ({
|
|||||||
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
|
focusedRowKey === `folder:${folder.id}` ? ' focused' : ''
|
||||||
}`}
|
}`}
|
||||||
id={`folder-row-${folder.id}`}
|
id={`folder-row-${folder.id}`}
|
||||||
onClick={() => onFolderSelect(folder.id)}
|
onClick={() => {
|
||||||
|
onFolderSelect(folder.id);
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
onFocusedRowChange?.(`folder:${folder.id}`);
|
||||||
|
}}
|
||||||
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
||||||
onDragLeave={onFolderDragLeave}
|
onDragLeave={onFolderDragLeave}
|
||||||
onDrop={(event) => onFolderDrop(event, folder.id)}
|
onDrop={(event) => onFolderDrop(event, folder.id)}
|
||||||
|
|||||||
+917
-375
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { ChevronIcon, FolderIcon, TagIcon, TrashIcon } from '../ui/icons';
|
import {
|
||||||
|
ChevronIcon,
|
||||||
|
FolderIcon,
|
||||||
|
TagIcon,
|
||||||
|
TrashIcon,
|
||||||
|
CorrespondentIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
|
||||||
const FolderNode = ({
|
const FolderNode = ({
|
||||||
node,
|
node,
|
||||||
@@ -56,12 +62,14 @@ const FolderNode = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{!isRoot && (
|
||||||
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
<span
|
||||||
onClick={handleToggleClick}
|
className={`toggle${showChevron ? '' : ' invisible'}${isExpanded ? ' expanded' : ''}`}
|
||||||
>
|
onClick={handleToggleClick}
|
||||||
{icon}
|
>
|
||||||
</span>
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="name">
|
<span className="name">
|
||||||
<FolderIcon className="folder-icon" />
|
<FolderIcon className="folder-icon" />
|
||||||
{node.name}
|
{node.name}
|
||||||
@@ -104,10 +112,15 @@ const Sidebar = ({
|
|||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onShowTags,
|
onShowTags,
|
||||||
tags = [],
|
tags = [],
|
||||||
|
onShowCorrespondents,
|
||||||
|
correspondents = [],
|
||||||
}) => {
|
}) => {
|
||||||
const handleShowTags = onShowTags || (() => {});
|
const handleShowTags = onShowTags || (() => {});
|
||||||
const tagsRouteMatch = useMatch('/tags');
|
const tagsRouteMatch = useMatch('/tags');
|
||||||
const isTagsRoute = Boolean(tagsRouteMatch);
|
const isTagsRoute = Boolean(tagsRouteMatch);
|
||||||
|
const handleShowCorrespondents = onShowCorrespondents || (() => {});
|
||||||
|
const correspondentsRouteMatch = useMatch('/correspondents');
|
||||||
|
const isCorrespondentsRoute = Boolean(correspondentsRouteMatch);
|
||||||
|
|
||||||
const renderNodes = useCallback(
|
const renderNodes = useCallback(
|
||||||
(ids, depth) =>
|
(ids, depth) =>
|
||||||
@@ -181,6 +194,27 @@ const Sidebar = ({
|
|||||||
<span>All tags</span>
|
<span>All tags</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-section__header">
|
||||||
|
<h3>Correspondents</h3>
|
||||||
|
<span className="meta">{correspondents.length}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`sidebar-item${isCorrespondentsRoute ? ' active' : ''}`}
|
||||||
|
onClick={handleShowCorrespondents}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleShowCorrespondents();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CorrespondentIcon className="sidebar-item__icon" />
|
||||||
|
<span>All correspondents</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { resolveDocumentAssetUrl } from './asset_manager';
|
import { resolveDocumentAssetUrl } from './asset_manager';
|
||||||
|
import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
|
||||||
import './skeuomorphic_ws.css';
|
import './skeuomorphic_ws.css';
|
||||||
|
|
||||||
const ITEM_WIDTH = 220;
|
const ITEM_WIDTH = 220;
|
||||||
@@ -299,15 +300,7 @@ const normalizeColor = (input) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getContrastingTextColor = (hex) => {
|
const getContrastingTextColor = (hex) => getReadableTextColor(hex, { light: '#1f2125' });
|
||||||
if (!hex) return '#1b1f24';
|
|
||||||
const value = parseInt(hex.slice(1), 16);
|
|
||||||
const r = (value >> 16) & 0xff;
|
|
||||||
const g = (value >> 8) & 0xff;
|
|
||||||
const b = value & 0xff;
|
|
||||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
||||||
return luminance > 0.6 ? '#1b1f24' : '#ffffff';
|
|
||||||
};
|
|
||||||
|
|
||||||
const SkeuomorphicWorkspace = ({
|
const SkeuomorphicWorkspace = ({
|
||||||
documents = [],
|
documents = [],
|
||||||
@@ -1555,11 +1548,8 @@ const SkeuomorphicWorkspace = ({
|
|||||||
if (!label) {
|
if (!label) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const randomColor = `#${Math.floor(Math.random() * 0xffffff)
|
|
||||||
.toString(16)
|
|
||||||
.padStart(6, '0')}`;
|
|
||||||
try {
|
try {
|
||||||
await onCreateTag({ label, color: randomColor });
|
await onCreateTag({ label, color: generateRandomTagColor() });
|
||||||
setActiveShelfTagId(null);
|
setActiveShelfTagId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create tag', error);
|
console.error('Failed to create tag', error);
|
||||||
|
|||||||
+77
-1
@@ -13,6 +13,8 @@
|
|||||||
--overlay-backdrop: rgba(30, 33, 39, 0.45);
|
--overlay-backdrop: rgba(30, 33, 39, 0.45);
|
||||||
--overlay-shadow: rgba(30, 33, 39, 0.18);
|
--overlay-shadow: rgba(30, 33, 39, 0.18);
|
||||||
|
|
||||||
|
--app-bar-background: var(--surface-subtle);
|
||||||
|
|
||||||
/* Accent (primary action, selection, focus) */
|
/* Accent (primary action, selection, focus) */
|
||||||
--accent: #3f6ad8;
|
--accent: #3f6ad8;
|
||||||
--accent-hover: #365cb9;
|
--accent-hover: #365cb9;
|
||||||
@@ -209,7 +211,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.6rem 1.5rem 0.4rem;
|
padding: 0.6rem 1.5rem 0.4rem;
|
||||||
background: var(--surface);
|
background: var(--app-bar-background);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -422,6 +424,80 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tags-table__row-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondents-panel .header-actions {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondents-actions__form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondents-actions__form input {
|
||||||
|
min-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill__label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill__label strong {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill__remove {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-pill__remove:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correspondent-form input,
|
||||||
|
.correspondent-form select {
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-workspace__message {
|
.preview-workspace__message {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IconFolderFilled,
|
IconFolderFilled,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconTagFilled,
|
IconTagFilled,
|
||||||
|
IconUserFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
@@ -54,6 +55,15 @@ export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CorrespondentIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||||
|
<IconUserFilled
|
||||||
|
className={composeClassName('icon icon--fill', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<TablerDownload
|
<TablerDownload
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -69,5 +79,6 @@ export default {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
|
CorrespondentIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
||||||
|
|
||||||
|
const gammaEncode = (channel) =>
|
||||||
|
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
||||||
|
|
||||||
|
const oklchToHex = (l, c, h) => {
|
||||||
|
const hr = (h * Math.PI) / 180;
|
||||||
|
const a = Math.cos(hr) * c;
|
||||||
|
const b = Math.sin(hr) * c;
|
||||||
|
|
||||||
|
const l1 = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||||
|
const m1 = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||||
|
const s1 = l - 0.0894841775 * a - 1.291485548 * b;
|
||||||
|
|
||||||
|
const l3 = l1 ** 3;
|
||||||
|
const m3 = m1 ** 3;
|
||||||
|
const s3 = s1 ** 3;
|
||||||
|
|
||||||
|
const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
|
||||||
|
const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
|
||||||
|
const bLin = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3;
|
||||||
|
|
||||||
|
if ([r, g, bLin].some((channel) => channel < 0 || channel > 1)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sr = Math.round(clamp01(gammaEncode(r)) * 255);
|
||||||
|
const sg = Math.round(clamp01(gammaEncode(g)) * 255);
|
||||||
|
const sb = Math.round(clamp01(gammaEncode(bLin)) * 255);
|
||||||
|
|
||||||
|
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const hexToRgb = (input) => {
|
export const hexToRgb = (input) => {
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
const match = HEX_COLOR_PATTERN.exec(input.trim());
|
||||||
@@ -13,28 +46,49 @@ export const hexToRgb = (input) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const relativeLuminance = ({ r, g, b }) => {
|
export const relativeLuminance = ({ r, g, b }) => {
|
||||||
const transform = (channel) => {
|
const toLinear = (channel) => {
|
||||||
const normalized = channel / 255;
|
const normalized = channel / 255;
|
||||||
return normalized <= 0.03928
|
return normalized <= 0.03928
|
||||||
? normalized / 12.92
|
? normalized / 12.92
|
||||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [red, green, blue] = [transform(r), transform(g), transform(b)];
|
const [red, green, blue] = [toLinear(r), toLinear(g), toLinear(b)];
|
||||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
|
||||||
|
const rgb = hexToRgb(hex);
|
||||||
|
if (!rgb) return dark;
|
||||||
|
const luminance = relativeLuminance(rgb);
|
||||||
|
return luminance > 0.6 ? light : dark;
|
||||||
|
};
|
||||||
|
|
||||||
export const getTagColorStyle = (hex) => {
|
export const getTagColorStyle = (hex) => {
|
||||||
const rgb = hexToRgb(hex);
|
const rgb = hexToRgb(hex);
|
||||||
if (!rgb) return null;
|
if (!rgb) return null;
|
||||||
const luminance = relativeLuminance(rgb);
|
|
||||||
const textColor = luminance > 0.6 ? '#1f1f1f' : '#ffffff';
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: rgb.hex,
|
backgroundColor: rgb.hex,
|
||||||
borderColor: rgb.hex,
|
borderColor: rgb.hex,
|
||||||
color: textColor,
|
color: getReadableTextColor(rgb.hex),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateRandomTagColor = () => {
|
||||||
|
const lightness = 0.72 + (Math.random() - 0.5) * 0.08;
|
||||||
|
let chroma = 0.8;
|
||||||
|
const hue = Math.random() * 360;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const hex = oklchToHex(lightness, chroma, hue);
|
||||||
|
if (hex) {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
chroma *= 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#8c8982';
|
||||||
|
};
|
||||||
|
|
||||||
export { HEX_COLOR_PATTERN };
|
export { HEX_COLOR_PATTERN };
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const formatFileSize = (value) => {
|
||||||
|
const bytes = Number(value);
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||||
|
let index = 0;
|
||||||
|
let amount = bytes;
|
||||||
|
|
||||||
|
while (amount >= 1024 && index < units.length - 1) {
|
||||||
|
amount /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${amount.toFixed(2)} ${units[index]}`;
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user