backend: split assets into objects

This commit is contained in:
2025-10-20 16:28:57 +02:00
parent 96a6d0ee5d
commit c44ba91ec7
9 changed files with 163 additions and 105 deletions
+120 -89
View File
@@ -118,12 +118,33 @@ 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 metadata: Option<Value>,
pub cardinality: Option<i32>,
}
#[derive(Serialize, Clone)]
pub struct DocumentAssetObjectResponse {
pub id: Uuid,
pub ordinal: i32,
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
pub expires_at: Option<i64>,
}
#[derive(Serialize)]
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<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub objects: Vec<DocumentAssetObjectResponse>,
}
#[derive(Serialize, Clone)]
@@ -339,6 +360,14 @@ pub struct AssignTagsRequest {
pub tag_ids: Vec<Uuid>,
}
#[derive(Deserialize, Default)]
pub struct AssetObjectsQuery {
#[serde(default)]
pub start: Option<i32>,
#[serde(default)]
pub limit: Option<i32>,
}
pub async fn list_documents(
State(state): State<AppState>,
Query(params): Query<DocumentListQuery>,
@@ -852,46 +881,70 @@ pub async fn list_document_assets(
pub async fn get_document_asset(
State(state): State<AppState>,
Path((document_id, asset_id)): Path<(Uuid, Uuid)>,
) -> AppResult<Json<DocumentAssetResponse>> {
Path(asset_id): Path<Uuid>,
Query(query): Query<AssetObjectsQuery>,
) -> AppResult<Json<DocumentAssetDetailResponse>> {
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 = match document_assets::table
.find(asset_id)
.first(&mut conn)
.optional()?
{
Some(asset) => asset,
None => return Err(AppError::not_found()),
};
let start = query.start.unwrap_or(1);
let limit = query.limit.unwrap_or(1);
if start < 1 {
return Err(AppError::bad_request("start must be at least 1"));
}
if limit < 1 {
return Err(AppError::bad_request("limit must be at least 1"));
}
let (asset, object): (DocumentAsset, DocumentAssetObject) = document_assets::table
.inner_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::id.eq(asset_id))
.first(&mut conn)?;
let version: DocumentVersion = document_versions::table
.find(asset.document_version_id)
.first(&mut conn)?;
let end = start
.checked_add(limit - 1)
.ok_or_else(|| AppError::bad_request("requested range is too large"))?;
if version.document_id != document_id {
return Err(AppError::not_found());
}
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::ordinal.ge(start))
.filter(document_asset_objects::ordinal.le(end))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)?;
let s3_key = object.s3_key.clone();
let object_metadata = object.metadata.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}")))?;
let expires_at = Utc::now()
.timestamp_millis()
.checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000)
.ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?;
Ok(Json(to_asset_response(
asset,
Some(object_metadata),
Some(presigned_url),
AssetResponseScope::Detailed,
)))
let mut object_responses = Vec::with_capacity(objects.len());
for object in objects {
let url = state
.storage
.presign_get_object(
&object.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
)
.await
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
object_responses.push(to_asset_object_response(
object,
Some(url),
Some(expires_at),
));
}
if object_responses.is_empty() {
return Err(AppError::not_found());
}
Ok(Json(to_asset_detail_response(asset, object_responses)))
}
pub async fn download_document(
@@ -1819,14 +1872,9 @@ pub(crate) async fn load_primary_assets(
.load(&mut conn)?;
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
for (asset, object) in assets {
for (asset, _object) in assets {
let version_id = asset.document_version_id;
let response = to_asset_response(
asset,
object.map(|o| o.metadata),
None,
AssetResponseScope::Summary,
);
let response = to_asset_summary(asset);
assets_by_version
.entry(version_id)
.or_default()
@@ -1916,52 +1964,42 @@ fn to_version_response(
}
}
fn merge_metadata(base: &Value, overlay: Option<&Value>) -> Value {
match (base, overlay) {
(Value::Object(base_obj), Some(Value::Object(overlay_obj))) => {
let mut merged = base_obj.clone();
for (key, value) in overlay_obj {
merged.insert(key.clone(), value.clone());
}
Value::Object(merged)
}
(_, Some(value)) => value.clone(),
(value, None) => value.clone(),
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,
}
}
#[derive(Clone, Copy)]
enum AssetResponseScope {
Summary,
Detailed,
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,
}
}
fn to_asset_response(
asset: DocumentAsset,
object_metadata: Option<Value>,
fn to_asset_object_response(
object: DocumentAssetObject,
url: Option<String>,
scope: AssetResponseScope,
) -> DocumentAssetResponse {
match scope {
AssetResponseScope::Summary => DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: None,
url: None,
created_at: None,
},
AssetResponseScope::Detailed => {
let metadata = merge_metadata(&asset.metadata, object_metadata.as_ref());
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: Some(metadata),
url,
created_at: Some(to_iso(asset.created_at)),
}
}
expires_at: Option<i64>,
) -> DocumentAssetObjectResponse {
DocumentAssetObjectResponse {
id: object.id,
ordinal: object.ordinal,
metadata: object.metadata,
url,
expires_at,
}
}
@@ -2023,14 +2061,7 @@ async fn load_asset_responses(
Ok(assets
.into_iter()
.map(|(asset, object)| {
to_asset_response(
asset,
object.map(|o| o.metadata),
None,
AssetResponseScope::Detailed,
)
})
.map(|(asset, _object)| to_asset_summary(asset))
.collect())
}
+3 -1
View File
@@ -75,7 +75,6 @@ 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),
@@ -120,11 +119,14 @@ pub fn create_router(state: AppState) -> Router<()> {
);
let protected_state = state.clone();
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
let protected_routes = Router::new()
.nest("/api/documents", documents_routes)
.nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes)
.nest("/api/correspondents", correspondents_routes)
.nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
Router::new()
+1 -1
View File
@@ -37,7 +37,7 @@ 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.
- GET /api/assets/:asset_id - Fetch asset metadata plus a presigned URL for a range of objects (query params: `start` and `limit`, defaulting to the first object).
Downloads
---------
+22 -4
View File
@@ -172,7 +172,7 @@ class AssetManager {
return next;
}
ensureAsset(documentId, asset, { force = false } = {}) {
ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) {
if (!documentId || !asset?.id) {
return Promise.resolve(asset || null);
}
@@ -189,7 +189,7 @@ class AssetManager {
return Promise.resolve({ ...asset, ...cached });
}
const inflightKey = `${documentId}:${asset.id}`;
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
if (!force && this.assetInflight.has(inflightKey)) {
return this.assetInflight.get(inflightKey);
}
@@ -198,13 +198,31 @@ class AssetManager {
return Promise.reject(new Error('AssetManager API client is not configured.'));
}
const params = {};
if (Number.isInteger(start) && start > 0) {
params.start = start;
}
if (Number.isInteger(limit) && limit > 0) {
params.limit = limit;
}
const requestConfig = Object.keys(params).length ? { params } : undefined;
const request = this.api
.get(`/documents/${documentId}/assets/${asset.id}`)
.get(`/assets/${asset.id}`, requestConfig)
.then(({ data }) => {
const objects = Array.isArray(data.objects) ? data.objects : [];
const primaryObject = objects[0] || null;
const expiresAt = typeof primaryObject?.expires_at === 'number'
? primaryObject.expires_at
: Date.now() + this.assetPresignTtlMs;
const entry = {
...asset,
...data,
expiresAt: Date.now() + this.assetPresignTtlMs,
objects,
url: primaryObject?.url || null,
expiresAt,
};
this.rememberAsset(entry);
return entry;
+4 -2
View File
@@ -446,8 +446,10 @@ const DetailPanel = ({
return null;
}
const asset = getDocumentAsset(doc, 'preview');
const width = Number(asset?.metadata?.width) || 0;
const height = Number(asset?.metadata?.height) || 0;
const primaryObject = asset?.objects?.[0] || null;
const primaryMetadata = primaryObject?.metadata || asset?.metadata || {};
const width = Number(primaryMetadata?.width) || 0;
const height = Number(primaryMetadata?.height) || 0;
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
return {
id: doc.id,
+4 -2
View File
@@ -22,8 +22,10 @@ const DocumentThumbnailImage = ({
}) => {
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(() => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version]);
const assetWidth = Number(thumbnailAsset?.metadata?.width);
const assetHeight = Number(thumbnailAsset?.metadata?.height);
const primaryObject = thumbnailAsset?.objects?.[0] || null;
const primaryMetadata = primaryObject?.metadata || thumbnailAsset?.metadata || {};
const assetWidth = Number(primaryMetadata?.width);
const assetHeight = Number(primaryMetadata?.height);
const dimensions = useMemo(() => {
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
+4 -2
View File
@@ -413,11 +413,13 @@ const AppLayout = () => {
const isAssetEquivalent = (lhs, rhs) => {
if (!lhs || !rhs) return false;
const lhsPrimaryMetadata = lhs?.objects?.[0]?.metadata || lhs?.metadata;
const rhsPrimaryMetadata = rhs?.objects?.[0]?.metadata || rhs?.metadata;
return (
lhs.id === rhs.id &&
lhs.url === rhs.url &&
lhs?.metadata?.width === rhs?.metadata?.width &&
lhs?.metadata?.height === rhs?.metadata?.height &&
lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width &&
lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height &&
lhs.mime_type === rhs.mime_type &&
lhs.asset_type === rhs.asset_type &&
lhs.created_at === rhs.created_at
+4 -2
View File
@@ -502,8 +502,10 @@ const SkeuomorphicWorkspace = ({
(doc) => {
if (!doc) return null;
const asset = resolvePreviewAsset(doc);
const width = asset?.metadata?.width;
const height = asset?.metadata?.height;
const primaryObject = asset?.objects?.[0] || null;
const primaryMetadata = primaryObject?.metadata || asset?.metadata || {};
const width = primaryMetadata?.width;
const height = primaryMetadata?.height;
if (typeof width === 'number' && typeof height === 'number') {
return { width, height };
}
+1 -2
View File
@@ -751,7 +751,6 @@ button.icon-button.ghost:hover:not([disabled]) {
}
.column + .column {
border-left: none;
padding-left: 1.25rem;
}
@@ -847,7 +846,6 @@ button.icon-button.ghost:hover:not([disabled]) {
color: inherit;
transition: background 0.12s ease, color 0.12s ease;
position: relative;
display: inline-block;
}
.folder-row span.name {
@@ -1511,6 +1509,7 @@ button.icon-button.ghost:hover:not([disabled]) {
position: relative;
padding: 1.25rem;
overflow-y: auto;
border-left: 1px solid var(--border);
}
.detail-panel .column-body {