13 Commits
Author SHA1 Message Date
nils 5091cd005e ollama experiment 2025-10-27 13:24:47 +01:00
nils 82aa8948cf tenants 2025-10-27 11:32:22 +01:00
nils 84a3a9a5b5 skeuo aspect ratio 2025-10-27 01:55:37 +01:00
nils b260065245 frontend: folders 2025-10-27 01:31:06 +01:00
nils 264316eb29 Merge remote-tracking branch 'origin/frontend'
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18m23s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 18m2s
2025-10-27 00:22:00 +01:00
nils c625c80958 folders
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has been cancelled
2025-10-27 00:20:53 +01:00
nils 157f03b655 Skip uploads of existing documents and support checksum preflight in importer 2025-10-26 02:56:35 +02:00
nils 9b7ca3d692 409 on duplicate 2025-10-26 02:33:30 +02:00
nils e51a59a829 upload with more data 2025-10-26 02:28:02 +02:00
nils 62cadbcfa0 openapi 2025-10-25 23:46:01 +02:00
nils d7aefc4110 k8s: disable index creation job 2025-10-25 13:57:56 +02:00
nils e972a8dddb fix folder move to root 2025-10-25 02:11:05 +02:00
nils 88b9375a4e foo 2025-10-24 12:55:21 +02:00
32 changed files with 4187 additions and 760 deletions
+87 -25
View File
@@ -67,7 +67,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -635,6 +635,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"url",
"utoipa",
"uuid",
]
@@ -704,7 +705,7 @@ dependencies = [
"regex",
"rustc-hash",
"shlex",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -989,7 +990,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -1000,7 +1001,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -1050,7 +1051,7 @@ dependencies = [
"dsl_auto_type",
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -1070,7 +1071,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
dependencies = [
"syn",
"syn 2.0.106",
]
[[package]]
@@ -1092,7 +1093,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -1118,7 +1119,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -1287,7 +1288,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -1812,6 +1813,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [
"equivalent",
"hashbrown 0.16.0",
"serde",
"serde_core",
]
[[package]]
@@ -2267,7 +2270,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -2359,7 +2362,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
"syn 2.0.106",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
@@ -2894,7 +2921,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -3082,6 +3109,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.106"
@@ -3110,7 +3147,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -3152,7 +3189,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -3163,7 +3200,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -3259,7 +3296,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -3409,7 +3446,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -3514,6 +3551,31 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utoipa"
version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.106",
"uuid",
]
[[package]]
name = "uuid"
version = "1.18.1"
@@ -3615,7 +3677,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
"wasm-bindgen-shared",
]
@@ -3650,7 +3712,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -3727,7 +3789,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -3738,7 +3800,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -3965,7 +4027,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
"synstructure",
]
@@ -3986,7 +4048,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
@@ -4006,7 +4068,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
"synstructure",
]
@@ -4046,7 +4108,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.106",
]
[[package]]
+1
View File
@@ -45,6 +45,7 @@ quick-xml = "0.32"
futures-util = "0.3"
url = "2.5"
once_cell = "1.19"
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
# Error handling
thiserror = "1.0"
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS folders_tenant_parent_name_unique_idx;
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
);
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
ON folders (
tenant_id,
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
);
@@ -0,0 +1,16 @@
-- Revert correspondent uniqueness to global name
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
ALTER TABLE correspondents ADD CONSTRAINT correspondents_name_unique UNIQUE (name);
-- Revert tag uniqueness to global label
DROP INDEX IF EXISTS tags_tenant_label_unique;
ALTER TABLE tags ADD CONSTRAINT tags_label_key UNIQUE (label);
-- Revert document filename uniqueness to global folder scope
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,20 @@
-- Ensure document filenames are unique per tenant + folder
DROP INDEX IF EXISTS documents_tenant_folder_filename_unique;
DROP INDEX IF EXISTS documents_unique_folder_filename;
CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
ON documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
-- Ensure tag labels are unique per tenant
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_label_key;
DROP INDEX IF EXISTS tags_tenant_label_unique;
CREATE UNIQUE INDEX tags_tenant_label_unique ON tags (tenant_id, label);
-- Ensure correspondent names are unique per tenant
ALTER TABLE correspondents DROP CONSTRAINT IF EXISTS correspondents_name_unique;
DROP INDEX IF EXISTS correspondents_tenant_name_unique;
CREATE UNIQUE INDEX correspondents_tenant_name_unique ON correspondents (tenant_id, name);
+8
View File
@@ -0,0 +1,8 @@
use backend::openapi::ApiDoc;
use utoipa::OpenApi;
fn main() {
let spec = ApiDoc::openapi();
let json = serde_json::to_string_pretty(&spec).expect("serialize openapi");
println!("{}", json);
}
+6 -2
View File
@@ -133,8 +133,12 @@ impl AppConfig {
fn redact_database_url(raw: &str) -> String {
match Url::parse(raw) {
Ok(mut parsed) => {
let _ = parsed.set_password(Some("*****"));
parsed.to_string()
if parsed.password().is_some() {
let _ = parsed.set_password(Some("*****"));
parsed.to_string()
} else {
raw.to_string()
}
}
Err(_) => "***".to_string(),
}
+14
View File
@@ -12,6 +12,7 @@ pub type AppResult<T> = Result<T, AppError>;
pub struct AppError {
status: StatusCode,
message: String,
code: Option<String>,
}
impl AppError {
@@ -19,6 +20,7 @@ impl AppError {
Self {
status,
message: message.into(),
code: None,
}
}
@@ -26,6 +28,10 @@ impl AppError {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, message)
}
pub fn unauthorized() -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
}
@@ -37,6 +43,11 @@ impl AppError {
pub fn internal<E: Display>(error: E) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
}
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
}
impl IntoResponse for AppError {
@@ -44,6 +55,7 @@ impl IntoResponse for AppError {
let status = self.status;
let body = Json(ErrorResponse {
error: self.message,
code: self.code,
});
(status, body).into_response()
}
@@ -52,6 +64,8 @@ impl IntoResponse for AppError {
#[derive(Serialize)]
struct ErrorResponse {
error: String,
#[serde(skip_serializing_if = "Option::is_none")]
code: Option<String>,
}
impl From<diesel::result::Error> for AppError {
+1
View File
@@ -4,6 +4,7 @@ pub mod db;
pub mod error;
pub mod jobs;
pub mod models;
pub mod openapi;
pub mod routes;
pub mod s3;
pub mod schema;
+958
View File
@@ -0,0 +1,958 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::{IntoParams, OpenApi, ToSchema};
use uuid::Uuid;
#[derive(OpenApi)]
#[openapi(
paths(
doc::health_check,
doc::login,
doc::refresh,
doc::logout,
doc::me,
doc::select_tenant,
doc::list_documents,
doc::check_document,
doc::upload_document,
doc::get_document,
doc::update_document,
doc::delete_document,
doc::download_document,
doc::download_with_token,
doc::move_document,
doc::assign_tags,
doc::remove_tag,
doc::bulk_move_documents,
doc::bulk_update_tags,
doc::bulk_assign_correspondents,
doc::assign_correspondents,
doc::remove_correspondent,
doc::reanalyze_selected_documents,
doc::list_document_assets,
doc::request_document_assets,
doc::get_document_asset,
doc::create_folder,
doc::ensure_folder_path,
doc::get_folder,
doc::list_folder_contents,
doc::delete_folder,
doc::update_folder,
doc::list_tags,
doc::create_tag,
doc::update_tag,
doc::delete_tag,
doc::list_correspondents,
doc::create_correspondent,
doc::update_correspondent,
doc::delete_correspondent,
),
components(
schemas(
schemas::LoginRequest,
schemas::AccessTokenResponse,
schemas::TenantSummary,
schemas::TenantSelectionResponse,
schemas::TenantSelectionRequest,
schemas::LoginResponseVariants,
schemas::DocumentResponse,
schemas::DocumentDetailResponse,
schemas::DocumentVersion,
schemas::DocumentAssetSummary,
schemas::DocumentAssetDetail,
schemas::DocumentAssetObject,
schemas::DocumentCorrespondent,
schemas::DocumentTag,
schemas::DocumentDownloadResponse,
schemas::UpdateDocumentRequest,
schemas::BulkMoveDocumentsRequest,
schemas::BulkMoveDocumentsResponse,
schemas::AssignTagsRequest,
schemas::MoveDocumentRequest,
schemas::BulkTagRequest,
schemas::BulkTagResponse,
schemas::CorrespondentAssignment,
schemas::BulkTagAction,
schemas::BulkCorrespondentsRequest,
schemas::BulkCorrespondentsResponse,
schemas::BulkCorrespondentAction,
schemas::AssignCorrespondentsRequest,
schemas::RemoveCorrespondentParams,
schemas::ReanalyzeRequest,
schemas::ReanalyzeResponse,
schemas::DocumentAssetRequestParams,
schemas::AssetObjectsQuery,
schemas::DocumentCheckQuery,
schemas::DocumentCheckResponse,
schemas::UploadDocumentForm,
schemas::CreateFolderRequest,
schemas::EnsureFolderPathRequest,
schemas::FolderResponse,
schemas::FolderInfo,
schemas::FolderContentsResponse,
schemas::FolderDocumentSummary,
schemas::UpdateFolderRequest,
schemas::FolderContentsParams,
schemas::TagCatalogEntry,
schemas::CreateTagRequest,
schemas::UpdateTagRequest,
schemas::CorrespondentCatalogEntry,
schemas::CreateCorrespondentRequest,
schemas::UpdateCorrespondentRequest,
)
),
tags(
(name = "Health", description = "Service health"),
(name = "Auth", description = "Authentication"),
(name = "Documents", description = "Document management"),
(name = "Assets", description = "Document assets"),
(name = "Folders", description = "Folder management"),
(name = "Tags", description = "Tag catalog"),
(name = "Correspondents", description = "Correspondent catalog")
)
)]
pub struct ApiDoc;
#[allow(dead_code)]
mod doc {
use super::schemas::*;
use uuid::Uuid;
#[allow(dead_code)]
fn __keep_uuid_import() {
let _ = Uuid::nil();
}
#[utoipa::path(
get,
path = "/api/health",
responses((status = 200, description = "Service is healthy")),
tag = "Health"
)]
pub(super) fn health_check() {}
#[utoipa::path(
post,
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login succeeded", body = LoginResponseVariants),
(status = 401, description = "Invalid credentials")
),
tag = "Auth"
)]
pub(super) fn login() {}
#[utoipa::path(
post,
path = "/api/auth/refresh",
responses(
(status = 200, description = "Refreshed access token", body = AccessTokenResponse),
(status = 401, description = "Missing or invalid refresh token")
),
tag = "Auth"
)]
pub(super) fn refresh() {}
#[utoipa::path(
post,
path = "/api/auth/logout",
responses((status = 204, description = "Session revoked")),
tag = "Auth"
)]
pub(super) fn logout() {}
#[utoipa::path(
get,
path = "/api/auth/me",
responses((status = 200, description = "Authenticated principal", body = AccessTokenResponse)),
tag = "Auth"
)]
pub(super) fn me() {}
#[utoipa::path(
post,
path = "/api/auth/select-tenant",
request_body = TenantSelectionRequest,
responses((status = 200, description = "Tenant selected", body = AccessTokenResponse)),
tag = "Auth"
)]
pub(super) fn select_tenant() {}
#[utoipa::path(
get,
path = "/api/documents",
params(DocumentListQuery),
responses((status = 200, description = "List documents", body = [DocumentResponse])),
tag = "Documents"
)]
pub(super) fn list_documents() {}
#[utoipa::path(
post,
path = "/api/documents",
request_body = UploadDocumentForm,
responses(
(status = 201, description = "Document created", body = DocumentDetailResponse),
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
(status = 204, description = "Upload skipped because the document already exists")
),
tag = "Documents"
)]
pub(super) fn upload_document() {}
#[utoipa::path(
get,
path = "/api/documents/check",
params(DocumentCheckQuery),
responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)),
tag = "Documents"
)]
pub(super) fn check_document() {}
#[utoipa::path(
get,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document detail", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub(super) fn get_document() {}
#[utoipa::path(
patch,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = UpdateDocumentRequest,
responses((status = 200, description = "Updated document", body = DocumentDetailResponse)),
tag = "Documents"
)]
pub(super) fn update_document() {}
#[utoipa::path(
delete,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 204, description = "Document deleted")),
tag = "Documents"
)]
pub(super) fn delete_document() {}
#[utoipa::path(
get,
path = "/api/documents/{id}/download",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)),
tag = "Documents"
)]
pub(super) fn download_document() {}
#[utoipa::path(
get,
path = "/download/{token}",
params(("token" = String, Path, description = "Download token")),
responses((status = 302, description = "Redirect to pre-signed URL")),
tag = "Documents"
)]
pub(super) fn download_with_token() {}
#[utoipa::path(
patch,
path = "/api/documents/{id}/folder",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = MoveDocumentRequest,
responses((status = 204, description = "Document moved")),
tag = "Documents"
)]
pub(super) fn move_document() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/tags",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignTagsRequest,
responses((status = 204, description = "Tags assigned")),
tag = "Documents"
)]
pub(super) fn assign_tags() {}
#[utoipa::path(
delete,
path = "/api/documents/{id}/tags/{tag_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("tag_id" = Uuid, Path, description = "Tag ID")
),
responses((status = 204, description = "Tag removed")),
tag = "Documents"
)]
pub(super) fn remove_tag() {}
#[utoipa::path(
post,
path = "/api/documents/bulk/move",
request_body = BulkMoveDocumentsRequest,
responses((status = 200, description = "Bulk move outcome", body = BulkMoveDocumentsResponse)),
tag = "Documents"
)]
pub(super) fn bulk_move_documents() {}
#[utoipa::path(
post,
path = "/api/documents/bulk/tags",
request_body = BulkTagRequest,
responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)),
tag = "Documents"
)]
pub(super) fn bulk_update_tags() {}
#[utoipa::path(
post,
path = "/api/documents/bulk/correspondents",
request_body = BulkCorrespondentsRequest,
responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentsResponse)),
tag = "Documents"
)]
pub(super) fn bulk_assign_correspondents() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/correspondents",
params(("id" = Uuid, Path, description = "Document ID")),
request_body = AssignCorrespondentsRequest,
responses((status = 204, description = "Correspondents assigned")),
tag = "Documents"
)]
pub(super) fn assign_correspondents() {}
#[utoipa::path(
delete,
path = "/api/documents/{id}/correspondents/{correspondent_id}",
params(
("id" = Uuid, Path, description = "Document ID"),
("correspondent_id" = Uuid, Path, description = "Correspondent ID"),
RemoveCorrespondentParams
),
responses((status = 204, description = "Correspondent removed")),
tag = "Documents"
)]
pub(super) fn remove_correspondent() {}
#[utoipa::path(
post,
path = "/api/documents/bulk/reanalyze",
request_body = ReanalyzeRequest,
responses((status = 200, description = "Reanalyze queued", body = ReanalyzeResponse)),
tag = "Documents"
)]
pub(super) fn reanalyze_selected_documents() {}
#[utoipa::path(
get,
path = "/api/documents/{id}/assets",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 200, description = "Document assets", body = [DocumentAssetSummary])),
tag = "Assets"
)]
pub(super) fn list_document_assets() {}
#[utoipa::path(
post,
path = "/api/documents/{id}/assets",
params(
("id" = Uuid, Path, description = "Document ID"),
DocumentAssetRequestParams
),
responses((status = 202, description = "Asset generation requested")),
tag = "Assets"
)]
pub(super) fn request_document_assets() {}
#[utoipa::path(
get,
path = "/api/assets/{asset_id}",
params(
("asset_id" = Uuid, Path, description = "Asset ID"),
AssetObjectsQuery
),
responses((status = 200, description = "Asset detail", body = DocumentAssetDetail)),
tag = "Assets"
)]
pub(super) fn get_document_asset() {}
#[utoipa::path(
post,
path = "/api/folders",
request_body = CreateFolderRequest,
responses((status = 200, description = "Folder created", body = FolderResponse)),
tag = "Folders"
)]
pub(super) fn create_folder() {}
#[utoipa::path(
post,
path = "/api/folders/path",
request_body = EnsureFolderPathRequest,
responses((status = 200, description = "Folder path ensured", body = FolderResponse)),
tag = "Folders"
)]
pub(super) fn ensure_folder_path() {}
#[utoipa::path(
get,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
responses((status = 200, description = "Folder detail", body = FolderResponse)),
tag = "Folders"
)]
pub(super) fn get_folder() {}
#[utoipa::path(
get,
path = "/api/folders/{id}/contents",
params(
("id" = Uuid, Path, description = "Folder ID"),
FolderContentsParams
),
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
tag = "Folders"
)]
pub(super) fn list_folder_contents() {}
#[utoipa::path(
delete,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
responses((status = 204, description = "Folder deleted")),
tag = "Folders"
)]
pub(super) fn delete_folder() {}
#[utoipa::path(
patch,
path = "/api/folders/{id}",
params(("id" = Uuid, Path, description = "Folder ID")),
request_body = UpdateFolderRequest,
responses((status = 204, description = "Folder updated")),
tag = "Folders"
)]
pub(super) fn update_folder() {}
#[utoipa::path(
get,
path = "/api/tags",
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
tag = "Tags"
)]
pub(super) fn list_tags() {}
#[utoipa::path(
post,
path = "/api/tags",
request_body = CreateTagRequest,
responses((status = 200, description = "Tag created", body = TagCatalogEntry)),
tag = "Tags"
)]
pub(super) fn create_tag() {}
#[utoipa::path(
patch,
path = "/api/tags/{id}",
params(("id" = Uuid, Path, description = "Tag ID")),
request_body = UpdateTagRequest,
responses((status = 200, description = "Tag updated", body = TagCatalogEntry)),
tag = "Tags"
)]
pub(super) fn update_tag() {}
#[utoipa::path(
delete,
path = "/api/tags/{id}",
params(("id" = Uuid, Path, description = "Tag ID")),
responses((status = 204, description = "Tag deleted")),
tag = "Tags"
)]
pub(super) fn delete_tag() {}
#[utoipa::path(
get,
path = "/api/correspondents",
responses((status = 200, description = "Correspondents", body = [CorrespondentCatalogEntry])),
tag = "Correspondents"
)]
pub(super) fn list_correspondents() {}
#[utoipa::path(
post,
path = "/api/correspondents",
request_body = CreateCorrespondentRequest,
responses((status = 200, description = "Correspondent created", body = CorrespondentCatalogEntry)),
tag = "Correspondents"
)]
pub(super) fn create_correspondent() {}
#[utoipa::path(
patch,
path = "/api/correspondents/{id}",
params(("id" = Uuid, Path, description = "Correspondent ID")),
request_body = UpdateCorrespondentRequest,
responses((status = 200, description = "Correspondent updated", body = CorrespondentCatalogEntry)),
tag = "Correspondents"
)]
pub(super) fn update_correspondent() {}
#[utoipa::path(
delete,
path = "/api/correspondents/{id}",
params(("id" = Uuid, Path, description = "Correspondent ID")),
responses((status = 204, description = "Correspondent deleted")),
tag = "Correspondents"
)]
pub(super) fn delete_correspondent() {}
}
#[cfg(test)]
mod tests {
use super::ApiDoc;
use utoipa::OpenApi;
#[test]
fn openapi_serializes() {
let spec = ApiDoc::openapi();
let _ = serde_json::to_string(&spec).expect("serialize openapi");
}
}
pub mod schemas {
use super::*;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct LoginRequest {
pub username: String,
pub password: String,
#[schema(nullable)]
pub preferred_tenant_slug: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct AccessTokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub tenant: TenantSnippet,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSummary {
pub tenant_id: Uuid,
pub slug: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSnippet {
pub id: Uuid,
pub slug: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionResponse {
pub access_token: String,
pub tenants: Vec<TenantSummary>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum LoginResponseVariants {
Token(AccessTokenResponse),
Selection(TenantSelectionResponse),
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct DocumentListQuery {
pub folder_id: Option<Uuid>,
pub include_deleted: Option<bool>,
pub include_descendants: Option<bool>,
pub query: Option<String>,
pub tags: Option<String>,
pub correspondents: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentTag {
pub id: Uuid,
pub label: String,
#[schema(nullable)]
pub color: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentAssetObject {
pub id: Uuid,
pub ordinal: i32,
pub metadata: Value,
#[schema(nullable)]
pub url: Option<String>,
#[schema(nullable)]
pub expires_at: Option<i64>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentAssetSummary {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
#[schema(nullable)]
pub cardinality: Option<i32>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentAssetDetail {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
pub created_at: String,
#[schema(nullable)]
pub cardinality: Option<i32>,
pub objects: Vec<DocumentAssetObject>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentVersion {
pub id: Uuid,
pub version_number: i32,
pub checksum: String,
pub size_bytes: i64,
pub created_at: String,
pub metadata: Value,
#[schema(nullable)]
pub operations_summary: Option<Value>,
#[schema(nullable)]
pub assets: Option<Vec<DocumentAssetSummary>>,
pub download_path: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentCorrespondent {
pub id: Uuid,
pub name: String,
pub role: String,
pub metadata: Value,
pub assigned_at: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentResponse {
pub id: Uuid,
pub filename: String,
pub title: String,
pub original_name: String,
#[schema(nullable)]
pub content_type: Option<String>,
#[schema(nullable)]
pub folder_id: Option<Uuid>,
pub uploaded_at: String,
pub updated_at: String,
#[schema(nullable)]
pub deleted_at: Option<String>,
#[schema(nullable)]
pub issued_at: Option<String>,
pub metadata: Value,
pub tags: Vec<DocumentTag>,
#[schema(nullable)]
pub correspondents: Option<Vec<DocumentCorrespondent>>,
#[schema(nullable)]
pub current_version: Option<DocumentVersion>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentDetailResponse {
pub document: DocumentResponse,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentDownloadResponse {
pub url: String,
pub expires_in: u64,
pub filename: String,
#[schema(nullable)]
pub content_type: Option<String>,
pub size_bytes: i64,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateDocumentRequest {
#[schema(nullable)]
pub title: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkMoveDocumentsRequest {
pub document_ids: Vec<Uuid>,
#[schema(nullable)]
pub folder_id: Option<Uuid>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkMoveDocumentsResponse {
pub updated: usize,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct AssignTagsRequest {
pub tag_ids: Vec<Uuid>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct MoveDocumentRequest {
#[schema(nullable)]
pub folder_id: Option<Uuid>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum BulkTagAction {
Add,
Remove,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkTagRequest {
pub document_ids: Vec<Uuid>,
pub tag_ids: Vec<Uuid>,
pub action: BulkTagAction,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkTagResponse {
pub added: usize,
pub removed: usize,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentAssignment {
pub correspondent_id: Uuid,
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct AssignCorrespondentsRequest {
pub assignments: Vec<CorrespondentAssignment>,
#[serde(default)]
pub replace: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum BulkCorrespondentAction {
Add,
Remove,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkCorrespondentsRequest {
pub document_ids: Vec<Uuid>,
pub assignments: Vec<CorrespondentAssignment>,
#[serde(default = "default_bulk_correspondent_action")]
pub action: BulkCorrespondentAction,
}
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
BulkCorrespondentAction::Add
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct BulkCorrespondentsResponse {
pub assigned: usize,
pub removed: usize,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct RemoveCorrespondentParams {
pub role: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ReanalyzeRequest {
pub document_ids: Vec<Uuid>,
#[serde(default)]
pub force: bool,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ReanalyzeResponse {
pub queued: usize,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct DocumentAssetRequestParams {
#[serde(default)]
pub force: Option<bool>,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct AssetObjectsQuery {
#[serde(default)]
pub start: Option<i32>,
#[serde(default)]
pub limit: Option<i32>,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct DocumentCheckQuery {
pub checksum: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DocumentCheckResponse {
pub exists: bool,
#[schema(nullable)]
pub document_id: Option<Uuid>,
#[schema(nullable)]
pub title: Option<String>,
#[schema(nullable)]
pub filename: Option<String>,
#[schema(nullable)]
pub version_id: Option<Uuid>,
#[schema(nullable)]
pub version_number: Option<i32>,
#[schema(nullable)]
pub uploaded_at: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UploadDocumentForm {
#[schema(value_type = String, format = Binary)]
pub file: String,
#[schema(nullable)]
pub folder_id: Option<Uuid>,
#[schema(nullable)]
pub metadata: Option<Value>,
#[schema(nullable)]
pub title: Option<String>,
#[schema(nullable, value_type = Vec<Uuid>)]
pub tag_ids: Option<Vec<Uuid>>,
#[schema(nullable, value_type = Vec<CorrespondentAssignment>)]
pub correspondents: Option<Vec<CorrespondentAssignment>>,
#[schema(nullable, example = "2024-01-01T00:00:00Z")]
pub issued_at: Option<String>,
#[schema(nullable)]
pub skip_existing: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateFolderRequest {
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct EnsureFolderPathRequest {
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct FolderInfo {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct FolderResponse {
pub folder: FolderInfo,
}
#[derive(Serialize, Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct FolderContentsParams {
#[serde(default)]
pub include_documents: Option<bool>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct FolderDocumentSummary {
pub id: Uuid,
pub title: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct FolderContentsResponse {
#[schema(nullable)]
pub folder: Option<FolderInfo>,
pub subfolders: Vec<FolderInfo>,
pub documents: Vec<FolderDocumentSummary>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateFolderRequest {
#[schema(nullable)]
pub parent_id: Option<Option<Uuid>>,
#[schema(nullable)]
pub name: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TagCatalogEntry {
pub id: Uuid,
pub label: String,
#[schema(nullable)]
pub color: Option<String>,
pub usage_count: i64,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateTagRequest {
pub label: String,
#[schema(nullable)]
pub color: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateTagRequest {
#[schema(nullable)]
pub label: Option<Option<String>>,
#[schema(nullable)]
pub color: Option<Option<String>>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CorrespondentCatalogEntry {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub role_counts: Value,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateCorrespondentRequest {
pub name: String,
#[schema(default, value_type = Object)]
pub metadata: Value,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct UpdateCorrespondentRequest {
#[schema(nullable)]
pub name: Option<String>,
#[schema(nullable)]
pub metadata: Option<Value>,
}
}
+56 -2
View File
@@ -43,6 +43,7 @@ pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub tenant: TenantSnippet,
}
#[derive(Serialize)]
@@ -51,12 +52,23 @@ pub struct TenantSummary {
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSnippet {
pub id: Uuid,
pub slug: String,
}
#[derive(Serialize)]
pub struct TenantSelectionResponse {
pub selection_token: String,
pub access_token: String,
pub tenants: Vec<TenantSummary>,
}
#[derive(Serialize)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[derive(Deserialize)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
@@ -121,7 +133,7 @@ pub async fn login(
.collect();
let response = Json(TenantSelectionResponse {
selection_token,
access_token: selection_token,
tenants,
})
.into_response();
@@ -250,6 +262,38 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user)
}
pub async fn list_tenants(
State(state): State<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>,
) -> AppResult<Json<TenantListResponse>> {
let bearer = auth.ok_or_else(AppError::unauthorized)?;
let token = bearer.token();
let user_id = match state.jwt.verify_token(token) {
Ok(claims) => claims.sub,
Err(_) => {
let claims = state
.jwt
.verify_tenant_selector_token(token)
.map_err(|_| AppError::unauthorized())?;
claims.sub
}
};
let mut conn = state.db_unscoped()?;
let tenants = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user_id))
.select((tenant_dsl::id, tenant_dsl::slug))
.load::<(Uuid, String)>(&mut conn)?
.into_iter()
.map(|(id, slug)| TenantSnippet { id, slug })
.collect();
Ok(Json(TenantListResponse { tenants }))
}
fn issue_session(
state: &AppState,
conn: &mut PgConnection,
@@ -262,6 +306,12 @@ fn issue_session(
.generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?;
let tenant_slug: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::slug)
.first(conn)
.map_err(AppError::from)?;
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
@@ -283,6 +333,10 @@ fn issue_session(
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: tenant_id,
slug: tenant_slug,
},
})
.into_response();
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
use std::path::Path as FsPath;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::state::AppState;
use crate::utils::time::to_iso;
use super::{
DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse,
DocumentVersionResponse,
};
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| AppError::internal(format!("failed to generate download token: {err}")))
}
pub fn to_version_response(
version: DocumentVersion,
include_operations_summary: bool,
) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
s3_key: version.s3_key,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
metadata: version.metadata,
operations_summary: if include_operations_summary {
Some(version.operations_summary)
} else {
None
},
}
}
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 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()
}
}
@@ -0,0 +1,61 @@
use std::collections::HashSet;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use super::CorrespondentAssignmentInput;
pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"];
pub fn normalize_role(value: &str) -> String {
value.trim().to_lowercase()
}
pub fn is_valid_correspondent_role(role: &str) -> bool {
CORRESPONDENT_ROLES.iter().any(|allowed| *allowed == role)
}
pub fn normalize_correspondent_assignments(
assignments: &[CorrespondentAssignmentInput],
) -> AppResult<(Vec<(Uuid, String)>, Vec<Uuid>, Vec<String>)> {
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 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 mut correspondents_vec: Vec<Uuid> = correspondent_ids.into_iter().collect();
correspondents_vec.sort();
let mut roles_vec: Vec<String> = role_set.into_iter().collect();
roles_vec.sort();
Ok((normalized_pairs, correspondents_vec, roles_vec))
}
@@ -0,0 +1,91 @@
use serde_json::Value;
use uuid::Uuid;
pub fn build_quickwit_query(input: &str) -> Option<String> {
let tokens: Vec<String> = input
.split_whitespace()
.filter(|token| !token.is_empty())
.map(|token| {
let normalized = token.to_lowercase();
escape_quickwit_token(&normalized)
})
.collect();
if tokens.is_empty() {
return None;
}
let parts: Vec<String> = tokens
.into_iter()
.map(|token| format!("(title:{token} OR text:{token})"))
.collect();
Some(parts.join(" AND "))
}
pub fn escape_quickwit_token(token: &str) -> String {
let mut escaped = String::with_capacity(token.len());
for ch in token.chars() {
match ch {
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
| '*' | '?' | ':' | '\\' | '/' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
pub fn extract_document_id(hit: &Value) -> Option<Uuid> {
for key in ["_source", "source", "fields", "stored_fields"] {
if let Some(value) = hit.get(key) {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
}
if let Some(value) = hit.get("document_id") {
if let Some(uuid) = extract_uuid_from_value(value) {
return Some(uuid);
}
}
None
}
pub fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
if let Some(obj) = value.as_object() {
if let Some(inner) = obj.get("document_id") {
return parse_uuid_value(inner);
}
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = extract_uuid_from_value(item) {
return Some(uuid);
}
}
}
parse_uuid_value(value)
}
pub fn parse_uuid_value(value: &Value) -> Option<Uuid> {
if let Some(s) = value.as_str() {
return Uuid::parse_str(s).ok();
}
if let Some(arr) = value.as_array() {
for item in arr {
if let Some(uuid) = parse_uuid_value(item) {
return Some(uuid);
}
}
}
None
}
+148 -45
View File
@@ -4,6 +4,7 @@ use axum::{
};
use diesel::{dsl::exists, prelude::*, PgConnection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::models::{Document, Folder, NewFolder};
@@ -18,7 +19,10 @@ use super::documents::{
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
to_document_response, DocumentResponse,
};
use crate::utils::time::to_iso;
use crate::utils::{
json::{classify_nullable, NullableValue},
time::to_iso,
};
#[derive(Deserialize)]
pub struct CreateFolderRequest {
@@ -32,13 +36,6 @@ pub struct EnsureFolderPathRequest {
pub segments: Vec<String>,
}
#[derive(Deserialize)]
pub struct UpdateFolderRequest {
#[serde(default)]
pub parent_id: Option<Option<Uuid>>,
pub name: Option<String>,
}
#[derive(Serialize)]
pub struct FolderResponse {
pub folder: FolderInfo,
@@ -70,6 +67,24 @@ pub struct FolderInfo {
pub updated_at: String,
}
pub async fn get_folder(
Path(folder_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<FolderResponse>> {
let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(&mut conn)?;
Ok(Json(FolderResponse {
folder: folder_to_info(folder),
}))
}
pub async fn ensure_folder_path(
TenantScopedConn {
mut conn,
@@ -94,16 +109,16 @@ pub async fn ensure_folder_path(
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)
.optional()?
};
@@ -118,13 +133,32 @@ pub async fn ensure_folder_path(
tenant_id,
};
diesel::insert_into(folders::table)
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder)
.execute(conn)?;
.on_conflict_do_nothing()
.returning(folders::id)
.get_result(conn)
.optional()?;
folders::table.find(new_folder.id).first(conn)?
if let Some(id) = inserted_id {
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?
} else if let Some(parent_id) = current_parent {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(conn)?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(conn)?
}
};
current_parent = Some(folder.id);
last_folder = Some(folder);
}
@@ -149,18 +183,61 @@ pub async fn create_folder(
return Err(AppError::bad_request("name must not be empty"));
}
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: payload.name.trim().to_string(),
parent_id: payload.parent_id,
tenant_id,
let name = payload.name.trim();
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(&mut conn)
.optional()?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(&mut conn)
.optional()?
};
diesel::insert_into(folders::table)
.values(&new_folder)
.execute(&mut conn)?;
let folder: Folder = if let Some(folder) = existing {
folder
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: payload.parent_id,
tenant_id,
};
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder)
.on_conflict_do_nothing()
.returning(folders::id)
.get_result(&mut conn)
.optional()?;
if let Some(id) = inserted_id {
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(&mut conn)?
} else if let Some(parent_id) = payload.parent_id {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(&mut conn)?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(&mut conn)?
}
};
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
Ok(Json(FolderResponse {
folder: folder_to_info(folder),
}))
@@ -322,8 +399,15 @@ pub async fn update_folder(
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateFolderRequest>,
Json(body): Json<Value>,
) -> AppResult<StatusCode> {
if !body.is_object() {
return Err(AppError::bad_request("request body must be a JSON object"));
}
let parent_class = classify_nullable(body.get("parent_id")).map_err(AppError::bad_request)?;
let name_class = classify_nullable(body.get("name")).map_err(AppError::bad_request)?;
conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table
.find(folder_id)
@@ -332,42 +416,61 @@ pub async fn update_folder(
let mut next_parent = folder.parent_id;
let mut parent_changed = false;
if let Some(parent_request) = payload.parent_id {
if parent_request == Some(folder_id) {
return Err(AppError::bad_request("folder cannot be its own parent"));
match parent_class {
NullableValue::Omitted => {}
NullableValue::Null => {
if folder.parent_id.is_some() {
parent_changed = true;
}
next_parent = None;
}
NullableValue::String(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("parent_id must not be empty"));
}
let parent_id = Uuid::parse_str(trimmed)
.map_err(|_| AppError::bad_request("parent_id must be a valid UUID or null"))?;
if parent_id == folder_id {
return Err(AppError::bad_request("folder cannot be its own parent"));
}
if let Some(parent_id) = parent_request {
let _parent: Folder = folders::table
.find(parent_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request(
"cannot move folder into itself or a descendant",
));
if folder.parent_id != Some(parent_id) {
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request(
"cannot move folder into itself or a descendant",
));
}
parent_changed = true;
}
}
parent_changed = parent_request != folder.parent_id;
next_parent = parent_request;
next_parent = Some(parent_id);
}
}
let mut new_name = folder.name.clone();
let mut name_changed = false;
if let Some(name) = payload.name {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
match name_class {
NullableValue::Omitted => {}
NullableValue::Null => {
return Err(AppError::bad_request("name cannot be null"));
}
NullableValue::String(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != folder.name {
new_name = trimmed.to_string();
name_changed = true;
if trimmed != folder.name {
new_name = trimmed.to_string();
name_changed = true;
}
}
}
+22 -5
View File
@@ -2,12 +2,15 @@ use axum::http::HeaderValue;
use axum::{
extract::DefaultBodyLimit,
middleware,
response::Json,
routing::{delete, get, patch, post},
Router,
};
use std::sync::Arc;
use tower_http::cors::{AllowOrigin, CorsLayer};
use utoipa::OpenApi;
use crate::{auth::AuthenticatedUser, state::AppState};
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
pub mod auth;
pub mod correspondents;
@@ -51,9 +54,11 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout))
.route("/select-tenant", post(auth::select_tenant))
.route("/tenants", get(auth::list_tenants))
.route("/me", get(auth::me));
let documents_routes = Router::new()
.route("/check", get(documents::check_document))
.route(
"/",
get(documents::list_documents).post(documents::upload_document),
@@ -97,10 +102,9 @@ pub fn create_router(state: AppState) -> Router<()> {
let folders_routes = Router::new()
.route("/", post(folders::create_folder))
.route("/path", post(folders::ensure_folder_path))
.route(
"/:id",
delete(folders::delete_folder).patch(folders::update_folder),
)
.route("/:id", get(folders::get_folder))
.route("/:id", delete(folders::delete_folder))
.route("/:id", patch(folders::update_folder))
.route("/:id/contents", get(folders::list_folder_contents));
let tags_routes = Router::new()
@@ -129,9 +133,22 @@ pub fn create_router(state: AppState) -> Router<()> {
.nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
let openapi_arc = Arc::new(ApiDoc::openapi());
let docs_route = Router::new().route(
"/api/docs/openapi.json",
get({
let spec = openapi_arc.clone();
move || {
let spec = spec.clone();
async move { Json((*spec).clone()) }
}
}),
);
Router::new()
.merge(download_routes)
.merge(protected_routes)
.merge(docs_route)
.nest("/api/auth", auth_routes)
.route("/api/health", get(health::health_check))
.with_state(state)
+219 -12
View File
@@ -11,12 +11,13 @@ use axum::Router;
use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
use backend::db::{self, PgPool};
use backend::models::{Job, NewUser, NewUserMembership};
use backend::models::{Job, NewUser, NewUserMembership, Tenant};
use backend::routes;
use backend::state::AppState;
use backend::storage::ObjectStorage;
use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use diesel::OptionalExtension;
use diesel::PgConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt;
@@ -151,16 +152,20 @@ impl TestApp {
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
let router = routes::create_router(state.clone());
Ok(Self {
let app = Self {
state,
router,
storage,
})
};
app.ensure_default_tenant().await?;
Ok(app)
}
pub async fn cleanup(&self) -> Result<()> {
let pool = self.state.pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let _ = tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
@@ -168,7 +173,10 @@ impl TestApp {
Ok(())
})
.await
.context("cleanup task panicked")?
.context("cleanup task panicked")?;
self.ensure_default_tenant().await?;
Ok(())
}
#[allow(dead_code)]
@@ -176,6 +184,20 @@ impl TestApp {
self.storage.clone()
}
#[allow(dead_code)]
pub async fn storage_key_for(&self, key: &str) -> Result<String> {
let tenant = self
.state
.tenants
.get_by_slug(&self.state.config.default_tenant_slug)
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
let root = tenant
.storage_root
.clone()
.ok_or_else(|| anyhow!("default tenant missing storage root"))?;
Ok(format!("{}{}", root, key))
}
pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
let username = username.to_string();
let password = password.to_string();
@@ -184,7 +206,7 @@ impl TestApp {
.state
.tenants
.tenant_id_for_slug(&self.state.config.default_tenant_slug)
.context("default tenant not found")?;
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
self.with_conn(move |conn| {
let password_hash = hash_password(&password)?;
let user = NewUser {
@@ -213,6 +235,60 @@ impl TestApp {
.await
}
async fn ensure_default_tenant(&self) -> Result<Uuid> {
let slug_value = self.state.config.default_tenant_slug.clone();
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
self.with_conn(move |conn| {
use backend::schema::tenants::dsl as tenants_dsl;
let existing = tenants_dsl::tenants
.filter(tenants_dsl::slug.eq(&slug_value))
.first::<Tenant>(conn)
.optional()
.context("failed to load default tenant")?;
let tenant_id = if let Some(current) = existing {
let desired_root = current
.storage_root
.clone()
.filter(|root| root.ends_with('/'))
.unwrap_or_else(|| format!("test-tenants/{}/", current.id));
if current.storage_root.as_deref() != Some(desired_root.as_str()) {
diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id)))
.set(tenants_dsl::storage_root.eq(Some(desired_root)))
.execute(conn)
.context("failed to update default tenant storage root")?;
}
current.id
} else {
let new_id = Uuid::new_v4();
let root = format!("test-tenants/{}/", new_id);
let quickwit_value = if quickwit_enabled {
Some(format!("documents-{}", new_id))
} else {
None
};
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(new_id),
tenants_dsl::slug.eq(&slug_value),
tenants_dsl::storage_root.eq(Some(root)),
tenants_dsl::quickwit_index.eq(quickwit_value),
))
.execute(conn)
.context("failed to insert default tenant")?;
new_id
};
Ok(tenant_id)
})
.await
}
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
#[derive(Serialize)]
struct LoginPayload<'a> {
@@ -247,12 +323,12 @@ impl TestApp {
#[derive(Deserialize)]
struct TenantSummary {
tenant_id: Uuid,
slug: String,
_slug: String,
}
#[derive(Deserialize)]
struct TenantSelectionResponse {
selection_token: String,
access_token: String,
tenants: Vec<TenantSummary>,
}
@@ -274,7 +350,7 @@ impl TestApp {
&SelectTenantPayload {
tenant_id: target_tenant,
},
Some(&selection.selection_token),
Some(&selection.access_token),
)
.await?;
@@ -402,6 +478,60 @@ impl TestApp {
data: &[u8],
folder_id: Option<Uuid>,
token: &str,
) -> Result<hyper::Response<Body>> {
let extras = UploadExtras::empty();
self.upload_document_with_extras(
path,
filename,
content_type,
data,
folder_id,
extras,
token,
)
.await
}
pub async fn upload_document_with_options(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
title: Option<&str>,
metadata_json: Option<&str>,
token: &str,
) -> Result<hyper::Response<Body>> {
let extras = UploadExtras {
title,
metadata_json,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: false,
};
self.upload_document_with_extras(
path,
filename,
content_type,
data,
folder_id,
extras,
token,
)
.await
}
pub async fn upload_document_with_extras(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
extras: UploadExtras<'_>,
token: &str,
) -> Result<hyper::Response<Body>> {
let boundary = format!("boundary-{}", Uuid::new_v4());
let mut body = Vec::new();
@@ -424,6 +554,46 @@ impl TestApp {
body.extend(b"\r\n");
}
if let Some(title_value) = extras.title {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
body.extend(title_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(metadata_value) = extras.metadata_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n");
body.extend(metadata_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(tag_ids_value) = extras.tag_ids_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n");
body.extend(tag_ids_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(correspondents_value) = extras.correspondents_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n");
body.extend(correspondents_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(issued_at_value) = extras.issued_at {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n");
body.extend(issued_at_value.as_bytes());
body.extend(b"\r\n");
}
if extras.skip_existing {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n");
}
body.extend(format!("--{boundary}--\r\n").as_bytes());
let builder = Request::builder()
@@ -444,7 +614,7 @@ impl TestApp {
.expect("infallible response"))
}
async fn with_conn<F, T>(&self, f: F) -> Result<T>
pub async fn with_conn<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
T: Send + 'static,
@@ -461,6 +631,28 @@ impl TestApp {
}
}
pub struct UploadExtras<'a> {
pub title: Option<&'a str>,
pub metadata_json: Option<&'a str>,
pub tag_ids_json: Option<&'a str>,
pub correspondents_json: Option<&'a str>,
pub issued_at: Option<&'a str>,
pub skip_existing: bool,
}
impl<'a> UploadExtras<'a> {
pub fn empty() -> Self {
Self {
title: None,
metadata_json: None,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: false,
}
}
}
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
DB_LOCK.lock().await
}
@@ -490,13 +682,28 @@ async fn prepare_database(pool: &PgPool) -> Result<()> {
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
conn.batch_execute(
"TRUNCATE TABLE document_tags, document_versions, documents, folders, tags, users RESTART IDENTITY CASCADE;",
"TRUNCATE TABLE \
document_asset_objects, \
document_assets, \
document_correspondents, \
correspondents, \
document_tags, \
document_versions, \
documents, \
folders, \
jobs, \
refresh_tokens, \
tags, \
user_memberships, \
users, \
tenants \
RESTART IDENTITY CASCADE;",
)
.context("failed to truncate tables")?;
Ok(())
}
fn hash_password(password: &str) -> Result<String> {
pub fn hash_password(password: &str) -> Result<String> {
use argon2::password_hash::{PasswordHasher, SaltString};
use argon2::Argon2;
+379 -32
View File
@@ -2,7 +2,7 @@ mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -15,6 +15,7 @@ struct DocumentDetail {
struct DocumentInfo {
id: Uuid,
title: String,
filename: String,
original_name: String,
deleted_at: Option<String>,
issued_at: Option<String>,
@@ -56,12 +57,12 @@ struct DocumentDownload {
filename: String,
}
#[derive(Deserialize)]
#[derive(Deserialize)]
struct BulkReanalyze {
queued: usize,
}
#[derive(Deserialize)]
struct BulkMoveResult {
updated: usize,
}
@@ -167,7 +168,14 @@ async fn upload_and_list_document() -> Result<()> {
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::CREATED);
{
let status = upload.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
@@ -186,16 +194,24 @@ async fn upload_and_list_document() -> Result<()> {
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
assert!(current_version.assets.is_empty());
let storage_key = app.storage_key_for(&current_version.s3_key).await?;
let stored = app
.storage()
.get(&current_version.s3_key)
.get(&storage_key)
.await
.expect("object stored");
assert_eq!(stored.bytes, file_bytes);
assert_eq!(app.storage().object_count().await, 1);
let response = app.get("/api/documents", Some(&token)).await?;
assert_eq!(response.status(), StatusCode::OK);
{
let status = response.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let body = body_to_vec(response.into_body()).await?;
let mut list: Vec<DocumentListItem> = serde_json::from_slice(&body)?;
assert_eq!(list.len(), 1);
@@ -220,7 +236,14 @@ async fn upload_and_list_document() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(download.status(), StatusCode::OK);
{
let status = download.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let body = body_to_vec(download.into_body()).await?;
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
assert!(download_info.url.contains(&current_version.s3_key));
@@ -239,6 +262,50 @@ async fn upload_and_list_document() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn upload_document_with_custom_title_sets_filename() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "passw0rd";
app.insert_user("nora", password, "admin").await?;
let token = app.login_token("nora", password).await?;
let file_bytes = b"example contract body".to_vec();
let title = "Vendor Contract";
let original_filename = "scan.pdf";
let upload = app
.upload_document_with_options(
"/api/documents",
original_filename,
"application/pdf",
&file_bytes,
None,
Some(title),
None,
&token,
)
.await?;
{
let status = upload.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
assert_eq!(detail.document.title, title);
assert_eq!(detail.document.filename, format!("{title}.pdf"));
assert_eq!(detail.document.original_name, original_filename);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn duplicate_and_restore_document() -> Result<()> {
let _lock = acquire_db_lock().await;
@@ -259,7 +326,14 @@ async fn duplicate_and_restore_document() -> Result<()> {
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
{
let status = first.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
@@ -273,7 +347,14 @@ async fn duplicate_and_restore_document() -> Result<()> {
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::OK);
{
let status = second.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
@@ -306,7 +387,14 @@ async fn duplicate_and_restore_document() -> Result<()> {
&token,
)
.await?;
assert_eq!(third.status(), StatusCode::OK);
{
let status = third.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let third_body = body_to_vec(third.into_body()).await?;
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
@@ -318,6 +406,132 @@ async fn duplicate_and_restore_document() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn upload_skips_existing_when_requested() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "skip-doc";
app.insert_user("skip", password, "admin").await?;
let token = app.login_token("skip", password).await?;
let primary_tag_payload = CreateTagPayload {
label: "primary",
color: None,
};
let primary_tag_resp = app
.post_json("/api/tags", &primary_tag_payload, Some(&token))
.await?;
{
let status = primary_tag_resp.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let primary_tag_body = body_to_vec(primary_tag_resp.into_body()).await?;
let primary_tag: TagResponse = serde_json::from_slice(&primary_tag_body)?;
let payload = b"identical document payload";
let primary_tag_ids = format!("[\"{}\"]", primary_tag.id);
let extras = UploadExtras {
title: Some("Original"),
metadata_json: None,
tag_ids_json: Some(primary_tag_ids.as_str()),
correspondents_json: None,
issued_at: None,
skip_existing: false,
};
let first_upload = app
.upload_document_with_extras(
"/api/documents",
"original.pdf",
"application/pdf",
payload,
None,
extras,
&token,
)
.await?;
{
let status = first_upload.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let first_body = body_to_vec(first_upload.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let alt_tag_payload = CreateTagPayload {
label: "alternate",
color: None,
};
let alt_tag_resp = app
.post_json("/api/tags", &alt_tag_payload, Some(&token))
.await?;
{
let status = alt_tag_resp.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let alt_tag_body = body_to_vec(alt_tag_resp.into_body()).await?;
let alt_tag: TagResponse = serde_json::from_slice(&alt_tag_body)?;
let alt_tag_ids = format!("[\"{}\"]", alt_tag.id);
let skip_extras = UploadExtras {
title: Some("Updated"),
metadata_json: None,
tag_ids_json: Some(alt_tag_ids.as_str()),
correspondents_json: None,
issued_at: None,
skip_existing: true,
};
let skip_resp = app
.upload_document_with_extras(
"/api/documents",
"ignored.pdf",
"application/pdf",
payload,
None,
skip_extras,
&token,
)
.await?;
assert_eq!(skip_resp.status(), StatusCode::NO_CONTENT);
let fetch = app
.get(
&format!("/api/documents/{}", first_detail.document.id),
Some(&token),
)
.await?;
{
let status = fetch.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let fetch_body = body_to_vec(fetch.into_body()).await?;
let fetched: DocumentDetail = serde_json::from_slice(&fetch_body)?;
assert_eq!(fetched.document.id, first_detail.document.id);
assert_eq!(fetched.document.title, first_detail.document.title);
assert_eq!(fetched.document.tags.len(), 1);
assert_eq!(fetched.document.tags[0].label, "primary");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_move_documents_to_folder() -> Result<()> {
@@ -338,7 +552,14 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
&token,
)
.await?;
assert_eq!(alpha.status(), StatusCode::CREATED);
{
let status = alpha.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let alpha_body = body_to_vec(alpha.into_body()).await?;
let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?;
@@ -352,7 +573,10 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
&token,
)
.await?;
assert_eq!(beta.status(), StatusCode::CREATED);
{
let status = beta.status();
assert!(status.is_success(), "status was {}", status);
}
let beta_body = body_to_vec(beta.into_body()).await?;
let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?;
@@ -366,7 +590,10 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(folder_resp.status(), StatusCode::OK);
{
let status = folder_resp.status();
assert!(status.is_success(), "status was {}", status);
}
let folder_body = body_to_vec(folder_resp.into_body()).await?;
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
@@ -380,8 +607,14 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(move_resp.status(), StatusCode::OK);
let move_status = move_resp.status();
let move_body = body_to_vec(move_resp.into_body()).await?;
assert!(
move_status.is_success(),
"status was {} body {}",
move_status,
String::from_utf8_lossy(&move_body)
);
let result: BulkMoveResult = serde_json::from_slice(&move_body)?;
assert_eq!(result.updated, 2);
@@ -391,7 +624,10 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(folder_contents.status(), StatusCode::OK);
{
let status = folder_contents.status();
assert!(status.is_success(), "status was {}", status);
}
let folder_body = body_to_vec(folder_contents.into_body()).await?;
let folder_docs: FolderContents = serde_json::from_slice(&folder_body)?;
let moved_ids: Vec<_> = folder_docs.documents.iter().map(|doc| doc.id).collect();
@@ -429,7 +665,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
{
let status = first.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
@@ -443,7 +686,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
{
let status = second.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
@@ -457,7 +707,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(urgent_tag.status(), StatusCode::OK);
{
let status = urgent_tag.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let urgent_body = body_to_vec(urgent_tag.into_body()).await?;
let urgent: TagResponse = serde_json::from_slice(&urgent_body)?;
@@ -471,7 +728,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(review_tag.status(), StatusCode::OK);
{
let status = review_tag.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let review_body = body_to_vec(review_tag.into_body()).await?;
let review: TagResponse = serde_json::from_slice(&review_body)?;
@@ -486,7 +750,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(add_resp.status(), StatusCode::OK);
{
let status = add_resp.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let add_body = body_to_vec(add_resp.into_body()).await?;
let add_result: BulkTagResult = serde_json::from_slice(&add_body)?;
assert_eq!(add_result.added, 4);
@@ -495,7 +766,10 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
let refreshed = app
.get(&format!("/api/documents/{}", doc_id), Some(&token))
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
{
let status = refreshed.status();
assert!(status == StatusCode::OK || status == StatusCode::CREATED);
}
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
let labels: Vec<_> = detail
@@ -519,7 +793,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(remove_resp.status(), StatusCode::OK);
{
let status = remove_resp.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let remove_body = body_to_vec(remove_resp.into_body()).await?;
let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?;
assert_eq!(remove_result.removed, 2);
@@ -563,7 +844,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
{
let status = first.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
@@ -577,7 +865,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
{
let status = second.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
@@ -588,7 +883,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(sender.status(), StatusCode::OK);
{
let status = sender.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let sender_body = body_to_vec(sender.into_body()).await?;
let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?;
@@ -599,7 +901,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(receiver.status(), StatusCode::OK);
{
let status = receiver.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let receiver_body = body_to_vec(receiver.into_body()).await?;
let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?;
@@ -627,7 +936,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(assign_resp.status(), StatusCode::OK);
{
let status = assign_resp.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let assign_body = body_to_vec(assign_resp.into_body()).await?;
let assign_result: BulkCorrespondentResult = serde_json::from_slice(&assign_body)?;
assert_eq!(assign_result.assigned, 4);
@@ -637,7 +953,10 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
{
let status = refreshed.status();
assert!(status == StatusCode::OK || status == StatusCode::CREATED);
}
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 2);
@@ -660,7 +979,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(duplicate_resp.status(), StatusCode::OK);
{
let status = duplicate_resp.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let duplicate_body = body_to_vec(duplicate_resp.into_body()).await?;
let duplicate_result: BulkCorrespondentResult = serde_json::from_slice(&duplicate_body)?;
assert_eq!(duplicate_result.assigned, 0);
@@ -673,7 +999,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(replacement.status(), StatusCode::OK);
{
let status = replacement.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let replacement_body = body_to_vec(replacement.into_body()).await?;
let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?;
@@ -697,7 +1030,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(replace_resp.status(), StatusCode::OK);
{
let status = replace_resp.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let replace_body = body_to_vec(replace_resp.into_body()).await?;
let replace_result: BulkCorrespondentResult = serde_json::from_slice(&replace_body)?;
assert_eq!(replace_result.assigned, 2);
@@ -743,7 +1083,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
Some(&token),
)
.await?;
assert_eq!(remove_resp.status(), StatusCode::OK);
{
let status = remove_resp.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let remove_body = body_to_vec(remove_resp.into_body()).await?;
let remove_result: BulkCorrespondentResult = serde_json::from_slice(&remove_body)?;
assert_eq!(remove_result.assigned, 0);
+189 -14
View File
@@ -5,6 +5,7 @@ use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)]
@@ -16,6 +17,7 @@ struct FolderResponse {
struct FolderInfo {
id: Uuid,
name: String,
parent_id: Option<Uuid>,
}
#[derive(Deserialize)]
@@ -42,14 +44,6 @@ struct EnsureFolderPath<'a> {
segments: &'a [&'a str],
}
#[derive(Serialize)]
struct UpdateFolderRequest {
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: Option<Option<Uuid>>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Serialize)]
struct MoveDocumentRequest {
folder_id: Option<Uuid>,
@@ -150,6 +144,78 @@ async fn folder_move_and_delete_flow() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn update_folder_parent_to_root() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "rootpass";
app.insert_user("root-admin", password, "admin").await?;
let token = app.login_token("root-admin", password).await?;
// Create a parent folder under root
let parent_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Parent",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(parent_resp.status(), StatusCode::OK);
let parent_body = body_to_vec(parent_resp.into_body()).await?;
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
// Create a child folder inside the parent
let child_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Child",
parent_id: Some(parent.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(child_resp.status(), StatusCode::OK);
let child_body = body_to_vec(child_resp.into_body()).await?;
let child: FolderResponse = serde_json::from_slice(&child_body)?;
// Move the child back to the root by setting parent_id to null
let update_resp = app
.patch_json(
&format!("/api/folders/{}", child.folder.id),
&json!({ "parent_id": null }),
Some(&token),
)
.await?;
assert_eq!(update_resp.status(), StatusCode::NO_CONTENT);
// Fetch the child folder and ensure parent_id is now null
let updated_resp = app
.get(&format!("/api/folders/{}", child.folder.id), Some(&token))
.await?;
assert_eq!(updated_resp.status(), StatusCode::OK);
let updated_body = body_to_vec(updated_resp.into_body()).await?;
let updated_folder: FolderResponse = serde_json::from_slice(&updated_body)?;
assert!(updated_folder.folder.parent_id.is_none());
// Root contents should include the child folder by name
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
assert_eq!(root_contents.status(), StatusCode::OK);
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
assert!(root
.subfolders
.iter()
.any(|folder| folder.id == child.folder.id));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn ensure_path_creates_nested_folders() -> Result<()> {
let _lock = acquire_db_lock().await;
@@ -166,14 +232,14 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
let first_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert_eq!(first_resp.status(), StatusCode::OK);
assert!(first_resp.status().is_success());
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert_eq!(second_resp.status(), StatusCode::OK);
assert!(second_resp.status().is_success());
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(second_folder.folder.id, first_folder.folder.id);
@@ -227,6 +293,118 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn create_folder_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "idempotent";
app.insert_user("folders-idem", password, "admin").await?;
let token = app.login_token("folders-idem", password).await?;
let payload = CreateFolder {
name: "Archive",
parent_id: None,
};
let first_resp = app
.post_json("/api/folders", &payload, Some(&token))
.await?;
assert!(first_resp.status().is_success());
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders", &payload, Some(&token))
.await?;
assert!(second_resp.status().is_success());
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(first_folder.folder.id, second_folder.folder.id);
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let occurrences = root
.subfolders
.iter()
.filter(|folder| folder.id == first_folder.folder.id)
.count();
assert_eq!(occurrences, 1);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn ensure_folder_path_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "pathpass";
app.insert_user("path-admin", password, "admin").await?;
let token = app.login_token("path-admin", password).await?;
let segments = ["500 Immobilien", "501 Kreuzweg 2", "501.01 Rechtliches"];
let payload = EnsureFolderPath {
parent_id: None,
segments: &segments,
};
let first_resp = app
.post_json("/api/folders/path", &payload, Some(&token))
.await?;
assert!(first_resp.status().is_success());
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders/path", &payload, Some(&token))
.await?;
assert!(second_resp.status().is_success());
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(first_folder.folder.id, second_folder.folder.id);
// Verify intermediate folders are not duplicated
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let root_occurrences = root
.subfolders
.iter()
.filter(|folder| folder.name == segments[0])
.count();
assert_eq!(root_occurrences, 1);
let level_one = root
.subfolders
.iter()
.find(|folder| folder.name == segments[0])
.map(|folder| folder.id)
.expect("root segment not created");
let level_one_contents = app
.get(
&format!("/api/folders/{}/contents", level_one),
Some(&token),
)
.await?;
let level_one_body = body_to_vec(level_one_contents.into_body()).await?;
let level_one_folders: FolderContents = serde_json::from_slice(&level_one_body)?;
let level_one_occurrences = level_one_folders
.subfolders
.iter()
.filter(|folder| folder.name == segments[1])
.count();
assert_eq!(level_one_occurrences, 1);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
let _lock = acquire_db_lock().await;
@@ -267,10 +445,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
let rename_resp = app
.patch_json(
&format!("/api/folders/{}", parent.folder.id),
&UpdateFolderRequest {
parent_id: None,
name: Some("Archive".to_string()),
},
&json!({ "name": "Archive" }),
Some(&token),
)
.await?;
+103 -7
View File
@@ -2,11 +2,23 @@ mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use backend::models::{NewUser, NewUserMembership, Tag};
use backend::schema::{
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
users::dsl as users_dsl,
};
use common::{acquire_db_lock, body_to_vec, hash_password, TestApp};
use diesel::prelude::*;
use serde::Deserialize;
use serde::Serialize;
use uuid::Uuid;
#[derive(Serialize)]
struct CreateTagPayload<'a> {
label: &'a str,
color: Option<&'a str>,
}
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentInfo,
@@ -61,12 +73,6 @@ async fn tag_assignment_flow() -> Result<()> {
let upload_body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
#[derive(Serialize)]
struct CreateTagPayload<'a> {
label: &'a str,
color: Option<&'a str>,
}
let create_tag = app
.post_json(
"/api/tags",
@@ -172,3 +178,93 @@ async fn tag_assignment_flow() -> Result<()> {
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn tags_are_isolated_between_tenants() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password_a = "tenant-a";
app.insert_user("alice", password_a, "admin").await?;
let token_a = app.login_token("alice", password_a).await?;
let shared_label = "Shared Label";
let create_a = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: shared_label,
color: Some("#123456"),
},
Some(&token_a),
)
.await?;
assert_eq!(create_a.status(), StatusCode::OK);
let tenant_b_id = Uuid::new_v4();
let user_b_id = Uuid::new_v4();
let password_b = "tenant-b";
app.with_conn(move |conn| {
let storage_root = format!("test-tenants/{tenant_b_id}/");
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(tenant_b_id),
tenants_dsl::slug.eq("tenant-b"),
tenants_dsl::storage_root.eq(Some(storage_root)),
))
.execute(conn)?;
let password_hash = hash_password(password_b)?;
let new_user = NewUser {
id: user_b_id,
username: "bob".to_string(),
password_hash,
};
diesel::insert_into(users_dsl::users)
.values(&new_user)
.execute(conn)?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user_b_id,
tenant_id: tenant_b_id,
role: "admin".to_string(),
};
diesel::insert_into(memberships_dsl::user_memberships)
.values(&membership)
.execute(conn)?;
Ok::<_, anyhow::Error>(())
})
.await?;
let token_b = app.login_token("bob", password_b).await?;
let create_b = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: shared_label,
color: Some("#654321"),
},
Some(&token_b),
)
.await?;
assert_eq!(create_b.status(), StatusCode::OK);
app.with_conn(move |conn| {
let tags: Vec<Tag> = tags_dsl::tags
.filter(tags_dsl::label.eq(shared_label))
.order(tags_dsl::tenant_id.asc())
.load(conn)?;
assert_eq!(tags.len(), 2);
assert_ne!(tags[0].tenant_id, tags[1].tenant_id);
Ok::<_, anyhow::Error>(())
})
.await?;
Ok(())
}
+7 -4
View File
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
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/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, slug } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead.
- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, slug } }`.
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
- GET /api/auth/me - Return the authenticated principal payload.
@@ -16,8 +16,9 @@ Health
Documents
---------
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). 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).
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true unless explicitly set to `false` without filters), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
- GET /api/documents/check?checksum=<sha256> - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata.
- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id` and `role`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<title><original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching 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/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
@@ -46,9 +47,11 @@ 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 - Fetch folder metadata.
- GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root.
- DELETE /api/folders/:id - Soft-delete a folder.
- PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`).
- GET /api/docs/openapi.json - Generated OpenAPI specification (JSON).
Tags
----
+473
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
DownloadIcon,
EditIcon,
@@ -317,12 +318,17 @@ const DetailPanel = ({
onPromoteSelection,
activePreviewId = null,
onUpdateTitle = async () => false,
onUpdateIssuedAt = async () => false,
ensureAssetUrl = null,
getDocumentAsset = () => null,
correspondents = [],
onCorrespondentAdd,
onCorrespondentRemove,
resolveApiPath,
onFolderNavigate = null,
ollamaUrl = null,
ollamaModel = 'llama3',
resolveFolderPath = null,
onClose = () => {},
}) => {
const selectedCount = selectedDocuments.length;
@@ -351,6 +357,12 @@ const DetailPanel = ({
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrError, setOcrError] = useState(null);
const [zoomedPreview, setZoomedPreview] = useState(null);
const ocrTextCacheRef = useRef({ docId: null, text: null });
const [aiSuggestion, setAiSuggestion] = useState(null);
const [aiSuggestionSelection, setAiSuggestionSelection] = useState({ title: true, issuedAt: true });
const [aiSuggestionLoading, setAiSuggestionLoading] = useState(false);
const [aiSuggestionError, setAiSuggestionError] = useState(null);
const [aiSuggestionApplying, setAiSuggestionApplying] = useState(false);
useEffect(() => {
if (!singleDoc) {
@@ -384,6 +396,15 @@ const DetailPanel = ({
setZoomedPreview(null);
}, [selectionKey]);
useEffect(() => {
ocrTextCacheRef.current = { docId: null, text: null };
setAiSuggestion(null);
setAiSuggestionSelection({ title: true, issuedAt: true });
setAiSuggestionError(null);
setAiSuggestionLoading(false);
setAiSuggestionApplying(false);
}, [singleDoc?.id]);
const startTitleEdit = useCallback(() => {
if (!singleDoc) return;
setTitleEditDocId(singleDoc.id);
@@ -437,6 +458,40 @@ const DetailPanel = ({
[singleDoc, getDocumentAsset],
);
const effectiveOllamaUrl = useMemo(
() => (ollamaUrl ? String(ollamaUrl).trim().replace(/\/$/, '') : null),
[ollamaUrl],
);
const effectiveOllamaModel = useMemo(
() => (ollamaModel ? String(ollamaModel).trim() || 'llama3' : 'llama3'),
[ollamaModel],
);
const canSuggestTitle = Boolean(singleDoc);
const suggestionDisabledReason = useMemo(() => {
if (!singleDoc) {
return 'Select a single document to generate suggestions.';
}
if (!hasOcrAsset) {
return 'OCR text is required to generate suggestions.';
}
if (!effectiveOllamaUrl) {
return 'Set an Ollama URL to enable AI suggestions.';
}
return null;
}, [singleDoc, hasOcrAsset, effectiveOllamaUrl]);
const suggestButtonDisabled = Boolean(aiSuggestionLoading || suggestionDisabledReason);
const canApplyAiSuggestion = useMemo(() => {
if (!aiSuggestion) {
return false;
}
const applyTitle = aiSuggestionSelection.title && Boolean(aiSuggestion.title);
const applyIssuedAt = aiSuggestionSelection.issuedAt && Boolean(aiSuggestion.issuedAt);
return applyTitle || applyIssuedAt;
}, [aiSuggestion, aiSuggestionSelection]);
const loadOcrUrl = useCallback(async () => {
if (!singleDoc) {
return;
@@ -479,6 +534,138 @@ const DetailPanel = ({
}
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
const fetchOcrText = useCallback(async () => {
if (!singleDoc) {
throw new Error('No document selected.');
}
const cached = ocrTextCacheRef.current;
if (cached?.docId === singleDoc.id && typeof cached.text === 'string') {
return cached.text;
}
const asset = getDocumentAsset(singleDoc, 'ocr-text');
if (!asset) {
throw new Error('No OCR text available for this document.');
}
let entry = asset;
let url = entry?.url ||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
if (!url && typeof ensureAssetUrl === 'function') {
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
if (ensured) {
entry = ensured;
}
url = entry?.url ||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
}
if (!url) {
throw new Error('OCR text URL is unavailable.');
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch OCR text (status ${response.status})`);
}
const text = await response.text();
const trimmed = (text || '').trim();
if (!trimmed) {
throw new Error('OCR text is empty.');
}
ocrTextCacheRef.current = { docId: singleDoc.id, text: trimmed };
return trimmed;
}, [singleDoc, ensureAssetUrl, getDocumentAsset]);
const extractSuggestionPayload = useCallback((raw) => {
const tryParse = (input) => {
if (!input) return null;
try {
return JSON.parse(input);
} catch (error) {
return null;
}
};
const trimmed = (raw || '').trim();
if (!trimmed) {
return null;
}
let payload = tryParse(trimmed);
if (!payload) {
const fenced = trimmed.match(/```json([\s\S]*?)```/i);
if (fenced) {
payload = tryParse(fenced[1]);
}
}
if (!payload) {
const firstObject = trimmed.match(/\{[\s\S]*\}/);
if (firstObject) {
payload = tryParse(firstObject[0]);
}
}
if (!payload || typeof payload !== 'object') {
return null;
}
const title = typeof payload.title === 'string' ? payload.title : null;
const issuedAt =
typeof payload.issued_at === 'string'
? payload.issued_at
: typeof payload.issuedAt === 'string'
? payload.issuedAt
: null;
return { title, issuedAt };
}, []);
const normalizeIssuedAtSuggestion = useCallback((input) => {
if (!input) {
return null;
}
const raw = String(input).trim();
if (!raw || raw.toLowerCase() === 'null') {
return null;
}
const isoMatch = raw.match(/(\d{4})[-/.](\d{2})[-/.](\d{2})/);
let year;
let month;
let day;
if (isoMatch) {
year = isoMatch[1];
month = isoMatch[2];
day = isoMatch[3];
} else {
const parsed = Date.parse(raw);
if (Number.isNaN(parsed)) {
return null;
}
const date = new Date(parsed);
year = String(date.getUTCFullYear());
month = String(date.getUTCMonth() + 1).padStart(2, '0');
day = String(date.getUTCDate()).padStart(2, '0');
}
if (!year || !month || !day) {
return null;
}
return `${year}-${month}-${day}T00:00:00Z`;
}, []);
const openOcrModal = useCallback(() => {
if (!singleDoc) {
return;
@@ -491,6 +678,154 @@ const DetailPanel = ({
setOcrOpen(false);
}, []);
const handleSuggestTitle = useCallback(async () => {
if (!singleDoc) {
return;
}
if (suggestionDisabledReason) {
setAiSuggestionError(suggestionDisabledReason);
return;
}
setAiSuggestionLoading(true);
setAiSuggestionError(null);
try {
const ocrText = await fetchOcrText();
const snippetLimit = 6000;
const trimmedOcr = ocrText.length > snippetLimit ? `${ocrText.slice(0, snippetLimit)}` : ocrText;
const prompt = `You are helping rename scanned documents. Using only the OCR extract below, reply with a single JSON object of the form:
{
"title": "<short descriptive title in the document's original language or null>",
"issued_at": "<ISO8601 date YYYY-MM-DD if a clear issue/publication date exists, otherwise null>"
}
The title should be precise and descriptive (max 12 words), keep key entities (people, companies, case numbers, etc.), stay in the document's language, and omit surrounding quotes or trailing punctuation. Only include a date when the text clearly indicates one.
OCR TEXT:
${trimmedOcr}`;
const body = {
model: effectiveOllamaModel,
prompt,
stream: false,
};
const suggestionResponse = await fetch(`${effectiveOllamaUrl}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!suggestionResponse.ok) {
throw new Error(`Ollama request failed (${suggestionResponse.status})`);
}
const data = await suggestionResponse.json();
const raw = (data?.response || data?.text || '').trim();
if (!raw) {
throw new Error('Model returned an empty response.');
}
const payload = extractSuggestionPayload(raw);
if (!payload) {
throw new Error('Unable to parse suggestion response.');
}
const cleanedTitle = payload.title
? payload.title
.replace(/^['"\s]+/, '')
.replace(/['"\s]+$/, '')
.replace(/[.!?]+$/, '')
.trim()
: null;
const normalizedIssuedAt = normalizeIssuedAtSuggestion(payload.issuedAt);
if (!cleanedTitle && !normalizedIssuedAt) {
throw new Error('No usable title or date found in the response.');
}
setAiSuggestion({
title: cleanedTitle || null,
issuedAt: normalizedIssuedAt,
raw,
model: effectiveOllamaModel,
});
setAiSuggestionSelection({
title: Boolean(cleanedTitle),
issuedAt: Boolean(normalizedIssuedAt),
});
setAiSuggestionError(null);
} catch (error) {
setAiSuggestion(null);
setAiSuggestionSelection({ title: true, issuedAt: true });
setAiSuggestionError(error.message || 'Failed to generate suggestions.');
} finally {
setAiSuggestionLoading(false);
}
}, [
singleDoc,
effectiveOllamaUrl,
effectiveOllamaModel,
fetchOcrText,
extractSuggestionPayload,
normalizeIssuedAtSuggestion,
suggestionDisabledReason,
]);
const handleApplySuggestion = useCallback(async () => {
if (!singleDoc || !aiSuggestion) {
return;
}
const applyTitle = Boolean(aiSuggestionSelection.title && aiSuggestion.title);
const applyIssuedAt = Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt);
if (!applyTitle && !applyIssuedAt) {
setAiSuggestionError('Select at least one suggestion to apply.');
return;
}
setAiSuggestionApplying(true);
setAiSuggestionError(null);
try {
let titleOk = true;
let issuedOk = true;
if (applyTitle) {
titleOk = await onUpdateTitle(singleDoc.id, aiSuggestion.title);
}
if (applyIssuedAt) {
issuedOk = await onUpdateIssuedAt(singleDoc.id, aiSuggestion.issuedAt);
}
if (titleOk && issuedOk) {
setAiSuggestion(null);
setAiSuggestionSelection({ title: true, issuedAt: true });
setAiSuggestionError(null);
} else {
const failures = [];
if (!titleOk && applyTitle) failures.push('title');
if (!issuedOk && applyIssuedAt) failures.push('date');
setAiSuggestionError(`Failed to update ${failures.join(' and ')}.`);
}
} finally {
setAiSuggestionApplying(false);
}
}, [
singleDoc,
aiSuggestion,
aiSuggestionSelection,
onUpdateTitle,
onUpdateIssuedAt,
]);
const handleDismissSuggestion = useCallback(() => {
setAiSuggestion(null);
setAiSuggestionSelection({ title: true, issuedAt: true });
setAiSuggestionError(null);
}, []);
const singlePreviewNavigator = useAssetNavigator({
document: singleDoc,
assetType: 'preview',
@@ -670,6 +1005,20 @@ const DetailPanel = ({
return sortCorrespondents(singleDoc.correspondents || []);
}, [singleDoc]);
const singleFolderPath = useMemo(() => {
if (!singleDoc?.folder_id) {
return null;
}
if (typeof resolveFolderPath !== 'function') {
return null;
}
const segments = resolveFolderPath(singleDoc.folder_id);
if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) {
return null;
}
return segments;
}, [singleDoc?.folder_id, resolveFolderPath]);
const bulkCorrespondents = useMemo(() => {
if (selectedDocuments.length <= 1) {
const doc = selectedDocuments[0];
@@ -1039,10 +1388,87 @@ const DetailPanel = ({
>
<EditIcon className="icon-inline" />
</button>
{canSuggestTitle ? (
<button
type="button"
className="secondary detail-title-suggest-button"
onClick={suggestButtonDisabled ? undefined : handleSuggestTitle}
disabled={suggestButtonDisabled}
title={
aiSuggestionLoading
? 'Generating suggestion…'
: suggestionDisabledReason || 'Generate a title suggestion from OCR text'
}
>
{aiSuggestionLoading ? 'Suggesting…' : 'Suggest title'}
</button>
) : null}
</>
)}
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
{aiSuggestionError ? (
<div className="status-inline error">{aiSuggestionError}</div>
) : null}
{aiSuggestionLoading && !aiSuggestion ? (
<div className="status-inline">Generating title suggestion</div>
) : null}
{aiSuggestion ? (
<div className="detail-title-suggestion" role="status" aria-live="polite">
<div className="detail-title-suggestion__label">AI suggestions</div>
<div className="detail-title-suggestion__meta">Generated with {aiSuggestion.model}</div>
<div className="detail-title-suggestion__options">
<label className="detail-title-suggestion__option">
<input
type="checkbox"
checked={Boolean(aiSuggestionSelection.title && aiSuggestion.title)}
disabled={!aiSuggestion.title || aiSuggestionApplying}
onChange={(event) =>
setAiSuggestionSelection((previous) => ({
...previous,
title: event.target.checked,
}))
}
/>
<span>
<strong>Title:</strong>{' '}
{aiSuggestion.title || <em>Not available</em>}
</span>
</label>
<label className="detail-title-suggestion__option">
<input
type="checkbox"
checked={Boolean(aiSuggestionSelection.issuedAt && aiSuggestion.issuedAt)}
disabled={!aiSuggestion.issuedAt || aiSuggestionApplying}
onChange={(event) =>
setAiSuggestionSelection((previous) => ({
...previous,
issuedAt: event.target.checked,
}))
}
/>
<span>
<strong>Issued date:</strong>{' '}
{aiSuggestion.issuedAt
? new Date(aiSuggestion.issuedAt).toLocaleDateString()
: <em>Not available</em>}
</span>
</label>
</div>
<div className="detail-title-suggestion__actions">
<button
type="button"
onClick={handleApplySuggestion}
disabled={aiSuggestionApplying || !canApplyAiSuggestion}
>
{aiSuggestionApplying ? 'Applying…' : 'Apply selected'}
</button>
<button type="button" className="secondary" onClick={handleDismissSuggestion}>
Dismiss
</button>
</div>
</div>
) : null}
<div className="meta">
<div>
<strong>Uploaded:</strong>{' '}
@@ -1063,6 +1489,53 @@ const DetailPanel = ({
<strong>Pages:</strong> {pageCountValue}
</div>
) : null}
{singleFolderPath?.length ? (
<div>
<strong>Folder:</strong>{' '}
<span className="detail-folder-path">
{singleFolderPath.map((segment, index) => {
const label = segment?.name || '…';
const targetId = segment?.id || null;
const key = `${targetId || label}-${index}`;
const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function';
const href = !isClickable
? null
: targetId === 'root'
? '/documents'
: `/documents/folder/${targetId}`;
return (
<React.Fragment key={key}>
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
{isClickable ? (
<a
href={href}
className="detail-folder-path__link"
onClick={(event) => {
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
event.stopPropagation();
onFolderNavigate(targetId);
}}
>
{label}
</a>
) : (
<span className="detail-folder-path__segment">{label}</span>
)}
</React.Fragment>
);
})}
</span>
</div>
) : null}
<div>
<strong>Original filename:</strong>{' '}
{singleDoc.original_name}
+373 -12
View File
@@ -55,7 +55,26 @@ const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const API_ROOT = (runtimeApiBase || process.env.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
const ENV = typeof process !== 'undefined' && process?.env ? process.env : {};
const API_ROOT = (runtimeApiBase || ENV.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, '');
const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434';
const runtimeOllamaUrl =
typeof window !== 'undefined' && window.__PAPERCRATE_OLLAMA_URL
? String(window.__PAPERCRATE_OLLAMA_URL)
: '';
const envOllamaUrl = (ENV.OLLAMA_BASE_URL || '').trim();
const rawOllamaUrl = (runtimeOllamaUrl || envOllamaUrl || DEFAULT_OLLAMA_URL).trim();
const OLLAMA_BASE_URL = rawOllamaUrl ? rawOllamaUrl.replace(/\/$/, '') : null;
const DEFAULT_OLLAMA_MODEL = 'gpt-oss';
const runtimeOllamaModel =
typeof window !== 'undefined' && window.__PAPERCRATE_OLLAMA_MODEL
? String(window.__PAPERCRATE_OLLAMA_MODEL)
: '';
const envOllamaModel = (ENV.OLLAMA_MODEL || '').trim();
const OLLAMA_MODEL = (runtimeOllamaModel || envOllamaModel || DEFAULT_OLLAMA_MODEL).trim() || DEFAULT_OLLAMA_MODEL;
const api = axios.create({
baseURL: API_ROOT ? `${API_ROOT}/api` : '/api',
@@ -63,6 +82,16 @@ const api = axios.create({
});
const STORED_TOKEN = window.localStorage.getItem('papercrate_token') || '';
let STORED_TENANT = null;
try {
const rawTenant = window.localStorage.getItem('papercrate_tenant');
if (rawTenant) {
STORED_TENANT = JSON.parse(rawTenant);
}
} catch (
// eslint-disable-next-line no-empty
error
) {}
if (STORED_TOKEN) {
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
}
@@ -73,6 +102,8 @@ const initialAppState = {
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: STORED_TENANT,
tenants: [],
};
const AppStateContext = React.createContext(null);
@@ -81,7 +112,14 @@ const AppDispatchContext = React.createContext(null);
const appStateReducer = (state, action) => {
switch (action.type) {
case 'LOGIN_REQUEST':
return { ...state, status: 'authenticating', error: null, tenantSelection: null };
return {
...state,
status: 'authenticating',
error: null,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGIN_SUCCESS':
return {
...state,
@@ -89,6 +127,8 @@ const appStateReducer = (state, action) => {
token: action.token,
error: null,
tenantSelection: null,
tenant: action.tenant || null,
tenants: state.tenants,
};
case 'LOGIN_FAILURE':
return {
@@ -97,6 +137,8 @@ const appStateReducer = (state, action) => {
error: action.error || null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'TENANT_SELECTION_REQUIRED':
return {
@@ -108,6 +150,8 @@ const appStateReducer = (state, action) => {
selectionToken: action.selectionToken,
tenants: action.tenants,
},
tenant: null,
tenants: [],
};
case 'CLEAR_TENANT_SELECTION':
return {
@@ -116,6 +160,8 @@ const appStateReducer = (state, action) => {
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'BOOTSTRAP_START':
return { ...state, status: 'bootstrapping', error: null };
@@ -132,6 +178,8 @@ const appStateReducer = (state, action) => {
isRefreshing: false,
status: state.status === 'logged-out' ? 'authenticated' : state.status,
tenantSelection: null,
tenant: action.tenant || state.tenant || null,
tenants: state.tenants,
};
case 'TOKEN_REFRESH_FAILURE':
return {
@@ -140,11 +188,26 @@ const appStateReducer = (state, action) => {
error: action.error || null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGOUT':
return { status: 'logged-out', token: '', error: null, isRefreshing: false, tenantSelection: null };
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'RESET_ERROR':
return { ...state, error: null };
case 'SET_TENANTS':
return {
...state,
tenants: Array.isArray(action.tenants) ? action.tenants : [],
};
default:
return state;
}
@@ -164,6 +227,45 @@ const AppStateProvider = ({ children }) => {
}
}, [state.token]);
useEffect(() => {
if (state.tenant) {
try {
window.localStorage.setItem('papercrate_tenant', JSON.stringify(state.tenant));
} catch (error) {
console.warn('Failed to persist tenant info', error);
}
} else {
window.localStorage.removeItem('papercrate_tenant');
}
}, [state.tenant]);
useEffect(() => {
let abort = false;
const loadTenants = async () => {
if (state.status !== 'authenticated' || !state.token) {
dispatch({ type: 'SET_TENANTS', tenants: [] });
return;
}
try {
const { data } = await api.get('/auth/tenants');
if (!abort) {
dispatch({
type: 'SET_TENANTS',
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
});
}
} catch (error) {
if (!abort) {
console.warn('Failed to load tenant list', error);
}
}
};
loadTenants();
return () => {
abort = true;
};
}, [state.status, state.token, dispatch]);
const stateValue = useMemo(() => state, [state]);
return (
@@ -416,7 +518,11 @@ const AppLayout = () => {
try {
const { data } = await api.post('/auth/refresh');
if (data?.access_token) {
appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: data.access_token });
appDispatch({
type: 'TOKEN_REFRESH_SUCCESS',
token: data.access_token,
tenant: data.tenant || null,
});
console.log('[Auth] Access token refreshed at', new Date().toISOString());
return data.access_token;
}
@@ -594,6 +700,7 @@ const AppLayout = () => {
const selectionInitializedRef = useRef(false);
const dragCounterRef = useRef(0);
const prefetchedFoldersRef = useRef(new Set(['root']));
const detailFolderFetchRef = useRef(new Set());
const selectionAnchorRef = useRef(initialRowSelection[initialRowSelection.length - 1] || null);
const selectionOrderRef = useRef(initialRowSelection);
@@ -643,6 +750,7 @@ const AppLayout = () => {
dragCounterRef.current = 0;
prefetchedFoldersRef.current = new Set(['root']);
breadcrumbFetchRef.current = new Set();
detailFolderFetchRef.current = new Set();
bootstrapInitializedRef.current = false;
selectionInitializedRef.current = false;
}, [assetManager]);
@@ -1868,19 +1976,134 @@ const AppLayout = () => {
greedyPrefetchFolders(pendingIds).catch(() => {});
}, [folderContents, greedyPrefetchFolders]);
const expandFolderAncestors = useCallback(
(targetId) => {
if (!targetId || targetId === 'root') {
setFolderNodes((prev) => {
if (prev.get('root')?.expanded) {
return prev;
}
const next = new Map(prev);
const rootNode = next.get('root');
if (rootNode) {
next.set('root', { ...rootNode, expanded: true });
}
return next;
});
return;
}
setFolderNodes((prev) => {
const next = new Map(prev);
let currentId = targetId;
let guard = 0;
while (currentId && !next.has(currentId) && guard < 32) {
guard += 1;
const node = prev.get(currentId);
if (!node) {
break;
}
currentId = node.parentId ?? 'root';
}
currentId = targetId;
guard = 0;
while (currentId && guard < 32) {
guard += 1;
const node = next.get(currentId);
if (!node) {
break;
}
if (!node.expanded && currentId !== targetId) {
next.set(currentId, { ...node, expanded: true });
}
currentId = node.parentId ?? 'root';
if (!currentId || currentId === 'root') {
const rootNode = next.get('root');
if (rootNode && !rootNode.expanded) {
next.set('root', { ...rootNode, expanded: true });
}
break;
}
}
return next;
});
},
[]);
const ensureFolderAncestorsLoaded = useCallback(
async (targetId) => {
if (!targetId || targetId === 'root') {
return;
}
const visited = new Set();
const stack = [];
let currentId = targetId;
let guard = 0;
while (currentId && currentId !== 'root' && guard < 32) {
guard += 1;
if (visited.has(currentId)) {
break;
}
visited.add(currentId);
const node = folderNodes.get(currentId);
if (!node) {
stack.push(currentId);
break;
}
const parentId = node.parentId ?? 'root';
if (!parentId || parentId === 'root') {
break;
}
stack.push(parentId);
currentId = parentId;
}
while (stack.length) {
const ancestorId = stack.pop();
if (!ancestorId || ancestorId === 'root') {
continue;
}
if (!folderNodes.has(ancestorId)) {
try {
await ensureFolderData(ancestorId, { includeDocuments: false, force: false });
} catch (error) {
console.warn('Failed to ensure ancestor folder for navigation', ancestorId, error);
}
}
}
},
[folderNodes, ensureFolderData],
);
const selectFolder = useCallback(
async (folderId, { replace = false, immediate = false } = {}) => {
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
await ensureFolderAncestorsLoaded(targetId);
expandFolderAncestors(targetId);
if (!navigate || immediate) {
await loadFolder(targetId, { preserveSearch: isFilterActive });
setSelectedFolder(targetId);
return;
}
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace });
},
[loadFolder, navigate, isFilterActive],
[
ensureFolderAncestorsLoaded,
expandFolderAncestors,
navigate,
loadFolder,
isFilterActive,
setSelectedFolder,
],
);
const initializeAfterLogin = useCallback(async () => {
@@ -3347,6 +3570,38 @@ const AppLayout = () => {
[assetManager, notifyApiError, setStatusMessage, updateDocumentCaches],
);
const handleDocumentIssuedAtUpdate = useCallback(
async (documentId, nextIssuedAt) => {
const payload = {
issued_at: nextIssuedAt ? new Date(nextIssuedAt).toISOString() : null,
};
setLoading(true);
try {
const { data } = await api.patch(`/documents/${documentId}`, payload);
const hydratedDetail = assetManager.hydrateDetail(data);
const hydratedDocument = hydratedDetail?.document || data.document || data;
updateDocumentCaches(documentId, (doc) => {
if (hydratedDocument) {
return { ...doc, ...hydratedDocument };
}
return { ...doc, issued_at: payload.issued_at };
});
setStatusMessage('Document issued date updated.', 'success');
return true;
} catch (error) {
const message = error.response?.data?.error || 'Failed to update issued date.';
notifyApiError(error, message);
return false;
} finally {
setLoading(false);
}
},
[assetManager, api, notifyApiError, setStatusMessage, updateDocumentCaches],
);
const applyTagRemovalToCaches = useCallback(
(documentId, tagId) => {
if (!documentId || !tagId) {
@@ -4079,10 +4334,10 @@ const AppLayout = () => {
appDispatch({ type: 'LOGIN_REQUEST' });
const { data } = await api.post('/auth/login', payload);
if (data?.selection_token && Array.isArray(data?.tenants)) {
if (data?.access_token && Array.isArray(data?.tenants)) {
appDispatch({
type: 'TENANT_SELECTION_REQUIRED',
selectionToken: data.selection_token,
selectionToken: data.access_token,
tenants: data.tenants,
});
setStatusMessage('Select a tenant to continue.', 'info');
@@ -4093,7 +4348,11 @@ const AppLayout = () => {
throw new Error('Invalid login response.');
}
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
appDispatch({
type: 'LOGIN_SUCCESS',
token: data.access_token,
tenant: data.tenant || null,
});
setStatusMessage('Login successful.', 'success');
} catch (error) {
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
@@ -4450,6 +4709,95 @@ const AppLayout = () => {
});
}, [missingBreadcrumbAncestors, ensureFolderData]);
useEffect(() => {
if (!orderedSelectedDocuments.length) {
return;
}
const visited = new Set();
orderedSelectedDocuments.forEach((doc) => {
const folderId = doc?.folder_id;
if (!folderId) {
return;
}
let currentId = folderId;
let guard = 0;
while (currentId && currentId !== 'root' && guard < 32) {
guard += 1;
if (visited.has(currentId)) {
break;
}
visited.add(currentId);
const node = folderNodes.get(currentId);
if (!node) {
if (!detailFolderFetchRef.current.has(currentId)) {
detailFolderFetchRef.current.add(currentId);
ensureFolderData(currentId, { force: false, includeDocuments: false })
.catch((error) => {
console.warn('Failed to preload folder metadata for detail path', currentId, error);
})
.finally(() => {
detailFolderFetchRef.current.delete(currentId);
});
}
break;
}
const parentId = node.parentId ?? 'root';
if (!parentId || parentId === 'root') {
break;
}
currentId = parentId;
}
});
}, [orderedSelectedDocuments, folderNodes, ensureFolderData]);
const resolveFolderPath = useCallback(
(folderId) => {
if (!folderId || folderId === 'root') {
return [];
}
const segments = [];
const visited = new Set();
let currentId = folderId;
let guard = 0;
while (currentId && guard < 32 && !visited.has(currentId)) {
guard += 1;
visited.add(currentId);
if (currentId === 'root') {
break;
}
const node = folderNodes.get(currentId);
if (!node) {
segments.push({ id: currentId, name: '…' });
break;
}
segments.push({ id: node.id, name: node.name || 'Folder' });
const parentId = node.parentId ?? 'root';
if (!parentId || parentId === 'root') {
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
break;
}
currentId = parentId;
}
if (!segments.some((segment) => segment.id === 'root')) {
segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
}
return segments.reverse();
},
[folderNodes],
);
const selectedPreviewEntry = useMemo(() => {
if (!selectedDocument) {
return null;
@@ -4592,13 +4940,18 @@ const AppLayout = () => {
onPromoteSelection: promoteSelectionOrder,
activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate,
onUpdateIssuedAt: handleDocumentIssuedAtUpdate,
ensureAssetUrl,
getDocumentAsset,
correspondents,
onCorrespondentAdd: handleCorrespondentAdd,
onCorrespondentRemove: handleCorrespondentRemove,
resolveApiPath,
onFolderNavigate: selectFolder,
onClose: clearDocumentSelection,
resolveFolderPath,
ollamaUrl: OLLAMA_BASE_URL,
ollamaModel: OLLAMA_MODEL,
};
const skeuoWorkspaceProps = useMemo(
@@ -5114,10 +5467,10 @@ const LoginRoute = () => {
try {
appDispatch({ type: 'LOGIN_REQUEST' });
const { data } = await api.post('/auth/login', payload);
if (data?.selection_token && Array.isArray(data?.tenants)) {
if (data?.access_token && Array.isArray(data?.tenants)) {
appDispatch({
type: 'TENANT_SELECTION_REQUIRED',
selectionToken: data.selection_token,
selectionToken: data.access_token,
tenants: data.tenants,
});
setStatusMessage('Select a tenant to continue.', 'info');
@@ -5128,7 +5481,11 @@ const LoginRoute = () => {
throw new Error('Invalid login response.');
}
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
appDispatch({
type: 'LOGIN_SUCCESS',
token: data.access_token,
tenant: data.tenant || null,
});
setStatusMessage('Login successful.', 'success');
} catch (error) {
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
@@ -5161,7 +5518,11 @@ const LoginRoute = () => {
throw new Error('Invalid tenant selection response.');
}
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
appDispatch({
type: 'LOGIN_SUCCESS',
token: data.access_token,
tenant: data.tenant || null,
});
setStatusMessage('Login successful.', 'success');
} catch (error) {
const message = error?.response?.data?.error || 'Failed to finalize login.';
-1
View File
@@ -55,7 +55,6 @@
display: flex;
flex-direction: column;
align-items: center;
transition: width 0.28s ease, height 0.28s ease;
}
.skeuo-item:focus-visible {
+144 -63
View File
@@ -60,9 +60,12 @@ const SkeuoPreviewCard = ({
prefetch,
});
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
const docId = doc?.id ?? null;
const metadataWidth = Number(currentMetadata?.width);
const metadataHeight = Number(currentMetadata?.height);
useEffect(() => {
if (!onNavigatorSnapshot || !docId) {
return undefined;
@@ -74,6 +77,9 @@ const SkeuoPreviewCard = ({
canGoNext,
goPrev: navigator.goPrev,
goNext: navigator.goNext,
ordinal,
width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null,
height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null,
};
onNavigatorSnapshot(docId, snapshot);
return () => onNavigatorSnapshot(docId, null);
@@ -83,6 +89,9 @@ const SkeuoPreviewCard = ({
title,
canGoPrev,
canGoNext,
ordinal,
metadataWidth,
metadataHeight,
navigator.goPrev,
navigator.goNext,
onNavigatorSnapshot,
@@ -555,6 +564,37 @@ const clamp = (value, min, max) => {
return value;
};
const clampCardDimensions = (width, height) => {
let nextWidth = Number(width);
let nextHeight = Number(height);
if (!Number.isFinite(nextWidth) || nextWidth <= 0 || !Number.isFinite(nextHeight) || nextHeight <= 0) {
return null;
}
const scaleDown = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight);
nextWidth *= scaleDown;
nextHeight *= scaleDown;
const minDim = Math.min(nextWidth, nextHeight);
if (minDim > 0 && minDim < CARD_MIN) {
const scaleUp = CARD_MIN / minDim;
nextWidth *= scaleUp;
nextHeight *= scaleUp;
const adjust = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight);
nextWidth *= adjust;
nextHeight *= adjust;
}
nextWidth = clamp(nextWidth, CARD_MIN, CARD_MAX);
nextHeight = clamp(nextHeight, CARD_MIN, CARD_MAX);
return {
width: nextWidth,
height: nextHeight,
};
};
const formatTransform = (x, y, rotation = 0, scale = 1) =>
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
@@ -602,43 +642,90 @@ const SkeuomorphicWorkspace = ({
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
const [docSizeVersion, setDocSizeVersion] = useState(0);
const [tagDropTargetId, setTagDropTargetId] = useState(null);
const [pendingTagDocId, setPendingTagDocId] = useState(null);
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
const draggingTagRef = useRef(null);
const pendingDocTagDragRef = useRef(null);
const docSizeMapRef = useRef(new Map());
const documentLookupRef = useRef(new Map());
const removalCursorActiveRef = useRef(false);
const handleNavigatorSnapshot = useCallback((docId, snapshot) => {
if (!docId) {
return;
}
setPreviewSnapshots((previous) => {
const prevSnapshot = previous.get(docId);
if (!snapshot) {
if (!previous.has(docId)) {
const applySnapshotDimensions = useCallback(
(docId, snapshot) => {
if (!docId || !snapshot) {
return;
}
const doc = documentLookupRef.current.get(docId);
if (!doc) {
return;
}
const width = Number(snapshot.width);
const height = Number(snapshot.height);
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
return;
}
const normalized = clampCardDimensions(width, height);
if (!normalized) {
return;
}
const key = resolveSizeKey(doc);
const existing = docSizeMapRef.current.get(key);
if (existing && existing.width === normalized.width && existing.height === normalized.height) {
return;
}
docSizeMapRef.current.set(key, normalized);
setDocSizeVersion((value) => value + 1);
},
[],
);
const handleNavigatorSnapshot = useCallback(
(docId, snapshot) => {
if (!docId) {
return;
}
setPreviewSnapshots((previous) => {
const prevSnapshot = previous.get(docId);
if (!snapshot) {
if (!previous.has(docId)) {
return previous;
}
const next = new Map(previous);
next.delete(docId);
return next;
}
const next = new Map(previous);
const sameSnapshot =
prevSnapshot &&
prevSnapshot.url === snapshot.url &&
prevSnapshot.alt === snapshot.alt &&
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
prevSnapshot.canGoNext === snapshot.canGoNext &&
prevSnapshot.goPrev === snapshot.goPrev &&
prevSnapshot.goNext === snapshot.goNext &&
prevSnapshot.ordinal === snapshot.ordinal &&
prevSnapshot.width === snapshot.width &&
prevSnapshot.height === snapshot.height;
if (sameSnapshot) {
return previous;
}
const next = new Map(previous);
next.delete(docId);
next.set(docId, snapshot);
return next;
});
if (snapshot) {
applySnapshotDimensions(docId, snapshot);
}
const next = new Map(previous);
const sameSnapshot =
prevSnapshot &&
prevSnapshot.url === snapshot.url &&
prevSnapshot.alt === snapshot.alt &&
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
prevSnapshot.canGoNext === snapshot.canGoNext &&
prevSnapshot.goPrev === snapshot.goPrev &&
prevSnapshot.goNext === snapshot.goNext;
if (sameSnapshot) {
return previous;
}
next.set(docId, snapshot);
return next;
});
}, []);
},
[applySnapshotDimensions],
);
const activeTagSet = useMemo(() => {
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
return new Set();
@@ -663,6 +750,15 @@ const SkeuomorphicWorkspace = ({
const resolvePreviewDimensions = useCallback(
(doc) => {
if (!doc) return null;
const snapshot = previewSnapshots.get(doc.id);
if (snapshot && snapshot.width && snapshot.height) {
return {
width: snapshot.width,
height: snapshot.height,
};
}
const asset = resolvePreviewAsset(doc);
const view = createAssetView(asset);
const metadata = view.getPrimaryMetadata() || {};
@@ -673,7 +769,7 @@ const SkeuomorphicWorkspace = ({
}
return null;
},
[resolvePreviewAsset],
[previewSnapshots, resolvePreviewAsset],
);
useEffect(() => {
@@ -780,48 +876,26 @@ const SkeuomorphicWorkspace = ({
if (cache) {
return cache;
}
let width;
let height;
const intrinsic = resolvePreviewDimensions(doc);
let normalized = null;
if (intrinsic?.width && intrinsic?.height) {
width = intrinsic.width;
height = intrinsic.height;
} else {
normalized = clampCardDimensions(intrinsic.width, intrinsic.height);
}
if (!normalized) {
const seed = seededRandom(`${key}:size`);
width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN);
let width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN);
width = clamp(width, CARD_MIN, Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT));
height = width * EMPTY_CARD_ASPECT;
const height = width * EMPTY_CARD_ASPECT;
normalized = clampCardDimensions(width, height);
}
if (!Number.isFinite(width) || width <= 0) {
width = CARD_MIN;
}
if (!Number.isFinite(height) || height <= 0) {
height = CARD_MIN;
if (!normalized) {
normalized = { width: CARD_MIN, height: CARD_MIN };
}
const scaleDown = Math.min(1, CARD_MAX / width, CARD_MAX / height);
width *= scaleDown;
height *= scaleDown;
const minDim = Math.min(width, height);
if (minDim < CARD_MIN) {
const scaleUp = CARD_MIN / minDim;
width *= scaleUp;
height *= scaleUp;
const adjust = Math.min(1, CARD_MAX / width, CARD_MAX / height);
width *= adjust;
height *= adjust;
}
width = clamp(width, CARD_MIN, CARD_MAX);
height = clamp(height, CARD_MIN, CARD_MAX);
const size = { width, height };
docSizeMapRef.current.set(key, size);
return size;
docSizeMapRef.current.set(key, normalized);
return normalized;
}, [resolvePreviewDimensions]);
const documentLookup = useMemo(() => {
@@ -834,8 +908,13 @@ const SkeuomorphicWorkspace = ({
return map;
}, [items]);
useEffect(() => {
documentLookupRef.current = documentLookup;
}, [documentLookup]);
useEffect(() => {
docSizeMapRef.current = new Map();
setDocSizeVersion((value) => value + 1);
}, [items]);
const overlayDisplay = useMemo(() => {
@@ -904,7 +983,8 @@ const SkeuomorphicWorkspace = ({
const container = containerRef.current;
if (!container) return () => {};
if (process.env.NODE_ENV !== 'production') {
const isDevEnv = typeof process !== 'undefined' && process?.env?.NODE_ENV !== 'production';
if (isDevEnv) {
console.log('[skeuo] canvas element', container);
}
@@ -1020,6 +1100,7 @@ const SkeuomorphicWorkspace = ({
items,
canvasSize.width,
canvasSize.height,
docSizeVersion,
ensureDocumentSize,
syncLayoutSnapshot,
]);
+99
View File
@@ -71,6 +71,25 @@ body {
overflow: hidden;
}
a {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
transition: color 0.15s ease, text-decoration-color 0.15s ease;
}
a:visited {
color: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%);
}
a:hover,
a:focus-visible {
color: var(--accent-hover);
outline: none;
text-decoration-color: currentColor;
}
#app {
height: 100%;
background: var(--bg);
@@ -1821,6 +1840,86 @@ button.danger:hover:not([disabled]) {
color: var(--muted);
}
.detail-panel .detail-title-suggest-button {
margin-left: 0.25rem;
}
.detail-panel .detail-title-suggestion {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: var(--surface-subtle);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.detail-panel .detail-title-suggestion__label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.detail-panel .detail-title-suggestion__value {
font-weight: 600;
color: var(--fg);
}
.detail-panel .detail-title-suggestion__meta {
font-size: 0.75rem;
color: var(--muted);
}
.detail-panel .detail-title-suggestion__actions {
display: flex;
gap: 0.5rem;
}
.detail-panel .detail-title-suggestion__options {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.detail-panel .detail-title-suggestion__option {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
}
.detail-panel .detail-title-suggestion__option input[type='checkbox'] {
width: 1rem;
height: 1rem;
}
.detail-panel .detail-folder-path {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
}
.detail-panel .detail-folder-path__link {
color: var(--accent);
text-decoration: none;
}
.detail-panel .detail-folder-path__link:hover,
.detail-panel .detail-folder-path__link:focus-visible {
text-decoration: underline;
outline: none;
}
.detail-panel .detail-folder-path__separator {
color: var(--muted);
}
.detail-panel .detail-folder-path__segment {
color: var(--fg);
}
.detail-panel .doc-title-row {
display: flex;
align-items: center;
+2
View File
@@ -48,6 +48,8 @@ module.exports = {
}),
new webpack.DefinePlugin({
'process.env.API_BASE_URL': JSON.stringify(API_BASE_URL),
'process.env.OLLAMA_BASE_URL': JSON.stringify(process.env.OLLAMA_BASE_URL || ''),
'process.env.OLLAMA_MODEL': JSON.stringify(process.env.OLLAMA_MODEL || ''),
}),
],
devServer: {
@@ -1,34 +0,0 @@
{{- if and .Values.quickwit.enabled .Values.quickwit.indexJob.enabled .Values.quickwit.indexJob.indexConfig.create }}
{{- $cfg := .Values.quickwit.indexJob.indexConfig -}}
{{- $config := dict "value" "" -}}
{{- if $cfg.file }}
{{- with $.Files.Get $cfg.file -}}
{{- $_ := set $config "value" . -}}
{{- end -}}
{{- end -}}
{{- if and (not (index $config "value")) $cfg.contents }}
{{- $_ := set $config "value" $cfg.contents -}}
{{- end -}}
{{- if eq (trim (default "" (index $config "value"))) "" -}}
{{- fail "Quickwit index configuration requires either indexConfig.file or indexConfig.contents" -}}
{{- end }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "papercrate.fullname" . }}-quickwit-index
labels:
{{- include "papercrate.labels" . | nindent 4 }}
app.kubernetes.io/component: quickwit
app.kubernetes.io/part-of: quickwit-index
data:
{{ $cfg.key }}: |
{{ index $config "value" | indent 4 }}
{{- $ctx := dict "storageUri" (.Values.quickwit.env.storageUri | default "") -}}
{{- if and (eq (index $ctx "storageUri") "") .Values.global.s3.bucket -}}
{{- $_ := set $ctx "storageUri" (printf "s3://%s/quickwit" .Values.global.s3.bucket) -}}
{{- end -}}
{{- $storageUri := index $ctx "storageUri" -}}
{{- if $storageUri }}
{{ printf "index_uri: %s/documents" (trimSuffix "/" $storageUri) | indent 4 }}
{{- end }}
{{- end }}
@@ -1,78 +0,0 @@
{{- if and .Values.quickwit.enabled .Values.quickwit.indexJob.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "papercrate.fullname" . }}-quickwit-index
labels:
{{- include "papercrate.labels" . | nindent 4 }}
app.kubernetes.io/component: quickwit-index
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: {{ .Values.quickwit.indexJob.backoffLimit }}
template:
metadata:
labels:
{{- include "papercrate.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: quickwit-index
spec:
restartPolicy: Never
{{- if or .Values.global.imagePullSecrets .Values.imagePullSecrets }}
imagePullSecrets:
{{- range .Values.global.imagePullSecrets }}
- name: {{ . }}
{{- end }}
{{- range .Values.imagePullSecrets }}
- name: {{ . }}
{{- end }}
{{- end }}
{{- $qw := dict "storageUri" .Values.quickwit.env.storageUri "metastoreUri" .Values.quickwit.env.metastoreUri -}}
{{- if and (not (index $qw "storageUri")) .Values.global.s3.bucket -}}
{{- $_ := set $qw "storageUri" (printf "s3://%s/quickwit" .Values.global.s3.bucket) -}}
{{- end -}}
{{- if not (index $qw "storageUri") -}}
{{- fail "quickwit.env.storageUri must be set, or provide global.s3.bucket" -}}
{{- end -}}
{{- if and (not (index $qw "metastoreUri")) .Values.postgres.enabled -}}
{{- $dbUser := required "postgres.auth.username is required when postgres.enabled" .Values.postgres.auth.username -}}
{{- $dbPass := required "postgres.auth.password is required when postgres.enabled" .Values.postgres.auth.password -}}
{{- $dbHost := printf "%s-postgres" (include "papercrate.fullname" .) -}}
{{- $_ := set $qw "metastoreUri" (printf "postgres://%s:%s@%s:5432/quickwit" $dbUser $dbPass $dbHost) -}}
{{- end -}}
{{- if not (index $qw "metastoreUri") -}}
{{- fail "quickwit.env.metastoreUri must be set when not using bundled Postgres" -}}
{{- end }}
containers:
- name: quickwit-index
image: {{ include "papercrate.quickwit.image" . }}:{{ include "papercrate.image.tag" (dict "image" .Values.quickwit.image "Chart" .Chart) }}
imagePullPolicy: {{ .Values.quickwit.image.pullPolicy }}
command:
- quickwit
- index
- create
args:
- --index-config=/config/{{ .Values.quickwit.indexJob.indexConfig.key }}
- --endpoint=http://{{ include "papercrate.fullname" . }}-quickwit:{{ .Values.quickwit.service.port }}
{{- if or .Values.quickwit.envFrom .Values.global.s3.credentialsSecret }}
envFrom:
{{- with .Values.global.s3.credentialsSecret }}
- secretRef:
name: {{ . }}
{{- end }}
{{- range .Values.quickwit.envFrom }}
- {{ toYaml . | nindent 12 | trim }}
{{- end }}
{{- end }}
{{- if or .Values.quickwit.indexJob.indexConfig.existingConfigMap .Values.quickwit.indexJob.indexConfig.create }}
volumeMounts:
- name: index-config
mountPath: /config
{{- end }}
{{- if or .Values.quickwit.indexJob.indexConfig.existingConfigMap .Values.quickwit.indexJob.indexConfig.create }}
volumes:
- name: index-config
configMap:
name: {{ if .Values.quickwit.indexJob.indexConfig.existingConfigMap }}{{ .Values.quickwit.indexJob.indexConfig.existingConfigMap }}{{ else }}{{ include "papercrate.fullname" . }}-quickwit-index{{ end }}
{{- end }}
{{- end }}
-10
View File
@@ -141,16 +141,6 @@ quickwit:
nodeSelector: {}
tolerations: []
affinity: {}
indexJob:
enabled: true
backoffLimit: 3
indexConfig:
existingConfigMap: ""
key: documents-index.yaml
create: true
file: files/quickwit/documents-index.yaml
contents: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""