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; use crate::utils::time::to_iso; #[derive(Serialize, Clone, ToSchema)] pub struct DocumentAssetResponse { pub id: Uuid, pub asset_type: String, pub mime_type: String, pub metadata: Value, #[serde(skip_serializing_if = "Option::is_none")] pub cardinality: Option, } #[derive(Serialize, Clone, ToSchema)] pub struct DocumentAssetObjectResponse { pub id: Uuid, pub ordinal: i32, pub metadata: Value, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option, } #[derive(Serialize, ToSchema)] pub struct DocumentAssetDetailResponse { pub id: Uuid, pub asset_type: String, pub mime_type: String, pub metadata: Value, pub created_at: String, #[serde(skip_serializing_if = "Option::is_none")] pub cardinality: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub objects: Vec, } #[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, 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, pub download_path: String, } pub fn build_download_path( state: &AppState, document: &Document, user_id: Uuid, ) -> AppResult { 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, ) -> 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, expires_at: Option, ) -> 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> { let mut conn = state.db_for_tenant(tenant_id)?; let assets: Vec<(DocumentAsset, Option)> = 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(&mut conn)?; drop(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)>> { if documents.is_empty() { return Ok(HashMap::new()); } let mut doc_to_version: HashMap = HashMap::with_capacity(documents.len()); let mut version_ids: Vec = 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 = document_versions::table .filter(document_versions::id.eq_any(&version_ids)) .load(&mut conn)?; let mut version_map: HashMap = HashMap::new(); for version in versions { version_map.insert(version.id, version); } let assets: Vec<(DocumentAsset, Option)> = 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> = 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)> = 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() } }