295 lines
8.8 KiB
Rust
295 lines
8.8 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::Path as FsPath;
|
|
|
|
use diesel::prelude::*;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
use utoipa::ToSchema;
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
|
|
use crate::schema::{document_asset_objects, document_assets, document_versions};
|
|
use crate::state::{AppState, PgPooledConnection};
|
|
use crate::utils::time::to_iso;
|
|
|
|
#[derive(Serialize, Clone, ToSchema)]
|
|
pub struct DocumentAssetResponse {
|
|
pub id: Uuid,
|
|
pub asset_type: String,
|
|
pub mime_type: String,
|
|
#[schema(value_type = Object)]
|
|
pub metadata: Value,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub cardinality: Option<i32>,
|
|
}
|
|
|
|
#[derive(Serialize, Clone, ToSchema)]
|
|
pub struct DocumentAssetObjectResponse {
|
|
pub id: Uuid,
|
|
pub ordinal: i32,
|
|
#[schema(value_type = Object)]
|
|
pub metadata: Value,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub expires_at: Option<i64>,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct DocumentAssetDetailResponse {
|
|
pub id: Uuid,
|
|
pub asset_type: String,
|
|
pub mime_type: String,
|
|
#[schema(value_type = Object)]
|
|
pub metadata: Value,
|
|
pub created_at: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[schema(nullable)]
|
|
pub cardinality: Option<i32>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub objects: Vec<DocumentAssetObjectResponse>,
|
|
}
|
|
|
|
#[derive(Serialize, Clone, ToSchema)]
|
|
pub struct DocumentVersionResponse {
|
|
pub id: Uuid,
|
|
pub version_number: i32,
|
|
pub size_bytes: i64,
|
|
pub checksum: String,
|
|
pub created_at: String,
|
|
#[schema(value_type = Object)]
|
|
pub metadata: Value,
|
|
}
|
|
|
|
#[derive(Serialize, Clone, ToSchema)]
|
|
pub struct DocumentVersionDetailResponse {
|
|
#[serde(flatten)]
|
|
pub version: DocumentVersionResponse,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub assets: Vec<DocumentAssetResponse>,
|
|
pub download_path: String,
|
|
}
|
|
|
|
pub fn build_download_path(
|
|
state: &AppState,
|
|
document: &Document,
|
|
user_id: Uuid,
|
|
) -> AppResult<String> {
|
|
state
|
|
.jwt
|
|
.generate_download_token(document.id, user_id, document.tenant_id)
|
|
.map(|token| format!("/download/{token}"))
|
|
.map_err(|err| {
|
|
tracing::error!(error = ?err, "failed to generate download token");
|
|
AppError::internal("failed to generate download token")
|
|
})
|
|
}
|
|
|
|
pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
|
DocumentVersionResponse {
|
|
id: version.id,
|
|
version_number: version.version_number,
|
|
size_bytes: version.size_bytes,
|
|
checksum: version.checksum,
|
|
created_at: to_iso(version.created_at),
|
|
metadata: version.metadata,
|
|
}
|
|
}
|
|
|
|
pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
|
|
DocumentAssetResponse {
|
|
id: asset.id,
|
|
asset_type: asset.asset_type,
|
|
mime_type: asset.mime_type,
|
|
metadata: asset.metadata,
|
|
cardinality: asset.cardinality,
|
|
}
|
|
}
|
|
|
|
pub fn to_asset_detail_response(
|
|
asset: DocumentAsset,
|
|
objects: Vec<DocumentAssetObjectResponse>,
|
|
) -> DocumentAssetDetailResponse {
|
|
DocumentAssetDetailResponse {
|
|
id: asset.id,
|
|
asset_type: asset.asset_type,
|
|
mime_type: asset.mime_type,
|
|
metadata: asset.metadata,
|
|
created_at: to_iso(asset.created_at),
|
|
cardinality: asset.cardinality,
|
|
objects,
|
|
}
|
|
}
|
|
|
|
pub fn to_asset_object_response(
|
|
object: DocumentAssetObject,
|
|
url: Option<String>,
|
|
expires_at: Option<i64>,
|
|
) -> DocumentAssetObjectResponse {
|
|
DocumentAssetObjectResponse {
|
|
id: object.id,
|
|
ordinal: object.ordinal,
|
|
metadata: object.metadata,
|
|
url,
|
|
expires_at,
|
|
}
|
|
}
|
|
|
|
pub fn delete_asset(state: &AppState, tenant_id: Uuid, asset_id: Uuid) -> AppResult<()> {
|
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
|
|
|
diesel::delete(
|
|
document_assets::table
|
|
.filter(document_assets::id.eq(asset_id))
|
|
.filter(document_assets::tenant_id.eq(tenant_id)),
|
|
)
|
|
.execute(&mut conn)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn load_asset_responses(
|
|
state: &AppState,
|
|
tenant_id: Uuid,
|
|
version_id: Uuid,
|
|
) -> AppResult<Vec<DocumentAssetResponse>> {
|
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
|
load_asset_responses_with_conn(&mut conn, tenant_id, version_id)
|
|
}
|
|
|
|
pub fn load_asset_responses_with_conn(
|
|
conn: &mut PgPooledConnection,
|
|
tenant_id: Uuid,
|
|
version_id: Uuid,
|
|
) -> AppResult<Vec<DocumentAssetResponse>> {
|
|
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
|
|
.left_outer_join(
|
|
document_asset_objects::table.on(document_asset_objects::asset_id
|
|
.eq(document_assets::id)
|
|
.and(document_asset_objects::ordinal.eq(1))),
|
|
)
|
|
.filter(document_assets::document_version_id.eq(version_id))
|
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
|
.order(document_assets::created_at.asc())
|
|
.select((
|
|
document_assets::all_columns,
|
|
document_asset_objects::all_columns.nullable(),
|
|
))
|
|
.load(conn)?;
|
|
|
|
Ok(assets
|
|
.into_iter()
|
|
.map(|(asset, _)| to_asset_summary(asset))
|
|
.collect())
|
|
}
|
|
|
|
pub fn load_primary_assets(
|
|
state: &AppState,
|
|
tenant_id: Uuid,
|
|
documents: &[Document],
|
|
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
|
|
if documents.is_empty() {
|
|
return Ok(HashMap::new());
|
|
}
|
|
|
|
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
|
|
let mut version_ids: Vec<Uuid> = Vec::with_capacity(documents.len());
|
|
for doc in documents {
|
|
doc_to_version.insert(doc.id, doc.current_version_id);
|
|
version_ids.push(doc.current_version_id);
|
|
}
|
|
|
|
version_ids.sort();
|
|
version_ids.dedup();
|
|
|
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
|
let versions: Vec<DocumentVersion> = document_versions::table
|
|
.filter(document_versions::id.eq_any(&version_ids))
|
|
.load(&mut conn)?;
|
|
|
|
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
|
|
for version in versions {
|
|
version_map.insert(version.id, version);
|
|
}
|
|
|
|
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
|
|
.left_outer_join(
|
|
document_asset_objects::table.on(document_asset_objects::asset_id
|
|
.eq(document_assets::id)
|
|
.and(document_asset_objects::ordinal.eq(1))),
|
|
)
|
|
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
|
.order((
|
|
document_assets::document_version_id.asc(),
|
|
document_assets::created_at.asc(),
|
|
))
|
|
.select((
|
|
document_assets::all_columns,
|
|
document_asset_objects::all_columns.nullable(),
|
|
))
|
|
.load(&mut conn)?;
|
|
|
|
drop(conn);
|
|
|
|
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
|
for (asset, _object) in assets {
|
|
let version_id = asset.document_version_id;
|
|
let response = to_asset_summary(asset);
|
|
assets_by_version
|
|
.entry(version_id)
|
|
.or_default()
|
|
.push(response);
|
|
}
|
|
|
|
let mut result: HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
|
|
HashMap::with_capacity(doc_to_version.len());
|
|
for (doc_id, version_id) in doc_to_version {
|
|
if let Some(version) = version_map.remove(&version_id) {
|
|
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
|
result.insert(doc_id, (to_version_response(version), assets));
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub fn derive_document_title(original: &str) -> String {
|
|
let trimmed = original.trim();
|
|
if trimmed.is_empty() {
|
|
return "Document".to_string();
|
|
}
|
|
|
|
let stem = FsPath::new(trimmed)
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_string());
|
|
|
|
stem.unwrap_or_else(|| trimmed.to_string())
|
|
}
|
|
|
|
pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String {
|
|
let extension = FsPath::new(current_filename)
|
|
.extension()
|
|
.and_then(|ext| ext.to_str());
|
|
|
|
if let Some(ext) = extension {
|
|
if title
|
|
.rsplit_once('.')
|
|
.map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext))
|
|
.unwrap_or(false)
|
|
{
|
|
title.to_string()
|
|
} else {
|
|
format!("{title}.{ext}")
|
|
}
|
|
} else {
|
|
title.to_string()
|
|
}
|
|
}
|