a lot of things
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 9m32s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 9m48s

This commit is contained in:
2025-10-14 00:56:25 +02:00
parent 9060905e7a
commit c17a9484ca
18 changed files with 3207 additions and 483 deletions
@@ -0,0 +1,13 @@
-- Restore width/height columns and repopulate from metadata where available.
ALTER TABLE document_assets
ADD COLUMN width INTEGER,
ADD COLUMN height INTEGER;
UPDATE document_assets
SET width = (metadata->>'width')::INTEGER
WHERE metadata ? 'width';
UPDATE document_assets
SET height = (metadata->>'height')::INTEGER
WHERE metadata ? 'height';
@@ -0,0 +1,15 @@
-- Backfill existing width/height values into metadata then drop the columns.
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('width', width)
WHERE width IS NOT NULL
AND NOT (metadata ? 'width');
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('height', height)
WHERE height IS NOT NULL
AND NOT (metadata ? 'height');
ALTER TABLE document_assets
DROP COLUMN width,
DROP COLUMN height;
@@ -0,0 +1,17 @@
ALTER TABLE documents ADD COLUMN current_version INT4;
UPDATE documents AS d
SET current_version = dv.version_number
FROM document_versions AS dv
WHERE dv.id = d.current_version_id;
ALTER TABLE documents
ALTER COLUMN current_version SET NOT NULL;
DROP INDEX IF EXISTS idx_documents_current_version_id;
ALTER TABLE documents
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
ALTER TABLE documents
DROP COLUMN current_version_id;
@@ -0,0 +1,21 @@
ALTER TABLE documents ADD COLUMN current_version_id UUID;
UPDATE documents AS d
SET current_version_id = dv.id
FROM document_versions AS dv
WHERE dv.document_id = d.id
AND dv.version_number = d.current_version;
ALTER TABLE documents
ALTER COLUMN current_version_id SET NOT NULL;
ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES document_versions(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX idx_documents_current_version_id
ON documents(current_version_id);
ALTER TABLE documents DROP COLUMN current_version;
+2 -6
View File
@@ -53,7 +53,7 @@ pub struct Document {
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub current_version_id: Uuid,
pub uploaded_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
@@ -70,7 +70,7 @@ pub struct NewDocument {
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub current_version_id: Uuid,
pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>,
pub title: String,
@@ -111,8 +111,6 @@ pub struct DocumentAsset {
pub asset_type: String,
pub s3_key: String,
pub mime_type: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub metadata: serde_json::Value,
pub created_at: NaiveDateTime,
}
@@ -125,8 +123,6 @@ pub struct NewDocumentAsset {
pub asset_type: String,
pub s3_key: String,
pub mime_type: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub metadata: serde_json::Value,
}
+133 -228
View File
@@ -77,40 +77,6 @@ impl From<Tag> for TagResponse {
}
}
#[derive(Serialize, Clone, Default)]
pub struct DocumentAssetGroupResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<DocumentAssetResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview: Option<DocumentAssetResponse>,
}
impl DocumentAssetGroupResponse {
pub fn is_empty(&self) -> bool {
self.thumbnail.is_none() && self.preview.is_none()
}
}
#[derive(Serialize)]
pub struct DocumentResponse {
pub id: Uuid,
pub filename: String,
pub title: String,
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub uploaded_at: String,
pub updated_at: String,
pub deleted_at: Option<String>,
pub issued_at: Option<String>,
pub metadata: Value,
pub tags: Vec<TagResponse>,
#[serde(default, skip_serializing_if = "DocumentAssetGroupResponse::is_empty")]
pub assets: DocumentAssetGroupResponse,
pub download_path: String,
}
#[derive(Serialize, Clone)]
pub struct DocumentVersionResponse {
pub id: Uuid,
@@ -127,19 +93,41 @@ pub struct DocumentAssetResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub width: Option<i32>,
pub height: Option<i32>,
pub url: String,
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub created_at: String,
}
type PrimaryDocumentAssets = DocumentAssetGroupResponse;
#[derive(Serialize, Clone)]
pub struct DocumentCurrentVersionResponse {
#[serde(flatten)]
pub version: DocumentVersionResponse,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<DocumentAssetResponse>,
pub download_path: String,
}
#[derive(Serialize)]
pub struct DocumentResponse {
pub id: Uuid,
pub filename: String,
pub title: String,
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub uploaded_at: String,
pub updated_at: String,
pub deleted_at: Option<String>,
pub issued_at: Option<String>,
pub metadata: Value,
pub tags: Vec<TagResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_version: Option<DocumentCurrentVersionResponse>,
}
#[derive(Serialize)]
pub struct DocumentDetailResponse {
pub document: DocumentResponse,
pub current_version: DocumentVersionResponse,
pub assets: Vec<DocumentAssetResponse>,
}
#[derive(Serialize)]
@@ -256,18 +244,18 @@ pub async fn list_documents(
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let primary_assets = load_primary_assets(&state, &docs).await?;
let primary_versions = load_primary_assets(&state, &docs).await?;
let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
let assets = primary_assets.get(&doc.id).cloned();
let current_version = primary_versions.get(&doc.id).cloned();
response.push(to_document_response(
&state,
user.user_id,
doc,
tags,
assets,
current_version,
)?);
}
@@ -287,8 +275,7 @@ pub async fn get_document(
}
let current_version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(doc.current_version))
.find(doc.current_version_id)
.first(&mut conn)?;
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
@@ -296,21 +283,7 @@ pub async fn get_document(
drop(conn);
let assets = load_asset_responses(&state, version_id).await?;
let mut grouped_assets = DocumentAssetGroupResponse::default();
if let Some(asset) = assets
.iter()
.find(|asset| asset.asset_type == "thumbnail")
.cloned()
{
grouped_assets.thumbnail = Some(asset);
}
if let Some(asset) = assets
.iter()
.find(|asset| asset.asset_type == "preview")
.cloned()
{
grouped_assets.preview = Some(asset);
}
let version_response = to_version_response(current_version);
Ok(Json(DocumentDetailResponse {
document: to_document_response(
@@ -318,10 +291,8 @@ pub async fn get_document(
user.user_id,
doc,
tags_map.get(&document_id).cloned(),
Some(grouped_assets),
Some((version_response, assets)),
)?,
current_version: to_version_response(current_version),
assets,
}))
}
@@ -441,17 +412,12 @@ pub async fn request_document_assets(
return Err(AppError::not_found());
}
let version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(document.current_version))
.first(&mut conn)?;
enqueue_job(
&mut conn,
JOB_ANALYZE_DOCUMENT,
json!({
"document_id": document_id,
"document_version_id": version.id,
"document_version_id": document.current_version_id,
"force": query.force,
}),
None,
@@ -467,11 +433,9 @@ pub async fn reanalyze_all_documents(
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
let mut conn = state.db()?;
let targets: Vec<(Uuid, Uuid)> = document_versions::table
.inner_join(documents::table.on(document_versions::document_id.eq(documents::id)))
let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::deleted_at.is_null())
.filter(document_versions::version_number.eq(documents::current_version))
.select((documents::id, document_versions::id))
.select((documents::id, documents::current_version_id))
.load(&mut conn)?;
let mut queued = 0usize;
@@ -511,12 +475,10 @@ pub async fn reanalyze_selected_documents(
let mut conn = state.db()?;
let targets: Vec<(Uuid, Uuid)> = document_versions::table
.inner_join(documents::table.on(document_versions::document_id.eq(documents::id)))
let targets: Vec<(Uuid, Uuid)> = documents::table
.filter(documents::id.eq_any(&document_ids))
.filter(documents::deleted_at.is_null())
.filter(document_versions::version_number.eq(documents::current_version))
.select((documents::id, document_versions::id))
.select((documents::id, documents::current_version_id))
.load(&mut conn)?;
if targets.len() != document_ids.len() {
@@ -554,16 +516,44 @@ pub async fn list_document_assets(
return Err(AppError::not_found());
}
let version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(document.current_version))
.first(&mut conn)?;
let version_id = document.current_version_id;
drop(conn);
let assets = load_asset_responses(&state, version.id).await?;
let assets = load_asset_responses(&state, version_id).await?;
Ok(Json(assets))
}
pub async fn get_document_asset(
State(state): State<AppState>,
Path((document_id, asset_id)): Path<(Uuid, Uuid)>,
) -> AppResult<Json<DocumentAssetResponse>> {
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 asset: DocumentAsset = document_assets::table.find(asset_id).first(&mut conn)?;
let version: DocumentVersion = document_versions::table
.find(asset.document_version_id)
.first(&mut conn)?;
if version.document_id != document_id {
return Err(AppError::not_found());
}
let s3_key = asset.s3_key.clone();
drop(conn);
let presigned_url = state
.storage
.presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
.await
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
Ok(Json(to_asset_response(asset, Some(presigned_url))))
}
pub async fn download_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
@@ -575,8 +565,7 @@ pub async fn download_document(
}
let version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(doc.current_version))
.find(doc.current_version_id)
.first(&mut conn)?;
let presigned_url = state
@@ -614,8 +603,7 @@ pub async fn download_with_token(
}
let version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(claims.doc_id))
.filter(document_versions::version_number.eq(doc.current_version))
.find(doc.current_version_id)
.first(&mut conn)?;
let now = Utc::now().naive_utc();
@@ -697,8 +685,7 @@ pub async fn update_document(
}
let current_version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(document.current_version))
.find(document.current_version_id)
.first(&mut conn)?;
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
@@ -706,21 +693,7 @@ pub async fn update_document(
drop(conn);
let assets = load_asset_responses(&state, version_id).await?;
let mut grouped_assets = DocumentAssetGroupResponse::default();
if let Some(asset) = assets
.iter()
.find(|asset| asset.asset_type == "thumbnail")
.cloned()
{
grouped_assets.thumbnail = Some(asset);
}
if let Some(asset) = assets
.iter()
.find(|asset| asset.asset_type == "preview")
.cloned()
{
grouped_assets.preview = Some(asset);
}
let version_response = to_version_response(current_version);
Ok(Json(DocumentDetailResponse {
document: to_document_response(
@@ -728,10 +701,8 @@ pub async fn update_document(
user.user_id,
document,
tags_map.get(&document_id).cloned(),
Some(grouped_assets),
Some((version_response, assets)),
)?,
current_version: to_version_response(current_version),
assets,
}))
}
@@ -982,9 +953,8 @@ async fn process_upload(
let existing = documents::table
.inner_join(
document_versions::table.on(document_versions::document_id
.eq(documents::id)
.and(document_versions::version_number.eq(documents::current_version))),
document_versions::table
.on(document_versions::id.eq(documents::current_version_id)),
)
.filter(document_versions::checksum.eq(&checksum_hex))
.select((documents::all_columns, document_versions::all_columns))
@@ -1008,21 +978,7 @@ async fn process_upload(
let tags = tags_map.get(&document.id).cloned();
drop(conn);
let assets = load_asset_responses(state, version.id).await?;
let mut grouped_assets = DocumentAssetGroupResponse::default();
if let Some(asset) = assets
.iter()
.find(|asset| asset.asset_type == "thumbnail")
.cloned()
{
grouped_assets.thumbnail = Some(asset);
}
if let Some(asset) = assets
.iter()
.find(|asset| asset.asset_type == "preview")
.cloned()
{
grouped_assets.preview = Some(asset);
}
let version_response = to_version_response(version.clone());
info!(
document_id = %document.id,
@@ -1037,10 +993,8 @@ async fn process_upload(
user_id,
document,
tags,
Some(grouped_assets),
Some((version_response, assets)),
)?,
current_version: to_version_response(version),
assets,
},
created: false,
});
@@ -1078,7 +1032,7 @@ async fn process_upload(
original_name: original_name.clone(),
content_type: content_type.clone(),
folder_id,
current_version: version_number,
current_version_id: version_id,
issued_at: None,
title: derive_document_title(&original_name),
metadata: metadata_value.clone(),
@@ -1109,9 +1063,13 @@ async fn process_upload(
};
let detail = DocumentDetailResponse {
document: to_document_response(state, user_id, document, None, None)?,
current_version: to_version_response(version.clone()),
assets: Vec::new(),
document: to_document_response(
state,
user_id,
document,
None,
Some((to_version_response(version.clone()), Vec::new())),
)?,
};
if let Ok(mut conn) = state.db() {
@@ -1171,111 +1129,61 @@ pub(crate) fn load_tags_for_documents(
pub(crate) async fn load_primary_assets(
state: &AppState,
documents: &[Document],
) -> AppResult<HashMap<Uuid, PrimaryDocumentAssets>> {
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
if documents.is_empty() {
return Ok(HashMap::new());
}
#[derive(Default)]
struct RawPrimaryAssets {
thumbnail: Option<DocumentAsset>,
preview: Option<DocumentAsset>,
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);
}
let mut current_versions: HashMap<Uuid, i32> = HashMap::new();
let mut doc_ids = Vec::with_capacity(documents.len());
for doc in documents {
current_versions.insert(doc.id, doc.current_version);
doc_ids.push(doc.id);
}
version_ids.sort();
version_ids.dedup();
let mut conn = state.db()?;
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::document_id.eq_any(&doc_ids))
.filter(document_versions::id.eq_any(&version_ids))
.load(&mut conn)?;
let mut version_id_by_doc: HashMap<Uuid, Uuid> = HashMap::new();
let mut doc_id_by_version: HashMap<Uuid, Uuid> = HashMap::new();
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
for version in versions {
if let Some(current_number) = current_versions.get(&version.document_id) {
if *current_number == version.version_number {
version_id_by_doc.insert(version.document_id, version.id);
doc_id_by_version.insert(version.id, version.document_id);
}
}
version_map.insert(version.id, version);
}
if version_id_by_doc.is_empty() {
return Ok(HashMap::new());
}
let version_ids: Vec<Uuid> = version_id_by_doc.values().copied().collect();
let assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq_any(&version_ids))
.filter(
document_assets::asset_type
.eq_any(vec!["thumbnail".to_string(), "preview".to_string()]),
)
.order((
document_assets::document_version_id.asc(),
document_assets::created_at.asc(),
))
.load(&mut conn)?;
let mut raw_assets: HashMap<Uuid, RawPrimaryAssets> = HashMap::new();
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
for asset in assets {
if let Some(doc_id) = doc_id_by_version.get(&asset.document_version_id) {
let entry = raw_assets.entry(*doc_id).or_default();
let asset_type = asset.asset_type.clone();
match asset_type.as_str() {
"thumbnail" => {
if entry.thumbnail.is_none() {
entry.thumbnail = Some(asset);
}
}
"preview" => {
if entry.preview.is_none() {
entry.preview = Some(asset);
}
}
_ => {}
}
}
let version_id = asset.document_version_id;
let response = to_asset_response(asset, None);
assets_by_version
.entry(version_id)
.or_default()
.push(response);
}
drop(conn);
let mut responses = HashMap::with_capacity(raw_assets.len());
for (doc_id, raw) in raw_assets {
let mut assets = PrimaryDocumentAssets::default();
if let Some(asset) = raw.preview {
let s3_key = asset.s3_key.clone();
let url = state
.storage
.presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
.await
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
assets.preview = Some(to_asset_response(asset, url));
}
if let Some(asset) = raw.thumbnail {
let s3_key = asset.s3_key.clone();
let url = state
.storage
.presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
.await
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
assets.thumbnail = Some(to_asset_response(asset, url));
}
if assets.thumbnail.is_some() || assets.preview.is_some() {
responses.insert(doc_id, assets);
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(responses)
Ok(result)
}
pub(crate) fn to_document_response(
@@ -1283,9 +1191,18 @@ pub(crate) fn to_document_response(
user_id: Uuid,
doc: Document,
tags: Option<Vec<Tag>>,
assets: Option<DocumentAssetGroupResponse>,
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
) -> AppResult<DocumentResponse> {
let download_path = build_download_path(state, doc.id, user_id)?;
let current_version = if let Some((version, assets)) = current_version {
let download_path = build_download_path(state, doc.id, user_id)?;
Some(DocumentCurrentVersionResponse {
version,
assets,
download_path,
})
} else {
None
};
Ok(DocumentResponse {
id: doc.id,
@@ -1294,7 +1211,6 @@ pub(crate) fn to_document_response(
original_name: doc.original_name,
content_type: doc.content_type,
folder_id: doc.folder_id,
current_version: doc.current_version,
uploaded_at: to_iso(doc.uploaded_at),
updated_at: to_iso(doc.updated_at),
deleted_at: doc.deleted_at.map(to_iso),
@@ -1305,8 +1221,7 @@ pub(crate) fn to_document_response(
.into_iter()
.map(TagResponse::from)
.collect(),
assets: assets.unwrap_or_default(),
download_path,
current_version,
})
}
@@ -1330,13 +1245,13 @@ fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
}
}
fn to_asset_response(asset: DocumentAsset, url: String) -> DocumentAssetResponse {
fn to_asset_response(asset: DocumentAsset, url: Option<String>) -> DocumentAssetResponse {
let metadata = asset.metadata.clone();
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
width: asset.width,
height: asset.height,
metadata,
url,
created_at: to_iso(asset.created_at),
}
@@ -1369,20 +1284,10 @@ async fn load_asset_responses(
.load(&mut conn)?;
drop(conn);
let mut responses = Vec::with_capacity(assets.len());
for asset in assets {
let url = state
.storage
.presign_get_object(
&asset.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
)
.await
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
responses.push(to_asset_response(asset, url));
}
Ok(responses)
Ok(assets
.into_iter()
.map(|asset| to_asset_response(asset, None))
.collect())
}
pub(crate) fn to_iso(dt: NaiveDateTime) -> String {
+6 -6
View File
@@ -215,18 +215,18 @@ pub async fn list_folder_contents(
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let primary_assets = load_primary_assets(&state, &docs).await?;
let primary_versions = load_primary_assets(&state, &docs).await?;
let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
let assets = primary_assets.get(&doc.id).cloned();
let current_version = primary_versions.get(&doc.id).cloned();
documents.push(to_document_response(
&state,
user.user_id,
doc,
tags,
assets,
current_version,
)?);
}
@@ -399,17 +399,17 @@ pub async fn search_documents(
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let primary_assets = load_primary_assets(&state, &docs).await?;
let primary_versions = load_primary_assets(&state, &docs).await?;
let mut response = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
let assets = primary_assets.get(&doc.id).cloned();
let current_version = primary_versions.get(&doc.id).cloned();
response.push(to_document_response(
&state,
user.user_id,
doc,
tags,
assets,
current_version,
)?);
}
+1
View File
@@ -69,6 +69,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.patch(documents::update_document),
)
.route("/:id/download", get(documents::download_document))
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
.route(
"/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets),
+1 -4
View File
@@ -7,8 +7,6 @@ diesel::table! {
asset_type -> Text,
s3_key -> Text,
mime_type -> Text,
width -> Nullable<Int4>,
height -> Nullable<Int4>,
metadata -> Jsonb,
created_at -> Timestamptz,
}
@@ -48,7 +46,6 @@ diesel::table! {
#[max_length = 100]
content_type -> Nullable<Varchar>,
folder_id -> Nullable<Uuid>,
current_version -> Int4,
uploaded_at -> Timestamptz,
updated_at -> Timestamptz,
deleted_at -> Nullable<Timestamptz>,
@@ -56,6 +53,7 @@ diesel::table! {
issued_at -> Nullable<Timestamptz>,
#[max_length = 255]
title -> Varchar,
current_version_id -> Uuid,
}
}
@@ -128,7 +126,6 @@ diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_tags -> documents (document_id));
diesel::joinable!(document_tags -> tags (tag_id));
diesel::joinable!(document_tags -> users (assigned_by));
diesel::joinable!(document_versions -> documents (document_id));
diesel::joinable!(documents -> folders (folder_id));
diesel::joinable!(refresh_tokens -> users (user_id));
-2
View File
@@ -377,8 +377,6 @@ fn persist_ocr_metadata(
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
s3_key: s3_key.to_string(),
mime_type: "text/plain".to_string(),
width: None,
height: None,
metadata: json!({
"generated_at": Utc::now().to_rfc3339(),
"source": source,
+2 -4
View File
@@ -405,10 +405,10 @@ fn persist_assets_metadata(
asset_type: asset.asset_type.to_string(),
s3_key: asset.s3_key.to_string(),
mime_type: "image/png".to_string(),
width: asset.generated.width,
height: asset.generated.height,
metadata: json!({
"generated_at": Utc::now().to_rfc3339(),
"width": asset.generated.width,
"height": asset.generated.height,
}),
};
@@ -422,8 +422,6 @@ fn persist_assets_metadata(
.set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::width.eq(excluded(document_assets::width)),
document_assets::height.eq(excluded(document_assets::height)),
document_assets::metadata.eq(excluded(document_assets::metadata)),
))
.execute(&mut conn)
+76 -21
View File
@@ -9,8 +9,6 @@ use uuid::Uuid;
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentInfo,
current_version: DocumentVersion,
assets: Vec<DocumentAssetInfo>,
}
#[derive(Deserialize)]
@@ -18,11 +16,11 @@ struct DocumentInfo {
id: Uuid,
title: String,
original_name: String,
current_version: i32,
deleted_at: Option<String>,
issued_at: Option<String>,
tags: Vec<TagSummary>,
download_path: String,
#[serde(default)]
current_version: Option<DocumentVersion>,
}
#[derive(Deserialize)]
@@ -30,6 +28,10 @@ struct DocumentVersion {
id: Uuid,
s3_key: String,
size_bytes: i64,
version_number: i32,
download_path: String,
#[serde(default)]
assets: Vec<DocumentAssetInfo>,
}
#[allow(dead_code)]
@@ -42,8 +44,8 @@ struct DocumentAssetInfo {
#[derive(Deserialize)]
struct DocumentListItem {
id: Uuid,
current_version: i32,
download_path: String,
#[serde(default)]
current_version: Option<DocumentVersion>,
}
#[derive(Deserialize)]
@@ -152,17 +154,22 @@ async fn upload_and_list_document() -> Result<()> {
assert_eq!(detail.document.original_name, "doc.txt");
assert_eq!(detail.document.title, "doc");
assert_eq!(detail.document.current_version, 1);
assert_eq!(detail.document.deleted_at, None);
assert!(detail.document.issued_at.is_none());
assert!(detail.document.tags.is_empty());
assert!(detail.document.download_path.starts_with("/download/"));
assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64);
assert!(detail.assets.is_empty());
assert!(current_version.download_path.starts_with("/download/"));
let current_version = detail
.document
.current_version
.as_ref()
.expect("current version detail");
assert_eq!(current_version.version_number, 1);
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
assert!(current_version.assets.is_empty());
let stored = app
.storage()
.get(&detail.current_version.s3_key)
.get(&current_version.s3_key)
.await
.expect("object stored");
assert_eq!(stored.bytes, file_bytes);
@@ -175,8 +182,18 @@ async fn upload_and_list_document() -> Result<()> {
assert_eq!(list.len(), 1);
let item = list.pop().unwrap();
assert_eq!(item.id, detail.document.id);
assert_eq!(item.current_version, 1);
assert!(item.download_path.starts_with("/download/"));
assert_eq!(
item.current_version
.as_ref()
.map(|version| version.version_number),
Some(1)
);
assert!(item
.current_version
.as_ref()
.expect("list current version")
.download_path
.starts_with("/download/"));
let download = app
.get(
@@ -187,17 +204,17 @@ async fn upload_and_list_document() -> Result<()> {
assert_eq!(download.status(), StatusCode::OK);
let body = body_to_vec(download.into_body()).await?;
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
assert!(download_info.url.contains(&detail.current_version.s3_key));
assert!(download_info.url.contains(&current_version.s3_key));
assert_eq!(download_info.filename, "doc.txt");
let redirect = app.get(&detail.document.download_path, None).await?;
let redirect = app.get(&current_version.download_path, None).await?;
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
let location = redirect
.headers()
.get("location")
.expect("redirect location header");
let location = location.to_str().expect("location header utf8");
assert!(location.contains(&detail.current_version.s3_key));
assert!(location.contains(&current_version.s3_key));
app.cleanup().await?;
Ok(())
@@ -243,7 +260,13 @@ async fn duplicate_and_restore_document() -> Result<()> {
assert_eq!(first_detail.document.id, second_detail.document.id);
assert_eq!(second_detail.document.deleted_at, None);
assert!(second_detail.assets.is_empty());
assert!(second_detail
.document
.current_version
.as_ref()
.expect("second current version")
.assets
.is_empty());
assert_eq!(app.storage().object_count().await, 1);
let delete = app
@@ -341,8 +364,24 @@ async fn bulk_reanalyze_documents() -> Result<()> {
}
let mut expected = vec![
(first_detail.document.id, first_detail.current_version.id),
(second_detail.document.id, second_detail.current_version.id),
(
first_detail.document.id,
first_detail
.document
.current_version
.as_ref()
.expect("first current version")
.id,
),
(
second_detail.document.id,
second_detail
.document
.current_version
.as_ref()
.expect("second current version")
.id,
),
];
payload_docs.sort();
expected.sort();
@@ -661,8 +700,24 @@ async fn bulk_reanalyze_selected_documents() -> Result<()> {
.all(|(doc_id, _)| *doc_id != second_detail.document.id));
let mut expected = vec![
(first_detail.document.id, first_detail.current_version.id),
(third_detail.document.id, third_detail.current_version.id),
(
first_detail.document.id,
first_detail
.document
.current_version
.as_ref()
.expect("first current version")
.id,
),
(
third_detail.document.id,
third_detail
.document
.current_version
.as_ref()
.expect("third current version")
.id,
),
];
payload_docs.sort();
expected.sort();
+257
View File
@@ -0,0 +1,257 @@
export const getAssetFromGroup = (assets, assetType) => {
if (!assetType || !assets) {
return null;
}
if (Array.isArray(assets)) {
return assets.find((entry) => entry?.asset_type === assetType) || null;
}
return assets?.[assetType] || null;
};
export const getAssetFromVersion = (currentVersion, assetType) => {
if (!currentVersion) {
return null;
}
return getAssetFromGroup(currentVersion.assets, assetType);
};
class AssetManager {
constructor({ api, assetPresignTtlMs }) {
this.api = api;
this.assetPresignTtlMs = assetPresignTtlMs;
this.assetCache = new Map();
this.assetInflight = new Map();
this.previewCache = new Map();
this.previewInflight = new Map();
}
setApi(api) {
this.api = api;
}
rememberAsset(entry) {
if (entry?.id) {
this.assetCache.set(entry.id, entry);
}
}
hydrateAsset(asset) {
if (!asset || !asset.id) {
return asset;
}
const cached = this.assetCache.get(asset.id);
if (!cached) {
return asset;
}
const merged = { ...cached, ...asset };
if (cached.url && !asset.url) {
merged.url = cached.url;
}
if (cached.expiresAt) {
const cachedExpires = Number(cached.expiresAt) || null;
const assetExpires = Number(asset.expiresAt) || null;
if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) {
merged.expiresAt = cachedExpires;
}
}
return merged;
}
hydrateDocument(document) {
if (!document) {
return document;
}
const currentVersion = document.current_version || null;
if (!currentVersion) {
return document;
}
let changed = false;
let nextAssets = currentVersion.assets;
if (nextAssets && !Array.isArray(nextAssets)) {
const hydrated = {};
Object.keys(nextAssets).forEach((key) => {
hydrated[key] = this.hydrateAsset(nextAssets[key]);
if (hydrated[key] !== nextAssets[key]) {
changed = true;
}
});
if (changed) {
nextAssets = { ...nextAssets, ...hydrated };
}
} else if (Array.isArray(nextAssets)) {
const hydratedList = nextAssets.map((item) => this.hydrateAsset(item));
if (
hydratedList.length !== nextAssets.length ||
hydratedList.some((item, index) => item !== nextAssets[index])
) {
changed = true;
nextAssets = hydratedList;
}
}
if (!changed) {
return document;
}
const nextCurrentVersion = { ...currentVersion, assets: nextAssets };
return { ...document, current_version: nextCurrentVersion };
}
hydrateDocuments(documents) {
if (!Array.isArray(documents)) {
return documents;
}
return documents.map((doc) => this.hydrateDocument(doc));
}
hydrateDetail(detail) {
if (!detail) {
return detail;
}
let changed = false;
const next = { ...detail };
if (detail.document) {
const hydratedDocument = this.hydrateDocument(detail.document);
if (hydratedDocument !== detail.document) {
next.document = hydratedDocument;
changed = true;
}
}
if (Array.isArray(detail.assets)) {
const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item));
if (
hydratedAssets.length !== detail.assets.length ||
hydratedAssets.some((item, index) => item !== detail.assets[index])
) {
next.assets = hydratedAssets;
changed = true;
}
}
return changed ? next : detail;
}
hydrateFolderContents(contents) {
if (!contents) {
return contents;
}
const next = { ...contents };
if (Array.isArray(contents.documents)) {
next.documents = this.hydrateDocuments(contents.documents);
}
if (contents.document) {
next.document = this.hydrateDocument(contents.document);
}
return next;
}
ensureAsset(documentId, asset, { force = false } = {}) {
if (!documentId || !asset?.id) {
return Promise.resolve(asset || null);
}
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
this.rememberAsset(asset);
return Promise.resolve(asset);
}
const cached = this.assetCache.get(asset.id);
const now = Date.now();
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
return Promise.resolve({ ...asset, ...cached });
}
const inflightKey = `${documentId}:${asset.id}`;
if (!force && this.assetInflight.has(inflightKey)) {
return this.assetInflight.get(inflightKey);
}
if (!this.api) {
return Promise.reject(new Error('AssetManager API client is not configured.'));
}
const request = this.api
.get(`/documents/${documentId}/assets/${asset.id}`)
.then(({ data }) => {
const entry = {
...asset,
...data,
expiresAt: Date.now() + this.assetPresignTtlMs,
};
this.rememberAsset(entry);
return entry;
})
.finally(() => {
this.assetInflight.delete(inflightKey);
});
this.assetInflight.set(inflightKey, request);
return request;
}
ensurePreview(documentId, { force = false } = {}) {
if (!documentId) {
return Promise.resolve(null);
}
const cached = this.previewCache.get(documentId);
const now = Date.now();
if (!force && cached && (!cached.expiresAt || cached.expiresAt > now)) {
return Promise.resolve(cached);
}
if (!force && this.previewInflight.has(documentId)) {
return this.previewInflight.get(documentId);
}
if (!this.api) {
return Promise.reject(new Error('AssetManager API client is not configured.'));
}
const request = this.api
.get(`/documents/${documentId}/download`)
.then(({ data }) => {
const ttl = data.expires_in ? Math.max(data.expires_in - 60, 30) * 1000 : 5 * 60 * 1000;
const entry = {
url: data.url,
contentType: data.content_type || null,
filename: data.filename,
expiresAt: Date.now() + ttl,
};
this.previewCache.set(documentId, entry);
return entry;
})
.finally(() => {
this.previewInflight.delete(documentId);
});
this.previewInflight.set(documentId, request);
return request;
}
getPreview(documentId) {
return this.previewCache.get(documentId) || null;
}
deleteDocument(documentId) {
if (!documentId) return;
this.previewCache.delete(documentId);
this.previewInflight.delete(documentId);
}
reset() {
this.assetCache.clear();
this.assetInflight.clear();
this.previewCache.clear();
this.previewInflight.clear();
}
}
export default AssetManager;
+33
View File
@@ -0,0 +1,33 @@
import { useCallback } from 'react';
const noop = () => {};
const normalizeMessage = (error) => {
if (!error) return 'Something went wrong.';
if (typeof error === 'string') {
return error;
}
const { response, message } = error;
if (response?.data?.error) return response.data.error;
if (response?.data?.message) return response.data.message;
return message || 'Something went wrong.';
};
const useApiError = ({
logger = console,
onReport = noop,
} = {}) => {
return useCallback(
(error, { message, variant = 'error', retry = null } = {}) => {
const normalizedMessage = message || normalizeMessage(error);
if (logger && typeof logger.error === 'function') {
logger.error('[API]', normalizedMessage, error);
}
onReport({ message: normalizedMessage, variant, retry, error });
return normalizedMessage;
},
[logger, onReport],
);
};
export default useApiError;
+673 -211
View File
File diff suppressed because it is too large Load Diff
+317
View File
@@ -0,0 +1,317 @@
/* Skeuomorphic workspace styles */
.skeuo-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.skeuo-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
--desk-1: #f0e2c6;
--desk-2: #e7d2ad;
--desk-3: #d8b98a;
--desk-grain-dark: rgba(110, 86, 48, 0.08);
--desk-grain-fine: rgba(86, 66, 33, 0.04);
--desk-vignette: rgba(60, 40, 18, 0.15);
background:
radial-gradient(120% 85% at 50% -10%, var(--desk-vignette), #0000 48%),
linear-gradient(7deg, #0000 0 32%, var(--desk-grain-dark) 60%, #0000 85%),
repeating-linear-gradient(
7deg,
var(--desk-grain-fine) 0 2px,
#0000 2px 9px
),
linear-gradient(90deg, var(--desk-1), var(--desk-2) 48%, var(--desk-3) 100%);
background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%;
background-blend-mode: multiply, overlay, normal, normal;
position: relative;
}
.skeuo-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.5rem 0.75rem;
}
.skeuo-header__meta {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.skeuo-header__meta h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.skeuo-breadcrumbs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--muted);
}
.skeuo-crumb {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.skeuo-crumb.is-current {
color: var(--fg);
font-weight: 600;
}
.skeuo-crumb-separator {
opacity: 0.4;
}
.skeuo-header__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.skeuo-canvas {
flex: 1;
position: relative;
overflow: hidden;
margin: 0 1.5rem 1.5rem;
border-radius: 18px;
background-image: radial-gradient(rgba(255, 255, 255, 0.5) 9%, transparent 9%),
linear-gradient(180deg, rgba(0, 0, 0, 0.05), transparent 32%);
background-size: 46px 46px, 100% 100%;
box-shadow: inset 0 16px 28px rgba(0, 0, 0, 0.07), inset 0 -6px 20px rgba(0, 0, 0, 0.05);
backdrop-filter: blur(6px);
}
.skeuo-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
color: var(--muted);
font-size: 0.95rem;
}
.skeuo-item {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
width: auto;
cursor: grab;
touch-action: none;
transform-origin: center center;
transition: transform 0.28s ease, box-shadow 0.16s ease;
outline: none;
will-change: transform;
}
.skeuo-item__body {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
transition: width 0.28s ease, height 0.28s ease;
}
.skeuo-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
}
.skeuo-item.is-dragging {
cursor: grabbing;
transition: none;
}
.skeuo-item.is-zoomed {
cursor: pointer;
}
.skeuo-item.is-zoomed .skeuo-item__card {
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
}
.skeuo-item.is-tag-target .skeuo-item__card {
outline: 1em dashed var(--accent);
outline-offset: 1.41em;
}
.skeuo-item.is-tag-pending .skeuo-item__card {
opacity: 0.6;
}
.skeuo-item.is-filtered-out {
opacity: 0.12;
pointer-events: none;
filter: blur(15px) grayscale(100%);
transition: opacity 0.6s ease, filter 0.28s ease;
z-index: 0 !important;
}
.skeuo-item__tags {
--tag-scale: 1;
position: absolute;
top: 0;
right: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: flex-end;
transform-origin: top right;
transform: scale(var(--tag-scale)) translate(-0.5em, 0.5em);
transition: transform 0.28s ease;
}
.skeuo-tag {
display: inline-flex;
align-items: center;
justify-content: flex-start;
padding: 0.28rem 0.7rem;
border-radius: 1px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
color: var(--fg);
font-size: 1rem;
font-weight: 600;
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.18);
text-align: left;
white-space: nowrap;
user-select: none;
}
.skeuo-item__tags .skeuo-tag {
font-size: 0.75rem;
padding: 0.18rem 0.55rem;
pointer-events: auto;
cursor: grab;
transition: transform 0.16s ease, opacity 0.2s ease;
}
.skeuo-tag.is-drag-hidden {
opacity: 0.4;
pointer-events: none;
}
.skeuo-tag span {
display: block;
pointer-events: none;
user-select: none;
}
.skeuo-item__tags .skeuo-tag.is-tear-pending {
opacity: 0.4;
}
.skeuo-tag-shelf {
position: absolute;
top: 2.25rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
align-items: flex-end;
max-height: calc(100% - 4rem);
overflow: auto;
padding: 0;
z-index: 20;
}
.skeuo-tag-shelf .skeuo-tag {
cursor: grab;
}
.skeuo-tag-shelf .skeuo-tag:active {
cursor: grabbing;
}
.skeuo-tag-shelf .skeuo-tag.is-inactive {
opacity: 0.25;
}
.skeuo-tag-shelf .skeuo-tag.is-active {
opacity: 1;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
}
.skeuo-tag-add {
width: 2.4rem;
height: 2.4rem;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.8);
color: var(--fg);
font-size: 1.4rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-top: 0.4rem;
transition: background 0.2s ease, transform 0.2s ease;
}
.skeuo-tag-add:hover {
background: rgba(255, 255, 255, 1);
transform: translateY(-1px);
}
.skeuo-tag-add:active {
transform: translateY(0);
}
.skeuo-tag-add:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
body.skeuo-cursor-remove,
body.skeuo-cursor-remove * {
cursor: not-allowed !important;
}
.skeuo-item__shadow {
display: none;
}
.skeuo-item__card {
position: relative;
width: 100%;
height: 100%;
border-radius: 0;
background: var(--surface);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.24);
overflow: hidden;
}
.skeuo-item__card img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.skeuo-item__placeholder {
font-size: 0.95rem;
font-weight: 500;
letter-spacing: normal;
color: var(--muted);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -691,7 +691,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.thumb-placeholder {
width: 36px;
height: 48px;
border-radius: 0;
border-radius: 1px;
color: var(--muted);
display: inline-flex;
align-items: center;